diff --git a/README.md b/README.md index d560ade..9343f0c 100644 --- a/README.md +++ b/README.md @@ -4,211 +4,140 @@ CommandRelay logo

-CommandRelay is a secure, bi-directional terminal control gateway for long-running coding sessions. +CommandRelay is a production-oriented gateway for remote terminal control of long-running coding sessions. -It lets you monitor and control home-machine terminal sessions (tmux and ghostty/cmux runtime) from remote clients, with replay, guarded input, and auditability. +## SSH-First Architecture -## Why This Exists +CommandRelay is oriented around an SSH-first operating model: -Long AI coding sessions run for hours while you are away from your main machine. +1. Run the gateway on the machine that owns the terminal runtime. +2. Keep runtime state in `tmux` so sessions survive client disconnects. +3. Reach the gateway from remote clients over an SSH transport path (for example, tunnel + WebSocket) while CommandRelay enforces terminal control policy. -CommandRelay gives you one control surface to: +`ghostty` is an optional local terminal UI. It is not the control backend for remote lane ownership, replay, or policy enforcement. -1. See active terminal sessions. -2. Reattach after disconnects. -3. Send commands safely when needed. -4. Keep read-only mode as default. +## Control Plane (Bi-Directional) -## What You Get +Web and native clients use the same v1 envelope and core flow: -1. WebSocket event protocol with strict envelope validation. -2. Replay-aware terminal output streaming (`streamSeq` + `attach(lastSeq)`). -3. Guarded input flow: `enable_input` -> `input` -> `disable_input`. -4. Kill switch and lane-ownership controls. -5. Runtime backend multiplexer (`tmux`, `cmux`) with backend-aware pane/session routing. -6. Proxy package ecosystem for reusable outbound proxy behavior. +1. `list_sessions` +2. `attach(paneId, lastSeq)` +3. `output` stream with ordered `streamSeq` replay behavior +4. guarded input: `enable_input` -> `input` -> `disable_input` -## Architecture +Safety controls: -### Runtime Topology +1. read-only by default +2. explicit input enable per client +3. global input kill switch +4. pane writer-lane ownership arbitration +5. message/input rate limits and max input bytes +6. audit logging for auth/input/policy events -```text -Remote Client (web/iOS/android/macos) - | - | WS (/ws) - v -+-----------------------------------+ -| CommandRelay Gateway (Node/TS) | -| - Auth / policy / limits | -| - Replay + output stream engine | -| - Input lane arbitration | -+----------------+------------------+ - | - | Runtime multiplexer - v - +-------------------+-------------------+ - | | -+----+------------------+ +-----------+-----------+ -| tmux backend adapter | | cmux backend adapter | -| pane ids: %1, %2 ... | | pane ids: surface-* | -+-----------------------+ +-----------------------+ -``` - -### Event Flow (Condensed) +## Architecture Snapshot ```text -Client -> hello/auth -> list_sessions -> attach(paneId,lastSeq) -Server -> session_list -> output(snapshot/delta, streamSeq) -Client -> enable_input -> input -> disable_input -Server -> ack/error + policy_update +Remote Web / Native Client + | + | SSH transport path + WS (/ws) + v ++----------------------------------------+ +| CommandRelay Gateway (Node/TS) | +| - auth + policy | +| - replay + stream sequencing | +| - input lane arbitration | ++----------------------+-----------------+ + | + v + +------------------+ + | tmux runtime | + | panes: %1, %2... | + +------------------+ + +Local optional UI: Ghostty (operator convenience only) ``` -### Safety State Model +## UX Model (Operator View) ```text -DISCONNECTED - -> AUTHENTICATED_READ_ONLY - -> STREAMING_READ_ONLY - -> STREAMING_INPUT_ENABLED (explicit only) - -> READ_ONLY (disable_input / kill switch / disconnect) ++------------------------------------------------------+ +| Session Tab: "backend-api" | +| +--------------------------+ +----------------------+ | +| | Pane %1 (read-only) | | Pane %2 (writer) | | +| | replay + live output | | input explicitly on | | +| +--------------------------+ +----------------------+ | +| Notifications: lane conflict, input enabled, | +| kill switch active, reconnect + replay complete | ++------------------------------------------------------+ ``` -## Runtime Backends (tmux + ghostty/cmux) +## Proxy Packages: Parallel Track -Configure runtime backends with: +The proxy packages (`@commandrelay/*`, `@termina/proxy-*`) are a parallel product track for outbound HTTP/proxy reuse. -```bash -COMMANDRELAY_RUNTIME_BACKENDS=tmux -# or -COMMANDRELAY_RUNTIME_BACKENDS=tmux,cmux -``` +They are not mandatory for the core terminal-control path (`list/attach/replay/input`) and should be treated as adjacent infrastructure, not a prerequisite for SSH + tmux operation. -Optional cmux command override: +## Quick Start ```bash -COMMANDRELAY_CMUX_COMMAND=/opt/homebrew/bin/cmux +npm install +npm run check +npm start ``` -Notes: - -1. Default backend set is `tmux`. -2. In multi-backend mode, pane IDs are backend-namespaced (`tmux:%1`, `cmux:surface-1`). -3. Startup logs availability per backend. -4. Startup fails only when all configured backends are unavailable in non-tmux-only mode. - -## Security Model - -CommandRelay is secure-by-default: - -1. Read-only mode on connect. -2. Explicit input enable required. -3. Global kill switch available. -4. Per-client input rate limits and max payload limits. -5. Pane ownership arbitration to prevent silent concurrent writers. -6. Audit logging support for auth/input/policy events. - -## Quick Start +Optional SSH startup wiring (remote profile orchestration contract): ```bash -npm install -npm run check +export COMMANDRELAY_TRANSPORT_MODE=ssh +export COMMANDRELAY_SSH_PROFILE=primary +export COMMANDRELAY_SSH_TARGET="dev@relay-host" +export COMMANDRELAY_SSH_COMMAND=ssh +export COMMANDRELAY_SSH_PORT=22 +export COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS=8 +export COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING=true npm start ``` -Default endpoints: +Current runtime data path remains the WS server (`/ws`) plus tmux runtime control. +In `ssh` mode, the bridge runs tmux operations on the remote target over SSH after startup preflight passes. +SSH runtime execution is non-interactive (`-T`, `BatchMode=yes`); when strict host key checking is disabled, runtime uses `UserKnownHostsFile=/dev/null` to suppress known_hosts writes. +`ssh` mode is tmux-only: set `COMMANDRELAY_RUNTIME_BACKENDS=tmux`. + +Default local endpoints (current runtime path): -1. Health: `GET http://127.0.0.1:8787/health` -2. Web app (if enabled): `http://127.0.0.1:8787/app/` -3. WebSocket: `ws://127.0.0.1:8787/ws` +1. `GET http://127.0.0.1:8787/health` +2. `http://127.0.0.1:8787/app/` (when static app hosting is enabled) +3. `ws://127.0.0.1:8787/ws` -## Core Environment Variables +## Core Configuration | Variable | Purpose | | --- | --- | | `COMMANDRELAY_AUTH_TOKEN` | Token auth for non-loopback binds | -| `COMMANDRELAY_RUNTIME_BACKENDS` | Runtime backend list (`tmux,cmux`) | -| `COMMANDRELAY_CMUX_COMMAND` | cmux executable/path override | +| `COMMANDRELAY_RUNTIME_BACKENDS` | Runtime backends (`tmux` default, optional `tmux,cmux`). Must be `tmux` when `COMMANDRELAY_TRANSPORT_MODE=ssh`. | +| `COMMANDRELAY_CMUX_COMMAND` | Optional `cmux` command/path override | +| `COMMANDRELAY_TRANSPORT_MODE` | Startup transport selector (`ws` default, `ssh` enables remote tmux execution over SSH) | +| `COMMANDRELAY_SSH_PROFILE` | SSH profile name (`primary` when unset). If set, must be non-empty and match `[A-Za-z0-9._-]+`. | +| `COMMANDRELAY_SSH_TARGET` | SSH target (required in `ssh` mode) in `[user@]host` format, where host is `name` or bracketed IPv6 (`[2001:db8::1]`). | +| `COMMANDRELAY_SSH_COMMAND` | SSH executable/command override used for preflight and runtime SSH execution (`ssh` default). | +| `COMMANDRELAY_SSH_PORT` | SSH target port override (`22` default) | +| `COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS` | SSH connect/runtime command timeout in seconds (`8` default, allowed `1..60`). | +| `COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING` | SSH strict host key checking policy (`true` default) | | `COMMANDRELAY_INPUT_KILL_SWITCH` | Global input disable switch | -| `COMMANDRELAY_ALLOW_INPUT_OVERRIDE` | Allow explicit pane ownership takeover | -| `COMMANDRELAY_MAX_INPUT_BYTES` | Max input payload bytes | +| `COMMANDRELAY_ALLOW_INPUT_OVERRIDE` | Allow/deny forced lane takeover | +| `COMMANDRELAY_MAX_INPUT_BYTES` | Max input payload size | | `COMMANDRELAY_MAX_MSG_PER_MIN` | Per-client message rate limit | | `COMMANDRELAY_MAX_INPUT_PER_MIN` | Per-client input rate limit | -| `COMMANDRELAY_STRICT_PROTOCOL_PARSING` | Strict envelope parsing toggle | -| `COMMANDRELAY_APP_STATIC_ENABLED` | Enable/disable static web app hosting | -| `COMMANDRELAY_APP_STATIC_DIR` | Static app root | +| `COMMANDRELAY_STRICT_PROTOCOL_PARSING` | Strict v1 envelope parsing | | `COMMANDRELAY_AUDIT_LOG` | Audit JSONL path | -| `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` | Outbound proxy settings | - -## Protocol and Behavior - -Primary protocol docs: - -1. `docs/protocol-v1.md` (normative contract) -2. `docs/protocol.md` (operator-facing summary) -3. `docs/security.md` (threat model + controls) - -`list_sessions` behavior in multi-backend mode: - -1. `payload.panes[]` include backend-aware pane ids. -2. `payload.sessions[]` are grouped by `(backendId, sessionName)` to avoid cross-backend session-name collisions. - -## Validation - -Use these for repeatable validation (not date-bound): - -```bash -npm run check:root -npm run test:root -npm run ci:all -``` - -Targeted protocol/runtime checks: - -```bash -node --import tsx --test src/protocol.conformance.test.ts -node --import tsx --test src/server/ws-contract-matrix.test.ts -node --import tsx --test src/server/bridge-server.policy.test.ts -``` - -## Project Structure - -```text -src/ - bridge/ replay + delta streaming engine - server/ ws/http gateway, policies, contract tests - runtime/ runtime mux + cmux adapter - tmux/ tmux adapter - net/ proxy routing and agent factory adapters -packages/ - cli-proxy/ - proxy-core/ - proxy-agent/ - proxy-fetch/ - proxy-http-client/ - proxy-undici/ -docs/ - protocol, security, operations, roadmap, proxy ecosystem -apps/ - ios/, android/, web/ -``` - -## Documentation Map - -1. `docs/README.md` - full docs index -2. `docs/getting-started.md` - setup and runbook -3. `docs/operations.md` - operations and runtime handling -4. `docs/roadmap-native.md` - iOS/Android/macos/web rollout -5. `docs/proxy-ecosystem-roadmap.md` - proxy package expansion + discovery/use strategy - -## Project Status - -The core TypeScript gateway is implemented, tested, and production-oriented for the tmux/cmux runtime path. -Active work continues on: +## Docs -1. Native client parity and UX hardening. -2. Multi-runtime and control-lane reliability. -3. Externalized `@commandrelay` / `@termina` proxy package line, with P1 (`@termina/proxy-undici`, `@termina/cli-proxy`, `@termina/proxy-fetch`) implemented and validated. +1. `docs/protocol-v1.md` - normative wire contract +2. `docs/security.md` - controls and threat notes +3. `docs/operations.md` - deployment and operator runbook +4. `docs/roadmap-native.md` - web/native parity roadmap +5. `docs/proxy-ecosystem-roadmap.md` - proxy package track ## License diff --git a/docs/README.md b/docs/README.md index 3541e12..a9b23ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ This folder contains project documentation for users, contributors, and operator ## Runtime Snapshot 1. Gateway runtime: TypeScript on Node.js `>=22` (`tsx` execution, `tsc --noEmit` checks). -2. Gateway transport package: `ws`. +2. SSH-first transport with current WS runtime path: data plane remains WebSocket (`/ws`), and `ssh` mode executes tmux runtime operations on the remote target after startup preflight passes (see `ssh-transport-contract.md` and ADR-001). 3. Outbound proxy package set: `http-proxy-agent`, `https-proxy-agent`, `socks-proxy-agent`, `pac-proxy-agent`. 4. Client ecosystem direction: iOS (Swift) first, Android (Kotlin) second, web fallback last. @@ -33,6 +33,13 @@ Local MCP note: 5. Planner: read [Native Roadmap](roadmap-native.md) and [Execution TODO](TODO.md). 6. Release owner: read [Proxy Publish Runbook](release/proxy-publish.md) and capture outputs in weekly checkpoints. +## Quickstart References + +1. [getting-started.md](getting-started.md): runtime quickstart and live environment setup. +2. [operations.md](operations.md): operator runbook and runtime behavior details. +3. SSH startup config keys: `COMMANDRELAY_TRANSPORT_MODE`, `COMMANDRELAY_SSH_PROFILE`, `COMMANDRELAY_SSH_TARGET`, `COMMANDRELAY_SSH_COMMAND`, `COMMANDRELAY_SSH_PORT`, `COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS`, `COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING`. +4. Tunnel helper runbook: [../scripts/ssh/README.md](../scripts/ssh/README.md). + ## Current Execution Baselines 1. iOS protocol mock package path: `apps/ios/M0ProtocolMockClient` (`swift test`). @@ -58,7 +65,11 @@ Local MCP note: 9. [roadmap-native.md](roadmap-native.md) 10. [macos-menu-bar-control-lane-spec.md](macos-menu-bar-control-lane-spec.md) 11. [control-lane-parity-checklist.md](control-lane-parity-checklist.md) -12. [proxy-ecosystem-roadmap.md](proxy-ecosystem-roadmap.md) -13. [research-next-opportunities.md](research-next-opportunities.md) -14. [TODO.md](TODO.md) -15. [release/proxy-publish.md](release/proxy-publish.md) +12. [controlled-input-audit.md](controlled-input-audit.md) +13. [proxy-ecosystem-roadmap.md](proxy-ecosystem-roadmap.md) +14. [research-next-opportunities.md](research-next-opportunities.md) +15. [TODO.md](TODO.md) +16. [release/proxy-publish.md](release/proxy-publish.md) +17. [ssh-transport-contract.md](ssh-transport-contract.md) +18. [adr/ADR-001-ssh-first-transport.md](adr/ADR-001-ssh-first-transport.md) +19. [architecture/host-state-authority-plan.md](architecture/host-state-authority-plan.md) diff --git a/docs/TODO.md b/docs/TODO.md index c5a06fa..5e3642e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,297 +1,240 @@ -# CommandRelay Execution TODO (Native-First) +# CommandRelay Execution TODO (SSH-First + Proxy Hardening) -Last reviewed: 2026-02-26 -Owner scope: iOS first, Android second, web fallback last. - -## Gateway Runtime Baseline - -- [x] TypeScript gateway runtime on Node.js `>=22` (`tsx` entrypoint and `tsc --noEmit` checks). -- [x] WebSocket transport baseline via `ws`. -- [x] Proxy agent package baseline via `http-proxy-agent`, `https-proxy-agent`, `socks-proxy-agent`, and `pac-proxy-agent`. - -## Priority Order - -1. iOS Swift app (primary delivery target). -2. Android app (feature-parity follow-up). -3. Web fallback (minimum viable control surface). - -## Current Milestones - -## Controlled-Input Status Snapshot - -- [x] Gateway controlled-input runtime is implemented and test-covered (`enable_input`, `input`, `disable_input`, kill switch enforcement). -- [x] iOS controlled-input baseline is implemented (`enable_input`, `input`, `disable_input` wiring + UX safety gate); Mac runtime validation is pending. - -## M0 - Gateway and Mobile Contract Baseline (Target: 2026-03-13) - -- [x] Freeze mobile event contract (`auth`, `list_sessions`, `attach`, `output`, `input`, `ack`, `error`). -- [x] Define replay/ordering guarantees (`streamSeq`, reconnect with `lastSeq`). -- [x] Finalize read-only-by-default and explicit input-enable policy. -- [x] Add API conformance checks for protocol envelope and event types. -- [x] Publish v1 contract doc for native clients. -- [x] Add CI Node 22 gate for root/package typecheck + TAP test artifacts. -- [ ] Exit criteria met: -- [ ] iOS can consume mocked gateway events without schema drift for 7 days. -- [x] Command input path can be disabled globally and per-session. - -## M1 - iOS Alpha (Read-Only Streaming) (Target: 2026-04-03) - -- [x] Create Swift app shell (auth, session list, pane viewer). -- [x] Implement WebSocket connection + reconnect with backoff. -- [x] Implement pane attach, output render, replay resume. -- [ ] Add accessibility baseline (VoiceOver labels, dynamic type, focus order). -- [ ] Add telemetry for connect latency, reconnect count, stream lag. -- [ ] Exit criteria met: -- [ ] 30-minute stable stream under flaky network simulation. -- [ ] Crash-free rate >= 99% on TestFlight alpha cohort. - -## M2 - iOS Beta (Controlled Input) (Target: 2026-04-24) - -- [x] Implement explicit `enable_input` UX with clear risk gate. -- [x] Implement input send/ack path with timeout/error handling. -- [x] Add safeguards: per-command length limits, rate limit feedback, kill switch handling (`input_too_large` + `input_rate_limited` payload metadata, 2026-02-26). -- [ ] Add audit event surfacing for sent commands. -- [ ] Exit criteria met: -- [ ] Read-only mode remains default on every reconnect. -- [ ] Input commands are fully auditable by pane and timestamp. - -## M3 - iOS GA (Target: 2026-05-15) - -- [ ] Complete reliability pass (background/foreground, idle resume, token refresh). -- [ ] Complete App Store readiness (privacy manifest, permission copy, support docs). -- [ ] Produce on-call runbook for gateway + mobile incidents. -- [ ] Exit criteria met: -- [ ] 14 days beta with no Sev-1 mobile-to-gateway regression. -- [ ] Median command round-trip latency <= 250ms on Tailscale path. - -## M4 - Android Alpha/Beta (Target: 2026-06-12) - -- [ ] Port protocol client and session UX in Kotlin (read-only first). -- [ ] Add controlled input flow matching iOS safety model. -- [ ] Validate device/network matrix and background limits. -- [ ] Exit criteria met: -- [ ] Functional parity with iOS core flows (`list`, `attach`, `replay`, guarded `input`). - -## M5 - Web Fallback (Last) (Target: 2026-07-03) - -- [ ] Build minimal responsive web console for emergency access. -- [ ] Support auth, session list, pane attach, read-only stream. -- [ ] Add guarded input behind explicit enable flow. -- [ ] Keep web control lane on the same v1 envelope/event set as native clients (no web-only protocol fork). -- [ ] Implement lane conflict UX: block send on `input_lane_conflict`, show owner context, require explicit takeover action. -- [ ] Add takeover path using `override=true`/`takeOwnership=true` with clear operator confirmation. -- [ ] Add multi-tab tests for single-writer lane ownership, detach/disconnect release, and takeover behavior. -- [ ] Exit criteria met: -- [ ] Works on modern mobile browsers as fallback only. -- [ ] iOS + web lane handoff scenarios pass shared fixture suite without schema drift. - -## M6 - macOS Menu Bar + iOS/Web Parity Follow-Through (Target: 2026-07-24) - -- [x] Define macOS menu bar scope: quick connect, session pick, read-only attach, explicit input arm/disarm (`docs/macos-menu-bar-control-lane-spec.md`, completed 2026-02-25). -- [x] Specify menu bar lane-state indicators (`read-only`, `input-enabled`, `lane-conflict`, `kill-switch-blocked`) (`docs/macos-menu-bar-control-lane-spec.md`, completed 2026-02-25). -- [x] Reuse the same gateway client contract/events used by iOS/web (`hello`, `policy_update`, `ack`, `error`) in spec mapping (`docs/macos-menu-bar-control-lane-spec.md`, completed 2026-02-25). -- [ ] Build parity matrix covering iOS/web/menu bar for connect/auth/list/attach/replay/enable/disable/input/conflict/takeover (baseline iOS/web matrix: `docs/control-lane-parity-checklist.md`). -- [ ] Add remaining cross-client fixture cases: - - [x] iOS writer -> web takeover (`src/server/bridge-server.policy.test.ts`, completed 2026-02-25) - - [x] web writer -> iOS takeover (`src/server/bridge-server.policy.test.ts`, completed 2026-02-25) - - menu bar observer -> iOS writer handoff - - menu bar writer -> web takeover -- [ ] Exit criteria met: -- [ ] Menu bar flow can attach read-only and complete guarded input handoff without protocol drift. -- [ ] iOS/web/menu bar parity checklist is fully green in weekly checkpoint artifact. - -## Dependencies - -- [x] Stable gateway protocol and auth policy. -- [ ] Tailscale network path for low-friction private connectivity. -- [ ] Test environments: tmux session fixtures + replay test data. -- [ ] Apple/Google developer accounts and release pipelines. -- [ ] Observability stack (logs, metrics, crash reporting). - -## Top Risks and Mitigations - -- [ ] Risk: protocol churn blocks native velocity. -- [ ] Mitigation: lock v1 schema in M0; version all event changes. -- [ ] Risk: accidental destructive remote commands. -- [ ] Mitigation: default read-only, explicit enable input, kill switch, audit trail. -- [ ] Risk: mobile reconnect instability on poor networks. -- [ ] Mitigation: replay buffer tests, chaos simulation, backoff tuning. -- [ ] Risk: app store review delays. -- [ ] Mitigation: submit early TestFlight/Internal tracks and stage approvals. - -## Immediate Next Actions (Rolling) - -- [x] Create `v1` protocol contract tests in gateway repo. -- [x] Build iOS spike for WebSocket connect/list/attach/output, then extend with controlled-input baseline. -- [x] Define iOS screen map and navigation for three core flows. -- [x] Decide telemetry schema (connect time, replay time, input ack latency). -- [x] Implement weekly checkpoint workflow artifacts (`scripts/checkpoints/generate-weekly-checkpoint.sh` + template) and document tracking in `docs/roadmap-native.md`. -- [x] Wire the existing proxy stack into auth/pairing/telemetry outbound clients and add integration tests for `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY`. -- [x] Draft macOS menu bar control-lane spec and state diagram (`docs/macos-menu-bar-control-lane-spec.md`, completed 2026-02-25). -- [x] Author iOS/web parity checklist for control-lane flows and map each item to an automated/manual test (`docs/control-lane-parity-checklist.md`, completed 2026-02-25). -- [x] Add two gateway fixture scenarios for lane conflict + explicit takeover (iOS writer -> web takeover, web writer -> iOS takeover) (`src/server/bridge-server.policy.test.ts`, completed 2026-02-25). -- [x] Document distilled capsule-to-brief wiring (`npm run capsule:build --` -> `npm run capsule:brief --`) in operations/docs/skill guidance (`docs/operations.md`, `docs/README.md`, `skills/termina-orchestrator/SKILL.md`, completed 2026-02-26). -- [x] Document distilled capsule-to-dispatch wiring (`npm run capsule:build --` -> `npm run capsule:brief --` -> `npm run capsule:dispatch --`) in operations/docs/skill guidance (`docs/operations.md`, `docs/README.md`, `skills/termina-orchestrator/SKILL.md`, completed 2026-02-26). - -## Weekly Cross-Platform Checkpoint Runbook - -- [x] Workflow script: `scripts/checkpoints/generate-weekly-checkpoint.sh` -- [x] Template: `scripts/checkpoints/templates/weekly-cross-platform-checkpoint.md` -- [x] Weekly command: - `scripts/checkpoints/generate-weekly-checkpoint.sh --date YYYY-MM-DD --facilitator "Owner Name"` -- [x] Weekly artifact to track in git: - `scripts/checkpoints/runs/YYYY-MM-DD-weekly-cross-platform-checkpoint.md` -- [ ] Post-sync tracking rule: - checkpoint is complete only after sign-off boxes are checked and milestone decisions are mirrored in `docs/roadmap-native.md` + `docs/TODO.md`. - -## Mac Validation Acceptance Checklist - -- [ ] Node.js runtime is `v22.x` on Mac validation machine. -- [ ] `npm run check` passes. -- [ ] `node --import tsx --test src/protocol.conformance.test.ts` passes. -- [ ] `node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge-server.policy.test.ts src/server/input-policy.test.ts` passes. -- [ ] `node --import tsx --test src/server/startup-validation.test.ts` passes. -- [ ] `node --import tsx --test src/control-plane/control-plane-client.test.ts src/net/proxy-agent-factory.test.ts src/net/proxy-router.test.ts` passes. -- [ ] `cd apps/ios/M0ProtocolMockClient && swift test` passes. -- [ ] `npm run test:ci:all` passes on Mac. -- [ ] Live smoke (kill switch off) passes: `COMMANDRELAY_INPUT_KILL_SWITCH=off npm run start` + `npm run bench:input -- --iterations 5`. -- [ ] Live smoke (kill switch on) blocks input: `COMMANDRELAY_INPUT_KILL_SWITCH=on npm run start` + `npm run bench:input -- --iterations 3` fails with input-disabled behavior. -- [ ] Nightly evidence captured: TAP artifacts + swift test log + short smoke summary. - -## Home Pickup TODO - -- [ ] Copy MCP template and set absolute paths: `cp mcp.example.json .mcp.json` then edit paths. -- [ ] Verify Chitragupta MCP starts with the workaround command from `docs/operations.md`. -- [ ] Run full gateway test pack: `npm run check && npm test && npm run test:ci:all`. -- [ ] Run replay-focused suites: - - `node --import tsx --test src/bridge/bridge-engine.replay.test.ts` - - `node --import tsx --test src/server/bridge-server.replay.e2e.test.ts` -- [ ] Run iOS transport tests: - - `cd apps/ios/M0ProtocolMockClient && swift test --filter M0WebSocketTransportClientTests` - - `swift test` -- [ ] Run Android parity module tests (requires Gradle wrapper or local Gradle): - - `cd apps/android/M0ProtocolMockClient && ./gradlew test` (or `gradle test`) -- [ ] Run tmux fixture harness smoke: - - `scripts/tmux-fixtures/create-fixture.sh --session fixture_smoke --panes 2` - - `scripts/tmux-fixtures/emit-fixture-output.sh --session fixture_smoke --profile replay --cycles 5` - - `scripts/tmux-fixtures/teardown-fixture.sh --session fixture_smoke` -- [ ] Run perf smoke benchmarks: - - `npm run bench:connect -- --iterations 20` - - `npm run bench:list -- --iterations 20` - - `npm run bench:input -- --iterations 20` -- [ ] If all green, open/update PR notes with: - - replay coverage results - - iOS/Android local test results - - perf summary (`p50/p95/p99`) - -## Home-Mac Continuation Checklist - -### Session Bootstrap (single owner) - -- [ ] Confirm Node.js `v22.x` and clean install: `node -v && npm ci`. -- [ ] Confirm local MCP wiring and Chitragupta launch command from `docs/operations.md`. -- [ ] Start evidence log for this run (tests, perf, publish dry-run outputs). - -### Parallel Track A: tmux Engine Follow-up - -- [ ] Run replay/ordering suites: - - `node --import tsx --test src/bridge/bridge-engine.replay.test.ts` - - `node --import tsx --test src/server/bridge-server.replay.e2e.test.ts` -- [ ] Run tmux fixture harness: - - `scripts/tmux-fixtures/create-fixture.sh --session fixture_smoke --panes 2` - - `scripts/tmux-fixtures/emit-fixture-output.sh --session fixture_smoke --profile replay --cycles 5` - - `scripts/tmux-fixtures/teardown-fixture.sh --session fixture_smoke` -- [ ] Run perf smoke (`connect`, `list`, `input`) with `--iterations 20`; record `p50/p95/p99`. -- [ ] Mark tmux track complete only when replay + fixture + perf evidence is captured. - -### Parallel Track B: iOS Follow-up - -- [ ] `cd apps/ios/M0ProtocolMockClient && swift test --filter M0WebSocketTransportClientTests`. -- [ ] `cd apps/ios/M0ProtocolMockClient && swift test`. -- [ ] Validate controlled-input safety behavior against gateway kill-switch on/off runs. -- [ ] Capture iOS evidence summary (pass/fail, flaky tests, retry count). - -### Merge Gate (both tracks) - -- [ ] Run full aggregate check: `npm run check && npm test && npm run test:ci:all`. -- [ ] Update `scripts/checkpoints/runs/2026-02-25-weekly-cross-platform-checkpoint.md` with outcomes. -- [ ] Update proxy release gate status in `docs/release/proxy-publish.md`. - -## Proxy Package Release Gates (for internal v0.1 prep) - -- [x] Gate 1: version readiness confirmed for each `@commandrelay/proxy-*` package (`@commandrelay/proxy-core@0.1.0`, `@commandrelay/proxy-agent@0.1.0`, `@commandrelay/proxy-http-client@0.1.0`). -- [ ] Gate 2: root/package `check`, `build`, `test` all green on Mac run. - - Batch evidence (2026-02-25): TAP green in current environment (`root 14/14`, `proxy-core 1/1`, `proxy-agent 2/2`, `proxy-http-client 1/1`). -- [ ] Gate 3: publish workflow dry-run green with expected package selector and `dist_tag`. - - Home Mac action: run `Publish Proxy Packages` with `mode=dry-run`, `package_selector=@commandrelay/proxy-*`, `dist_tag=latest`. -- [ ] Gate 4: `NPM_TOKEN` + `npm-publish` environment policy verified. - - Home Mac action: verify `NPM_TOKEN` secret, `npm-publish` reviewers, and default-branch restrictions. -- [ ] Gate 5: release notes/changelog draft reviewed before any publish-mode trigger. - - Home Mac action: append dry-run URL + artifact summary + go/no-go note. - -## Internal v0.1 Tag Plan (proposal only; do not create tags yet) - -- [ ] Step 1: complete tmux + iOS follow-up tracks and evidence capture. -- [ ] Step 2: run proxy publish dry-run gate review and resolve blockers. -- [ ] Step 3: freeze internal v0.1 candidate scope and finalize release notes draft. -- [ ] Step 4: run final go/no-go check (tests, perf, release gates, checkpoint sign-off). -- [ ] Step 5: if all gates stay green, prepare internal `v0.1` tag request in PR/release notes (no tag creation in this step). - -## Proxy Ecosystem Expansion Backlog - -Reference roadmap: `docs/proxy-ecosystem-roadmap.md`. - -- [x] Harden current package line for external use (`@commandrelay/proxy-core`, `@commandrelay/proxy-agent`, `@commandrelay/proxy-http-client`) with reusable docs/assets/examples. -- [ ] Publish/validate adapter ecosystem package plan and naming contract (`@termina/proxy-*`). -- [ ] P1 package wave (active): - - [x] `@termina/cli-proxy` (`packages/cli-proxy`, diagnostics CLI + JSON/human modes + tests/docs/assets completed on 2026-02-26) - - [x] `@termina/proxy-undici` (`packages/proxy-undici`, check/build/test + docs/assets/examples complete on 2026-02-26) - - [x] `@termina/proxy-fetch` (`packages/proxy-fetch`, fetch adapter + JSON/timeout/size guards + tests/docs/assets completed on 2026-02-26) -- [x] P1 exit criteria: `@termina/cli-proxy` + `@termina/proxy-fetch` both pass check/build/test and include README + NOTES + SVG branding assets. -- [ ] P2 package wave: +Last reviewed: 2026-02-27 +Primary strategy: SSH-first transport, tmux-first runtime, remote state owned by host. + +## Vision Reset (SSH-First) + +- CommandRelay is a host-adjacent remote operations product, not a mobile-first product. +- SSH is the default control/data transport for production use; WebSocket remains for compatibility and controlled environments. +- `tmux` on the remote host is the source of truth for live session state. +- Host runtime owns replay/order state (`streamSeq`, `lastSeq`, lane ownership, audit trail); clients only render and request actions. +- iOS, Android, web, and macOS menu bar are thin clients over one contract; no client-specific protocol forks. +- Safety posture remains strict: read-only default, explicit input enable, lane conflict controls, global kill switch, and auditable input history. + +## Two-Track Plan (Run in Parallel) + +- Track A ships the SSH-first CommandRelay product baseline. +- Track B hardens and productizes the `proxy-*` ecosystem used by CommandRelay and external consumers. +- Merge/release gate: no Track A GA candidate without Track B publish/process hardening at release-ready status. + +## Track A: SSH-First CommandRelay Product + +### A1) Transport + +- [ ] Finalize SSH transport contract for connect/auth/list/attach/replay/input/ack/error with explicit reconnect semantics. +- [ ] Specify host identity + trust model (host key verification mode, fingerprint surfacing, rotation handling). +- [ ] Lock protocol compatibility matrix for SSH transport and existing WebSocket transport. +- [ ] Add transport conformance tests covering: + - happy path attach + replay resume + - reconnect with `lastSeq` + - lane conflict + explicit takeover + - kill-switch enforcement on active lane + +### A2) Runtime (Host-State Ownership) + +- [ ] Make host runtime authoritative for session metadata, lane owner, replay offsets, and capability flags. +- [x] Validate tmux fixture harness for deterministic replay and multi-pane ordering. Status: `done` ([tmux fixture harness runbook](../scripts/tmux-fixtures/README.md), [fixture evidence runner](../scripts/tmux-fixtures/run-fixture-evidence.ts), [2026-02-27 fixture harness evidence run](../scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md)). +- [x] Add startup validation profile for remote host environments (Node runtime, tmux availability, permissions, env policy). Status: `done` ([startup profile checks](../src/startup/startup-profile.ts), [startup profile tests](../src/startup/startup-profile.test.ts), [remote runtime validator script](../scripts/ssh/validate-remote-runtime.sh), [runtime validator runbook](./operations.md#ssh-runtime-validator-reference), [validation checkpoint command evidence](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md#command-evidence)). +- [x] Ensure runtime failure modes are explicit and recoverable (auth reject, transport drop, tmux session loss, stale lane owner). Status: `done` ([runtime failure classifier](../src/server/bridge-runtime-failures.ts), [bridge handler wiring](../src/server/bridge-server.ts), [bridge attach failure propagation](../src/bridge/bridge-engine.ts), [failure-mode e2e tests](../src/server/bridge-server.failure-modes.e2e.test.ts), [classifier unit tests](../src/server/bridge-runtime-failures.test.ts)). + +### A3) UX (Thin Clients) + +- [ ] Keep one interaction model across iOS/web/macOS menu bar: + - read-only attach by default + - explicit input arm/disarm + - lane conflict message with owner context + - explicit takeover confirmation +- [ ] Complete parity checklist for connect/auth/list/attach/replay/enable/disable/input/conflict/takeover. +- [ ] Finish accessibility baseline in active clients (labels, focus order, dynamic type, keyboard paths where applicable). + +### A4) Safety + +- [x] Controlled-input baseline exists (`enable_input`, `input`, `disable_input`, kill switch, size/rate limit payload metadata). +- [x] Add host-side input audit log record (actor, pane, command hash/preview policy, timestamp, result) ([runtime audit writes](../src/server/bridge-server.ts), [audit timestamp envelope](../src/server/audit-log.ts), [policy audit assertions](../src/server/bridge-server.policy.test.ts), [e2e audit flow assertions](../src/server/bridge-server.e2e.test.ts)). +- [x] Add policy tests for default read-only on reconnect, lane lease expiry, and takeover audit event ([policy tests](../src/server/bridge-server.policy.test.ts), [arbiter lease tests](../src/server/bridge-server-utils.test.ts)). +- [x] Add operator safety runbook for kill-switch and lane lockout incidents ([runbook](./operations.md#controlled-input-safety-incident-runbook), [checkpoint evidence](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md#operator-safety-runbook-evidence)). + +### A5) Observability + +- [x] Define metrics contract and dashboards ([metrics contract v1](./observability-evidence-contract.md#metrics-contract-v1), [dashboard baseline v1](./observability-evidence-contract.md#dashboard-baseline-v1), [operations weekly flow](./operations.md#weekly-observability-baseline-and-evidence-pack)): + - connect latency + - replay lag + - reconnect count + - input ack latency + - lane conflict frequency + - kill-switch blocks +- [x] Add structured logs for lifecycle events (connect, attach, replay resume, input enabled/disabled, takeover, policy reject) ([connect audit emit](../src/server/bridge-server.ts), [lifecycle log assertions](../src/server/bridge-server.lifecycle-logging.test.ts)). +- [x] Replay resume/fallback behavior is implemented and currently test-covered ([bridge replay unit](../src/bridge/bridge-engine.replay.test.ts), [bridge replay e2e](../src/server/bridge-server.replay.e2e.test.ts)). +- [x] Dedicated replay audit actions `replay_resume` and `replay_gap_snapshot_fallback` are emitted from attach flow and asserted in replay e2e coverage ([audit contract](./controlled-input-audit.md), [attach audit writes](../src/server/bridge-server.ts), [replay audit assertions](../src/server/bridge-server.replay.e2e.test.ts)). +- [x] Set minimum evidence pack for weekly checkpoint artifacts ([minimum artifact set](./observability-evidence-contract.md#minimum-weekly-evidence-pack-v1), [command-to-evidence mapping](./observability-evidence-contract.md#command-to-evidence-mapping-v1), [operations weekly flow](./operations.md#weekly-observability-baseline-and-evidence-pack)). + +### A6) Release Criteria (Track A) + +- [ ] 7-day stability window with no Sev-1 SSH transport regressions in checkpoint evidence. +- [ ] 30-minute flaky-network stream test passes with replay correctness. +- [ ] Controlled input remains opt-in on every reconnect path. +- [ ] Full parity checklist is green across active clients. +- [ ] On-call incident/runbook document is complete and reviewed. + +## Track B: `proxy-*` Ecosystem Hardening and Productization + +### B1) Current Package Line Hardening + +- [x] Baseline line exists and is in active use: + - `@commandrelay/proxy-core` + - `@commandrelay/proxy-agent` + - `@commandrelay/proxy-http-client` +- [ ] Complete API stability review and public surface lock for v0.1. +- [x] Add negative tests for malformed proxy URLs, auth variants, NO_PROXY edge cases, PAC failures, and fallback behavior ([proxy factory negative + PAC suite](../src/net/proxy-agent-factory.test.ts), [proxy router malformed + NO_PROXY suite](../src/net/proxy-router.test.ts), [expected-vs-actual malformed input report](../scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md)). +- [x] Add interoperability matrix validation (Node fetch/undici/http(s), env var permutations, proxy chaining expectations) ([core env + routing matrix](../src/net/proxy-interoperability-matrix.test.ts), [fetch adapter matrix](../packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts), [undici dispatcher matrix + unsupported chaining expectations](../packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts), [http(s) request resolver interoperability](../packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts)). + +### B2) Productization Readiness + +- [x] Complete docs pack per package: README usage matrix, NOTES, migration/compat notes, troubleshooting. Status: `done` ([coverage matrix](./proxy/package-docs-matrix.md)); evidence confirms all six packages now satisfy this set. +- [x] Add runnable examples with expected output snapshots. Status: `done` ([coverage matrix](./proxy/package-docs-matrix.md)); evidence confirms all six packages now include runnable examples plus expected snapshot artifacts. +- [x] Ensure CI gates are explicit and reproducible (`check`, `build`, `test`) at root and per package ([root scripts](../package.json), [cli-proxy scripts](../packages/cli-proxy/package.json), [proxy-core scripts](../packages/proxy-core/package.json), [proxy-agent scripts](../packages/proxy-agent/package.json), [proxy-fetch scripts](../packages/proxy-fetch/package.json), [proxy-http-client scripts](../packages/proxy-http-client/package.json), [proxy-undici scripts](../packages/proxy-undici/package.json)). +- [ ] Confirm publish workflow dry-run path with selector and dist-tag policy. Status: `partial` ([workflow dispatch + selector/dist-tag logic](../.github/workflows/publish-proxy-packages.yml), [release runbook](./release/proxy-publish.md), [2026-02-27 local dry-run checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md)); remaining gaps: rerun dry-run in an unblocked environment and archive successful `npm pack/publish --dry-run` artifacts. +- [ ] Validate npm publish governance (`NPM_TOKEN`, `npm-publish` environment reviewers, branch protections). Status: `partial` ([workflow token/env guards](../.github/workflows/publish-proxy-packages.yml), [governance checklist](./release/proxy-publish.md#required-github-configuration)); remaining gaps: repository-level verification of secret presence, environment reviewers, and branch protection settings is not evidenced in-repo yet. + +### B3) Parallel Ecosystem Wave + +- [x] P1 completed: + - `@termina/cli-proxy` + - `@termina/proxy-undici` + - `@termina/proxy-fetch` +- [ ] P2 hardening wave (parallelizable): - `@termina/proxy-axios` - `@termina/proxy-got` - `@termina/proxy-runtime` -- [ ] P3 exploration: - - `@termina/proxy-ssh` (`ssh-proxy`) feasibility and threat model. -- [ ] External compatibility checks against ecosystem dependencies/counterparts: - - `agent-base` - - `data-uri-to-buffer` - - `degenerator` - - `get-uri` - - `http-proxy-agent` - - `https-proxy-agent` - - `pac-proxy-agent` - - `pac-resolver` - - `proxy-agent` - - `proxy` - - `socks-proxy-agent` - -## Research-Backed Next Wave (Home Pickup) - -Reference notes: `docs/research-next-opportunities.md`. - -- [ ] Cross-platform command safety contract: - - shared `input` timeout/retry semantics for iOS, Android, macOS, and web fallback - - shared telemetry keys for `enable_input` -> `input` -> `ack/error` - - deterministic kill-switch and lane-conflict behavior across clients -- [ ] Multi-session UX + handoff model: - - session switch rules while preserving read-only default - - explicit takeover UX with owner visibility and confirmation - - per-pane activity/audit indicators in native clients -- [ ] Reliability + SLO matrix: - - reconnect success target, command RTT target, replay catch-up target - - failover behavior when current writer disconnects mid-command - - weekly checkpoint artifact includes SLO trend deltas -- [ ] Proxy family hardening gates (pre external publish): - - mandatory benchmark budgets (latency, throughput, memory/socket growth) - - dependency/license/SBOM/vulnerability gate in release flow - - interoperability matrix for `fetch`, `undici`, `axios`, `got`, and CLI adapters -- [ ] Advanced transport exploration (feature-flagged): - - evaluate QUIC/WebTransport lane for degraded-network resilience - - compare against current WebSocket lane with controlled benchmark harness - - adopt only if reliability and operability improve without security regression -- [ ] Remote-control trust model upgrades: - - short-lived pairing via QR + signed challenge response - - step-up confirmation for risky command classes - - immutable command audit stream export for incident review +- [ ] P3 exploration gate: + - `@termina/proxy-ssh` feasibility + threat model (explicit go/no-go decision doc) + +### B4) Release Criteria (Track B) + +- [ ] Gate 1: version and changelog readiness confirmed for all release candidates. +- [ ] Gate 2: `check/build/test` green on designated Mac validation environment. +- [ ] Gate 3: publish dry-run green with expected selector + dist-tag. +- [ ] Gate 4: release notes and rollback notes approved before publish-mode is allowed. +- [ ] Gate 5: support/troubleshooting docs linked from package READMEs. + +## Next 2-4 Week Milestones (Execution-Ready) + +### Milestone W1 (2026-03-02 to 2026-03-08) + +- Track A goals: + - freeze SSH transport contract + compatibility matrix + - complete host-state authority spec for lane/replay ownership + - add first-pass SSH conformance tests +- Track B goals: + - finish proxy API surface audit for v0.1 candidates + - close negative-test gaps for proxy env parsing/fallback +- Acceptance criteria: + - [x] SSH contract/spec document merged and referenced by tests ([contract](./ssh-transport-contract.md), [matrix test](../src/server/ws-contract-matrix.test.ts)). + - [x] At least one automated suite exercises SSH reconnect with `lastSeq` replay ([replay e2e](../src/server/bridge-server.replay.e2e.test.ts)). + - [x] Proxy test report includes malformed URL + NO_PROXY + PAC failure cases with expected results ([proxy negative input report](../scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md), [proxy factory tests](../src/net/proxy-agent-factory.test.ts), [proxy router tests](../src/net/proxy-router.test.ts)). + +### Milestone W2 (2026-03-09 to 2026-03-15) + +- Track A goals: + - implement host audit log events and lane/takeover policy assertions + - complete tmux fixture replay ordering validation + - advance parity checklist coverage +- Track B goals: + - complete docs/examples pack for `@commandrelay/proxy-*` + - execute publish workflow dry-run and archive artifacts +- Acceptance criteria: + - [x] Audit log records are emitted for enable/disable/input/takeover flows, and `input` records include command metadata policy fields (`commandHash`, `previewPolicy`) ([runtime audit writes](../src/server/bridge-server.ts), [e2e audit flow assertions](../src/server/bridge-server.e2e.test.ts), [policy audit assertions](../src/server/bridge-server.policy.test.ts)). + - [x] Replay ordering suite passes under fixture harness without manual intervention ([tmux fixture harness runbook](../scripts/tmux-fixtures/README.md), [fixture harness evidence run](../scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md), [CR-P1-002 weekly evidence lane checkpoint](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md)). + - [ ] Dry-run artifacts contain selected package set, dist-tag, and no publish-policy blockers. Status: `partial` ([2026-02-27 proxy publish local dry-run checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md), [CR-P1-002 weekly evidence lane checkpoint](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md)); remaining gaps: selected package set + dist-tag evidence exists, but `npm pack/publish --dry-run` remains blocked by local npm cache `EACCES`. + +### Milestone W3 (2026-03-16 to 2026-03-22) + +- Track A goals: + - finalize observability metrics + dashboard baseline + - run 30-minute flaky-network soak for stream/replay behavior + - close critical UX parity gaps (conflict + takeover messaging) +- Track B goals: + - complete P2 package scaffolds and core adapter conformance tests + - resolve docs/troubleshooting gaps found in dry-run review +- Acceptance criteria: + - [ ] Weekly checkpoint includes metrics export and soak summary. + - [ ] No Sev-1/Sev-2 unresolved bugs in lane safety path. + - [ ] P2 packages have passing base check/build/test and minimal docs skeleton. + +### Milestone W4 (2026-03-23 to 2026-03-29) + +- Track A goals: + - run release-candidate gate review for SSH-first baseline + - publish incident/runbook docs for transport and safety operations +- Track B goals: + - run final pre-release gate review for proxy line + - prepare release notes + rollback notes for v0.1 internal release decision +- Acceptance criteria: + - [ ] Track A release criteria checklist is fully green or has explicit blocker list with owners. + - [ ] Track B gates 1-5 reviewed with evidence links and go/no-go status. + - [ ] Combined checkpoint artifact documents cross-track dependencies and release decision. + +## Prioritized Immediate Actions (Do Next) + +- Execution board: [Execution-Owned Tickets](./execution-owned-tickets.md) + +### P0 (Today -> next 48h) + +- [x] Convert this TODO into owned tickets with single owners and explicit file scope ([execution board](./execution-owned-tickets.md)). +- [x] Land SSH transport contract doc + test plan references ([contract](./ssh-transport-contract.md), [protocol references](./protocol-v1.md#11-contract-compatibility-test-plan-references), [contract tests](../src/server/ws-contract-matrix.test.ts)). +- [x] Start host-state authority implementation plan (lane owner + replay offsets + audit schema) ([plan](./architecture/host-state-authority-plan.md), [policy tests](../src/server/bridge-server.policy.test.ts)). +- [x] Execute proxy negative-test expansion for malformed env/config inputs ([proxy factory tests](../src/net/proxy-agent-factory.test.ts), [proxy router tests](../src/net/proxy-router.test.ts), [expected-vs-actual report](../scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md)). + +### P1 (This week) + +- Latest checkpoint evidence: [2026-02-27-cr-p1-002-weekly-evidence-lane.md](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md), [2026-02-27-feat-ssh-exploration-validation-checkpoint.md](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md) + +- [x] Run and archive core validation suites: + - `npm run check` + - `npm test` + - `npm run test:ci:all` + - `node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge-server.policy.test.ts src/server/input-policy.test.ts` + - `node --import tsx --test src/control-plane/control-plane-client.test.ts src/net/proxy-agent-factory.test.ts src/net/proxy-router.test.ts` +- [x] Validate replay resume/fallback behavior and current audit coverage for this branch (`node --import tsx --test src/bridge/bridge-engine.replay.test.ts src/server/bridge-server.replay.e2e.test.ts src/server/bridge-server.audit.test.ts`). +- [x] Update weekly checkpoint artifact and mirror milestone decisions into roadmap docs. Status: `done` for the docs evidence lane on 2026-02-27; tracked milestone outcomes remain `partial` where execution blockers persist ([CR-P1-002 weekly evidence lane checkpoint](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md), [proxy roadmap decision mirror](./proxy-ecosystem-roadmap.md#milestone-decision-mirror-2026-02-27-cr-p1-002)). +- [x] Run publish dry-run for `@commandrelay/proxy-*` and capture artifact links ([proxy publish checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md); local dry-run blocked by npm cache `EACCES`, blocker documented in [release runbook](./release/proxy-publish.md)). + +### P2 (Next 2 weeks) + +- [ ] Complete remaining parity matrix items including menu bar handoff cases. +- [ ] Complete observability dashboard baseline and alert thresholds. +- [ ] Finalize release-notes template for combined SSH-first + proxy hardening increment. + +## Retained Context (Still Relevant) + +### Completed Baselines + +- [x] TypeScript gateway runtime on Node.js `>=22` (`tsx` entrypoint and `tsc --noEmit` checks). +- [x] WebSocket baseline via `ws` remains available as compatibility transport. +- [x] Proxy agent baseline via `http-proxy-agent`, `https-proxy-agent`, `socks-proxy-agent`, `pac-proxy-agent`. +- [x] Controlled-input runtime baseline is implemented and test-covered. +- [x] iOS controlled-input baseline exists with safety gate wiring. +- [x] Weekly checkpoint workflow script/template exists. +- [x] Distilled capsule build/brief/dispatch wiring is documented. + +### Open Dependencies and Risks + +- [ ] Stable private network path for low-friction host connectivity (Tailscale or equivalent). +- [ ] Test environments: tmux fixtures + replay data kept fresh. +- [ ] Observability stack completion (logs, metrics, crash reporting). +- [ ] Risk: transport/protocol churn slows clients. +- [ ] Mitigation: versioned contract + conformance suite as release gate. +- [ ] Risk: accidental destructive commands. +- [ ] Mitigation: read-only default, explicit input enable, kill switch, audit trail. +- [ ] Risk: reconnect instability under poor networks. +- [ ] Mitigation: replay chaos tests + soak checkpoints + backoff tuning. + +## Key References + +- `docs/macos-menu-bar-control-lane-spec.md` +- `docs/control-lane-parity-checklist.md` +- `docs/proxy-ecosystem-roadmap.md` +- `docs/release/proxy-publish.md` +- `scripts/checkpoints/generate-weekly-checkpoint.sh` +- `scripts/checkpoints/templates/weekly-cross-platform-checkpoint.md` diff --git a/docs/adr/ADR-001-ssh-first-transport.md b/docs/adr/ADR-001-ssh-first-transport.md new file mode 100644 index 0000000..3e2026d --- /dev/null +++ b/docs/adr/ADR-001-ssh-first-transport.md @@ -0,0 +1,44 @@ +# ADR-001: SSH-First Transport + +## Status +Accepted + +## Date +2026-02-27 + +## Context +- CommandRelay needs resilient remote control across disconnects and client restarts. +- Session durability depends on `tmux` reattach/replay semantics. +- A WebSocket-first default increases exposed network surface and deployment variance. +- SSH is already standard in operator environments with mature auth and host trust controls. + +## Decision +- Set SSH as the default transport for CommandRelay. +- Keep `tmux` as the persistence layer, independent of transport lifecycle. +- Keep WebSocket available as explicit opt-in fallback for constrained environments. + +## Rationale +- SSH reuses proven security controls (keys, host verification, access policy). +- Default exposure is narrower than opening a WebSocket endpoint by default. +- SSH attach/detach behavior aligns with `tmux` session continuity goals. +- Operators can use existing tooling and runbooks instead of introducing new transport infrastructure. + +## Tradeoffs +- SSH provisioning and key management are required. +- Browser-only clients need a bridge or gateway for SSH workflows. +- Some environments may see slower reconnect UX than long-lived WebSocket connections. + +## Consequences +- Product UX must treat SSH auth and host trust failures as first-class paths. +- Test coverage must include host-key checks, auth failures, reconnect, and replay. +- Docs and operational playbooks must position WebSocket as exception flow, not baseline. + +## Alternatives considered +- WebSocket-first default: rejected due to larger exposed surface and proxy/TLS drift risk. +- Custom transport protocol: rejected due to long-term maintenance burden. +- Dual equal default (SSH + WebSocket): rejected to avoid ambiguous guidance and split testing focus. + +## Rollout/rollback triggers +- Roll out once SSH path is feature-complete for attach, replay, input guardrails, and failure handling. +- Keep WebSocket fallback during migration for controlled cases. +- Trigger rollback review if SSH path shows sustained reliability or security regressions that cannot be mitigated operationally. diff --git a/docs/architecture/host-state-authority-plan.md b/docs/architecture/host-state-authority-plan.md new file mode 100644 index 0000000..2b8d6ec --- /dev/null +++ b/docs/architecture/host-state-authority-plan.md @@ -0,0 +1,172 @@ +# Host-State Authority Implementation Plan + +Last updated: 2026-02-27 +Status: In progress (replay behavior and replay attach-audit actions landed) +Owner lane: host runtime (`src/server/*`, `src/bridge/*`) + +## Goal + +Make the host runtime the single authority for: +1. Pane write-lane ownership. +2. Replay offsets (`streamSeq`, `lastSeq`) and replay ordering. +3. Auditable state transitions and recovery behavior. + +Clients remain thin: they request actions and render host decisions. + +## Authority Boundaries + +1. Host-owned state: + - lane owner per `paneId` + - pane replay state (`streamSeq`, bounded history window) + - attach/replay decisions + - authoritative audit trail +2. Client-owned state: + - last rendered cursor (`lastSeq`) used only as a resume hint + - local UX state (`read_only`, `conflict`, `reconnecting`) + +## Data Model and Module Ownership + +## Lane Owner State (`src/server/bridge-server-utils.ts`) + +`PaneInputOwnershipArbiter` becomes metadata-aware: + +```ts +type LaneOwnerRecord = { + paneId: string; + ownerClientId: string; + acquiredAtMs: number; + lastInputAtMs: number; + leaseExpiresAtMs: number; + takeoverCount: number; +}; +``` + +Rules: +1. First allowed `input` claims lane. +2. Owner input refreshes `lastInputAtMs` and `leaseExpiresAtMs`. +3. Non-owner input gets `input_lane_conflict` unless explicit override is requested and allowed. +4. `detach`, `disconnect`, and socket `close` release lane if owned by that client. +5. Expired lease is auto-released by host before next claim attempt. + +Config knobs (add in `src/config.ts`): +1. `COMMANDRELAY_INPUT_LANE_LEASE_MS` (default: `120000`). +2. `COMMANDRELAY_INPUT_LANE_SWEEP_MS` (default: `5000`). + +## Replay Offset State (`src/bridge/bridge-engine.ts`) + +Per pane watcher tracks: +1. `streamSeq` (host monotonic sequence). +2. `history` (bounded, sorted by `streamSeq` on replay path). +3. `historyStartSeq`/`historyEndSeq` derived from stored events. + +Attach replay algorithm: +1. Parse `lastSeq` as optional integer cursor. +2. Replay events where `streamSeq > lastSeq`. +3. If replay set is empty and watcher exists, send snapshot at current `streamSeq`. +4. If pane watcher is new, capture snapshot and start at sequence `1`. + +Operational guarantees: +1. `streamSeq` is monotonic per pane watcher. +2. Replay never emits duplicates for a single attach operation. +3. If replay cannot be served from host history, host falls back to snapshot delivery and emits `replay_gap_snapshot_fallback` from attach flow. + +## Audit Schema (`src/server/audit-log.ts`) + +Keep JSONL storage, add normalized schema fields under `details` for compatibility: + +```json +{ + "ts": 1772179200000, + "action": "input_takeover", + "clientId": "client-b", + "details": { + "schemaVersion": 1, + "paneId": "%1", + "result": "allowed", + "reason": "override", + "ownerClientIdBefore": "client-a", + "ownerClientIdAfter": "client-b", + "streamSeq": 184, + "lastSeq": 176, + "leaseExpiresAtMs": 1772179320000 + } +} +``` + +Required actions: +1. `attach`, `detach`, `disconnect` +2. `enable_input`, `disable_input` +3. `input` (allowed/denied) +4. `input_takeover` +5. `lane_owner_released` (detach/disconnect/lease_expired) +6. `replay_resume` (with `lastSeq`, replayed count) +7. `replay_gap_snapshot_fallback` (requested seq outside retained window) + +## Implementation Snapshot (2026-02-27) + +| Scope | Status | Evidence | +| --- | --- | --- | +| Replay resume semantics (`streamSeq > lastSeq`) | Implemented + tested | [`src/bridge/bridge-engine.ts`](../../src/bridge/bridge-engine.ts), [`src/bridge/bridge-engine.replay.test.ts`](../../src/bridge/bridge-engine.replay.test.ts), [`src/server/bridge-server.replay.e2e.test.ts`](../../src/server/bridge-server.replay.e2e.test.ts) | +| Snapshot fallback on reconnect cursor mismatch | Implemented + tested | [`src/bridge/bridge-engine.ts`](../../src/bridge/bridge-engine.ts), [`src/server/bridge-server.replay.e2e.test.ts`](../../src/server/bridge-server.replay.e2e.test.ts) | +| `replay_resume` audit action emission | Implemented + tested | attach path emits `replay_resume` when replay resumes from `lastSeq` ([`src/server/bridge-server.ts`](../../src/server/bridge-server.ts), [`src/server/bridge-server.replay.e2e.test.ts`](../../src/server/bridge-server.replay.e2e.test.ts)) | +| `replay_gap_snapshot_fallback` audit action emission | Implemented + tested | attach path emits `replay_gap_snapshot_fallback` on ahead-of-stream fallback ([`src/server/bridge-server.ts`](../../src/server/bridge-server.ts), [`src/server/bridge-server.replay.e2e.test.ts`](../../src/server/bridge-server.replay.e2e.test.ts)) | + +## Failure and Recovery Flows + +| Failure | Detection | Host action | Client-visible result | Recovery | +| --- | --- | --- | --- | --- | +| Auth reject | invalid token | no state mutation | `auth_error` | re-authenticate | +| Transport drop | socket close | release lane + detach panes + clear limiter state | disconnected/reconnecting UX | reconnect, `attach(lastSeq)` | +| Stale lane owner | lease expired | auto-release before claim | previous owner loses lane silently | next writer claims or explicit takeover | +| Replay window exceeded | `lastSeq < historyStartSeq` | snapshot fallback (attach flow also emits replay-gap fallback audit when cursor is ahead of stream) | output snapshot continuity reset | client resets local buffer baseline | +| tmux poll failure | `capturePane` throws | emit `pane_poll_failed` | error event + degraded state | retry attach/reconnect with backoff | +| Audit file append failure | logger write exception | warn + continue runtime path | none (internal) | operator fixes filesystem and monitors warning rate | + +## Rollout Phases + +1. Phase 0: Guardrails and flags. + - Add lease config parsing and default values. + - Add no-op telemetry counters for replay-gap and lease-expiry events. + - Exit: runtime boots unchanged with new flags disabled/enabled by defaults. +2. Phase 1: Lane ownership lease authority. + - Extend `PaneInputOwnershipArbiter` metadata and expiry checks. + - Emit `lane_owner_released` audit records. + - Exit: ownership conflict/takeover behavior remains protocol-compatible. +3. Phase 2: Replay offset authority hardening. + - Replay-gap detection and `replay_resume`/`replay_gap_snapshot_fallback` attach-audit events are landed. + - Preserve current `attach` + `output` wire contract. + - Exit: reconnect behavior unchanged for clients, with new audit observability. +4. Phase 3: Failure/recovery enforcement. + - Add lease sweeper timer and deterministic close-path cleanup assertions. + - Validate degraded behavior for tmux poll failures and reconnect loops. + - Exit: no leaked lane owners after disconnect/restart simulations. +5. Phase 4: Rollout and gate. + - Enable in staging, run flaky-network soak, then promote to production. + - Exit: all test gates green and audit schema consumed by ops tooling. + +## Test Plan (Release Gate) + +Unit: +1. `src/server/bridge-server-utils.test.ts`: + - claim/refresh/release/lease-expire behaviors + - override + takeover transitions +2. `src/bridge/bridge-engine.replay.test.ts`: + - ordered replay `streamSeq > lastSeq` + - replay-gap fallback when `lastSeq` predates retained history + +Integration: +1. `src/server/bridge-server.policy.test.ts`: + - reconnect remains read-only until explicit enable + - stale owner expires and new owner can write + - takeover emits both `input` and `input_takeover` audit records +2. `src/server/bridge-server.replay.e2e.test.ts`: + - disconnect/reconnect with `lastSeq` + - no duplicate replay on repeated reconnect + +Operational/soak: +1. 30-minute flaky-network run with repeated reconnect and lane handoff. +2. Assert: + - zero out-of-order `streamSeq` per pane + - zero duplicate replay events per reconnect + - no orphaned lane owners after disconnect churn + - replay-gap events remain below agreed SLO threshold diff --git a/docs/brand/commandrelay-logo.svg b/docs/brand/commandrelay-logo.svg index b27ca46..f8595e7 100644 --- a/docs/brand/commandrelay-logo.svg +++ b/docs/brand/commandrelay-logo.svg @@ -1,5 +1,5 @@ - CommandRelay Termina Logo + CommandRelay Terminal Logo Terminal prompt inside a rounded badge with bidirectional relay arrows. @@ -10,6 +10,11 @@ + + + + + diff --git a/docs/chitragupta-mcp-status-report.md b/docs/chitragupta-mcp-status-report.md new file mode 100644 index 0000000..763eaae --- /dev/null +++ b/docs/chitragupta-mcp-status-report.md @@ -0,0 +1,103 @@ +# Chitragupta MCP Status Investigation Report + +Date: 2026-02-27 +Scope: branch/session-local evidence in this workspace, plus non-destructive local checks. + +## Current Health and Mesh State + +- `health_status` is stable: `Sattva=0.6000`, `Rajas=0.3000`, `Tamas=0.1000`, alerts `none`. +- Mesh is running but not operationally bootstrapped: + - `p2pBootstrapped: false` + - `nodeId: null` + - `connectedPeers: 0` + - `capabilityRouterActive: false` + - `mesh_peers` reports only dead local system peers and prints `P2P: not bootstrapped`. + +## Reproducible Failures Seen + +### 1) Prompt delegation failure (`spawn E2BIG`) + +Evidence from branch checkpoint: +- [scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md) (`Co-Orchestrator Check` section) records delegated prompt calls failing with `spawn E2BIG`. + +Reproduced in-session: +```bash +mcp__chitragupta__chitragupta_prompt +message: "Return exactly: OK" +``` +Observed error: +- `Agent prompt failed: ... CLI "claude" failed to spawn: spawn E2BIG | codex-cli ... spawn E2BIG ...` + +### 2) Non-bootstrapped mesh + +Reproduced in-session: +```bash +mcp__chitragupta__mesh_status +mcp__chitragupta__mesh_topology +mcp__chitragupta__mesh_peers +``` +Observed state: +- Mesh process is `running: true` but `p2pBootstrapped: false`. +- No node identity (`nodeId: null`), no connected peers, and no active capability router. + +### 3) Health script path mismatch (default path fails) + +Reproduced in-session: +```bash +scripts/chitragupta/health.sh +``` +Observed error: +- `Chitragupta directory not found: /mnt/c/sriinnu/personal/Kaala-brahma/terminal/../chitragupta` + +Path evidence: +- `.mcp.json` points MCP startup to `/mnt/c/sriinnu/personal/Kaala-brahma/AUriva/chitragupta/...` +- `../chitragupta` does not exist from this repo root. + +Control check (same script succeeds with explicit dir): +```bash +scripts/chitragupta/health.sh \ + --chitragupta-dir /mnt/c/sriinnu/personal/Kaala-brahma/AUriva/chitragupta \ + --project /mnt/c/sriinnu/personal/Kaala-brahma/terminal +``` +Result: `MCP diagnostics: PASS`. + +## Impact + +- Delegation path is unreliable/unavailable: co-orchestrator prompt routing cannot execute agent tasks (`spawn E2BIG`). +- Mesh-based collaboration features are effectively offline (no bootstrapped P2P/capability routing). +- Local health checks produce false negatives unless operators pass explicit paths, reducing trust in operational scripts. + +## Probable Causes + +1. `spawn E2BIG`: +- Provider CLIs are likely launched with oversized argument/environment payloads. +- Fallback providers are not effectively available once spawn fails (`No provider available` chain appears in the same error). + +2. Mesh not bootstrapped: +- P2P bootstrap prerequisites are missing or not configured at runtime (node identity/bootstrap transport never initialized). + +3. Health path mismatch: +- Script default assumes adjacent repo `../chitragupta`, but actual environment uses `AUriva/chitragupta`. +- Multiple path sources (`.mcp.json` vs script defaults) are diverged. + +## Prioritized Remediation Actions + +1. P0: Single source of truth for Chitragupta repo path. +- Introduce one canonical env/config value (for example `CHITRAGUPTA_DIR`) and consume it in `health.sh`, `start-mcp.sh`, bootstrap docs, and `.mcp.json` generation. +- Keep current explicit override flags, but stop relying on stale hardcoded defaults. + +2. P0: Stabilize delegation spawn path. +- Reduce spawn payload size (trim inherited env; avoid large arg blobs; prefer stdin/temp-file handoff for long prompts). +- Add a preflight spawn-size self-check in MCP diagnostics and fail with actionable guidance before delegation attempts. + +3. P1: Make mesh bootstrap health explicit. +- Extend health checks to fail when `p2pBootstrapped=false` in environments expecting mesh. +- Add startup logging that clearly states bootstrap mode and why router is inactive. + +4. P1: Add regression checks for these exact failure signatures. +- Script-level check: default path resolves to existing repo or emits fix hint with detected `.mcp.json` path. +- Delegation check: minimal `chitragupta_prompt` smoke test in checkpoint workflow. +- Mesh check: assert expected bootstrapped state for designated environments. + +5. P2: Operator runbook hardening. +- Add a short troubleshooting matrix to docs/operations for `spawn E2BIG`, `p2pBootstrapped=false`, and path mismatch failures with copy-paste fixes. diff --git a/docs/controlled-input-audit.md b/docs/controlled-input-audit.md new file mode 100644 index 0000000..437e3de --- /dev/null +++ b/docs/controlled-input-audit.md @@ -0,0 +1,76 @@ +# Controlled Input Audit Contract + +Last updated: 2026-02-27 + +This document defines the structured audit metadata contract for controlled input operations and replay reconnect audit actions. + +## Event Shape + +1. Each record is JSONL with top-level fields: `ts`, `action`, `clientId`, `details`. +2. `ts` is epoch milliseconds assigned by the bridge process. + +## Actions + +1. `enable_input`: + - `details.result`: `allowed` or `denied` + - `details.reason`: `client_enabled` or `global_input_kill_switch` +2. `disable_input`: + - `details.result`: `allowed` + - `details.reason`: `client_disabled` +3. `input`: + - success: `details.result=allowed`, `details.reason=ok` + - policy/rate/ownership denial: `details.result=denied`, `details.reason` in: + - `policy_blocked` + - `rate_limited` + - `ownership_conflict` + - shared metadata: + - `details.paneId` + - `details.bytes` + - `details.commandHash`: SHA-256 digest of submitted UTF-8 input when present, otherwise `null` + - `details.previewPolicy`: `sha256_only` when input payload exists, otherwise `none` +4. `input_takeover`: + - emitted when override path is used and lane ownership changes + - includes `details.paneId`, `details.bytes`, `details.result=allowed`, `details.reason=override` +5. `lane_owner_released`: + - emitted when a client loses ownership of one or more input lanes due to cleanup/release paths + - detach path: `details.paneId`, `details.result=allowed`, `details.reason=detach` + - disconnect/close path: `details.releasedPanes`, `details.result=allowed`, `details.reason` in: + - `disconnect` + - `socket_close` +6. `replay_resume` (contract): + - emitted on `attach` when `lastSeq` is provided and replay emits one or more historical events + - includes: + - `details.paneId` + - `details.lastSeq` + - `details.replayedCount` + - `details.replayStartSeq` + - `details.replayEndSeq` + - `details.result=allowed` + - `details.reason=resume` +7. `replay_gap_snapshot_fallback` (contract): + - emitted on `attach` when `lastSeq` is provided but replay cannot be served as a continuous resume window and host falls back to snapshot delivery + - includes: + - `details.paneId` + - `details.lastSeq` + - `details.streamSeq` (current host sequence at fallback) + - `details.result=allowed` + - `details.reason` in: + - `ahead_of_stream` + - `outside_retained_window` + - `empty_resume_window` + +## Sanitization + +1. Raw command payload text is never persisted in `details`. +2. Metadata-only capture is enforced (`paneId`, byte count, result, reason, `commandHash`, `previewPolicy`). + +## Implementation Status (2026-02-27) + +1. `replay_resume`: runtime emission is implemented from attach flow when replay resumes with one or more historical events. + - Current emitted details: `{ paneId, lastSeq, replayedCount, latestSeq }` ([`src/server/bridge-server.ts`](../src/server/bridge-server.ts)). +2. `replay_gap_snapshot_fallback`: runtime emission is implemented from attach flow for ahead-of-stream fallback. + - Current emitted details: `{ paneId, lastSeq, latestSeq }` ([`src/server/bridge-server.ts`](../src/server/bridge-server.ts)). +3. Runtime assertions for both replay audit actions are in replay e2e coverage: + - `replay_resume` assertions ([`src/server/bridge-server.replay.e2e.test.ts`](../src/server/bridge-server.replay.e2e.test.ts)) + - `replay_gap_snapshot_fallback` assertions ([`src/server/bridge-server.replay.e2e.test.ts`](../src/server/bridge-server.replay.e2e.test.ts)) +4. Remaining gap to close against the full contract above: normalize emitted replay audit payload fields (`result`/`reason`, and replay range fields) if that schema is required for downstream consumers. diff --git a/docs/execution-owned-tickets.md b/docs/execution-owned-tickets.md new file mode 100644 index 0000000..09ccb09 --- /dev/null +++ b/docs/execution-owned-tickets.md @@ -0,0 +1,304 @@ +# Execution-Owned Tickets (Immediate P0/P1) + +Last updated: 2026-02-27 (CR-P1-002 weekly evidence lane + A2 runtime failure-mode hardening) +Source: `docs/TODO.md` -> `Prioritized Immediate Actions (Do Next)` + +## Ticket Conventions + +- Owner placeholders must be replaced before execution begins. +- Status values: `todo`, `in_progress`, `blocked`, `done`. +- File scope is explicit and limits where each owner should edit. + +## P0 Tickets (Today -> next 48h) + +### CR-P0-001 Convert Immediate P0/P1 Plan Into Owned Tickets + +- Owner: `@owner-tbd` +- Priority: `P0` +- Status: `done` +- File scope: + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` +- Acceptance criteria: + - [x] Every immediate P0/P1 item is represented as an explicit ticket. + - [x] Each ticket has owner placeholder, file scope, acceptance criteria, and status. + - [x] `docs/TODO.md` links this execution board under prioritized actions. + +### CR-P0-002 Land SSH Transport Contract Doc + Test Plan References + +- Owner: `@owner-tbd` +- Priority: `P0` +- Status: `done` +- File scope: + - `docs/ssh-transport-contract.md` + - `docs/protocol-v1.md` + - `src/server/ws-contract-matrix.test.ts` + - `src/server/bridge-server.policy.test.ts` +- Acceptance criteria: + - [x] SSH connect/auth/list/attach/replay/input/ack/error semantics are documented. + - [x] Reconnect behavior with `lastSeq` is explicitly defined. + - [x] Contract docs reference the corresponding conformance test plan/files. + - [x] SSH/WebSocket compatibility notes are captured or linked. +- Evidence: + - [Operation Contract Matrix](./ssh-transport-contract.md#operation-contract-matrix) + - [Explicit reconnect semantics](./ssh-transport-contract.md#explicit-reconnect-semantics) + - [Protocol strict/conformance profile](./protocol-v1.md) + - [Contract matrix tests](../src/server/ws-contract-matrix.test.ts) + - [Reconnect replay e2e tests](../src/server/bridge-server.replay.e2e.test.ts) +### CR-P0-003 Start Host-State Authority Implementation Plan + +- Owner: `@owner-tbd` +- Priority: `P0` +- Status: `done` +- File scope: + - `docs/architecture/host-state-authority-plan.md` + - `docs/architecture.md` + - `src/server/bridge-server.ts` + - `src/server/bridge-server-utils.ts` + - `src/server/audit-log.ts` + - `src/server/bridge-server.policy.test.ts` + - `src/server/bridge-server.replay.e2e.test.ts` +- Acceptance criteria: + - [x] Plan defines host ownership for lane owner, replay offsets, and capability flags. + - [x] Audit schema fields are specified for enable/disable/input/takeover flows. + - [x] Host-side input audit records include actor (`clientId`), pane scope, `commandHash`, `previewPolicy`, timestamp (`ts`), and result metadata. + - [x] Rollout sequence and fallback behavior are documented. + - [x] At least one implementation slice is linked to validating tests. +- Evidence: + - [Host-state authority plan](./architecture/host-state-authority-plan.md) + - [Bridge audit payload writes (`input`, `input_takeover`, `lane_owner_released`)](../src/server/bridge-server.ts) + - [Audit logger implementation](../src/server/audit-log.ts) + - [Bridge e2e audit flow assertions](../src/server/bridge-server.e2e.test.ts) + - [Lane ownership + reconnect read-only policy tests](../src/server/bridge-server.policy.test.ts) + - [Replay resume semantics e2e tests](../src/server/bridge-server.replay.e2e.test.ts) + +### CR-P0-004 Expand Proxy Negative Tests for Malformed Env/Config + +- Owner: `@owner-tbd` +- Priority: `P0` +- Status: `done` +- File scope: + - `src/net/proxy-agent-factory.test.ts` + - `src/net/proxy-router.test.ts` + - `src/net/proxy-agent-factory.ts` + - `docs/proxy-ecosystem-roadmap.md` +- Acceptance criteria: + - [x] Negative tests cover malformed proxy URLs and invalid auth fragments. + - [x] Negative tests cover `NO_PROXY` edge cases and fallback behavior. + - [x] PAC failure behavior is asserted and documented ([PAC fail-closed assertion](../src/net/proxy-agent-factory.test.ts), [failure-mode matrix](./proxy/package-model.md#failure-mode-matrix)). + - [x] Test report notes expected vs actual handling for each malformed input class ([proxy negative input report](../scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md)). +- Evidence: + - [Proxy routing malformed env + NO_PROXY tests](../src/net/proxy-router.test.ts) + - [Proxy agent malformed credential/fallback tests](../src/net/proxy-agent-factory.test.ts) + - [PAC fail-closed behavior matrix](./proxy/package-model.md#failure-mode-matrix) + - [Proxy hardening roadmap intent](./proxy-ecosystem-roadmap.md) + +### CR-P0-005 Validate Proxy Interoperability Matrix Coverage + +- Owner: `@owner-tbd` +- Priority: `P0` +- Status: `done` +- File scope: + - `src/net/proxy-interoperability-matrix.test.ts` + - `src/net/proxy-agent-factory.test.ts` + - `src/net/proxy-router.test.ts` + - `packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts` + - `packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts` + - `packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts` + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` +- Acceptance criteria: + - [x] Interoperability matrix covers env permutations across `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` with uppercase/lowercase precedence scenarios. + - [x] Matrix assertions validate resolved proxy URL behavior across `http`/`https`/`ws`/`wss`. + - [x] Matrix assertions validate direct-vs-proxy outcomes and expected proxy agent class selection. + - [x] Matrix coverage extends to concrete Node client adapters (`fetch`, `undici`, `http(s)`) and proxy-chaining expectations. + - [x] Current matrix + negative suites are passing in targeted validation (`node --import tsx --test src/net/proxy-interoperability-matrix.test.ts src/net/proxy-agent-factory.test.ts src/net/proxy-router.test.ts`). +- Evidence: + - [Proxy interoperability matrix suite](../src/net/proxy-interoperability-matrix.test.ts) + - [Proxy routing malformed env + NO_PROXY tests](../src/net/proxy-router.test.ts) + - [Proxy agent malformed credential/fallback tests](../src/net/proxy-agent-factory.test.ts) + - [Proxy fetch adapter env matrix](../packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts) + - [Proxy undici adapter env matrix and unsupported chaining assertions](../packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts) + - [Proxy http-client resolver interoperability](../packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts) + +## P1 Tickets (This week) + +### CR-P1-001 Run + Archive Core Validation Suites + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `package.json` + - `src/server/ws-contract-matrix.test.ts` + - `src/server/bridge-server.policy.test.ts` + - `src/server/input-policy.test.ts` + - `src/control-plane/control-plane-client.test.ts` + - `src/net/proxy-agent-factory.test.ts` + - `src/net/proxy-router.test.ts` + - `scripts/checkpoints/runs/` +- Acceptance criteria: + - [x] The following commands run from a clean workspace and results are captured: + - `npm run check` + - `npm test` + - `npm run test:ci:all` + - `node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge-server.policy.test.ts src/server/input-policy.test.ts` + - `node --import tsx --test src/control-plane/control-plane-client.test.ts src/net/proxy-agent-factory.test.ts src/net/proxy-router.test.ts` + - [x] Output summary is archived in a dated checkpoint artifact. + - [x] Any failures include owner + next action in the artifact. +- Evidence: + - [2026-02-27 validation checkpoint](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md) + +### CR-P1-004 Publish Operator Safety Runbook (Kill-Switch + Lane Lockout) + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `docs/operations.md` + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` + - `scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md` +- Acceptance criteria: + - [x] Operations doc includes a concrete incident runbook for kill-switch and lane lockout paths. + - [x] TODO safety lane links to the runbook section and checkpoint evidence. + - [x] Checkpoint artifact records latest validation baseline commit and runbook evidence links. +- Evidence: + - [Controlled-input safety incident runbook](./operations.md#controlled-input-safety-incident-runbook) + - [TODO safety reference](./TODO.md#a4-safety) + - [2026-02-27 validation checkpoint](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md) + +### CR-P1-005 Define Observability Metrics Contract + Weekly Evidence Pack Minimum + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `docs/observability-evidence-contract.md` + - `docs/operations.md` + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` +- Acceptance criteria: + - [x] Six canonical observability metrics are documented with schema, labels, and rollup semantics. + - [x] Dashboard baseline panel set and weekly threshold table are documented. + - [x] Weekly checkpoint minimum evidence pack is defined with concrete artifact paths. + - [x] Command-to-evidence mapping is documented with pass signals. + - [x] TODO observability items are marked complete with direct references. +- Evidence: + - [Metrics contract v1](./observability-evidence-contract.md#metrics-contract-v1) + - [Dashboard baseline v1](./observability-evidence-contract.md#dashboard-baseline-v1) + - [Minimum weekly evidence pack v1](./observability-evidence-contract.md#minimum-weekly-evidence-pack-v1) + - [Command-to-evidence mapping v1](./observability-evidence-contract.md#command-to-evidence-mapping-v1) + - [Operations weekly evidence flow](./operations.md#weekly-observability-baseline-and-evidence-pack) + - [TODO observability lane](./TODO.md#a5-observability) + +### CR-P1-002 Update Weekly Checkpoint + Mirror Milestone Decisions + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `scripts/checkpoints/generate-weekly-checkpoint.sh` + - `scripts/checkpoints/templates/weekly-cross-platform-checkpoint.md` + - `scripts/checkpoints/runs/` + - `docs/TODO.md` + - `docs/proxy-ecosystem-roadmap.md` +- Acceptance criteria: + - [x] Weekly checkpoint artifact is generated or updated for the current cycle. + - [x] Milestone decisions are mirrored into roadmap docs with concrete dates/status. + - [x] Evidence links in the checkpoint resolve to existing files/artifacts. +- Execution note: + - This ticket is complete for docs synchronization only; it does not clear execution blockers tracked in mirrored milestone statuses. +- Evidence: + - [2026-02-27 CR-P1-002 weekly evidence lane checkpoint](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md) + - [TODO P1 status mirror](./TODO.md#p1-this-week) + - [Proxy roadmap milestone decision mirror](./proxy-ecosystem-roadmap.md#milestone-decision-mirror-2026-02-27-cr-p1-002) + +### CR-P1-003 Run Proxy Publish Dry-Run + Capture Artifact Links + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `blocked` +- File scope: + - `docs/release/proxy-publish.md` + - `scripts/checkpoints/runs/` + - `docs/proxy-ecosystem-roadmap.md` +- Acceptance criteria: + - [x] Dry-run executes with explicit package selector and dist-tag policy (`@commandrelay/proxy-*`, `latest`) via local CLI workflow. + - [x] Artifact links include selected packages, dry-run logs, and policy checks. + - [x] Publish blockers are documented (local npm cache `EACCES` on `/home/sriinnu/.npm`). +- Evidence: + - [2026-02-27 proxy publish local dry-run checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md) + - [Proxy publish runbook follow-up](./release/proxy-publish.md) + +## B2 Status Reconciliation (Docs + Readiness Evidence) + +### CR-P1-006 Reconcile B2 Productization Status with Link-Backed Evidence + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` + - `docs/proxy/package-docs-matrix.md` +- Acceptance criteria: + - [x] Add central per-package docs coverage matrix (README/NOTES/examples) with direct evidence links. + - [x] Mark B2 TODO items as `done` only where substantiated by links. + - [x] Mark incomplete B2 items as `partial` with explicit remaining gaps. + - [x] Record reconciliation result in execution-owned tickets. +- B2 reconciliation result: + - `B2.1 docs pack per package`: `done` ([matrix](./proxy/package-docs-matrix.md)); evidence confirms README usage matrix + NOTES + migration/compat + troubleshooting coverage for all six packages. + - `B2.2 runnable examples + expected snapshots`: `done` ([matrix](./proxy/package-docs-matrix.md)); evidence confirms snapshot-backed runnable examples across all six packages. + - `B2.3 CI gates explicit/reproducible at root + packages`: `done` ([root scripts](../package.json), [package scripts](./TODO.md#b2-productization-readiness)). + - `B2.4 publish workflow dry-run path (selector + dist-tag)`: `partial` ([workflow](../.github/workflows/publish-proxy-packages.yml), [dry-run checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md)); remaining gaps: successful unblocked dry-run artifact run still needed. + - `B2.5 npm publish governance validation`: `partial` ([workflow guards](../.github/workflows/publish-proxy-packages.yml), [runbook config checklist](./release/proxy-publish.md#required-github-configuration)); remaining gaps: repository/environment policy verification evidence not yet captured in-repo. + +### CR-P1-007 Reconcile A2 Runtime Status with Link-Backed Evidence + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `docs/TODO.md` + - `docs/execution-owned-tickets.md` + - `scripts/checkpoints/runs/` +- Acceptance criteria: + - [x] Reconcile A2 startup-profile status using direct code/test evidence links. + - [x] Reconcile A2 fixture-harness status without over-claiming; keep `partial` where execution evidence is missing. + - [x] Capture remaining gaps as explicit evidence asks in TODO/ticket docs. +- A2 reconciliation result: + - `A2 startup validation profile for remote host environments`: `done` ([startup profile checks](../src/startup/startup-profile.ts), [startup profile tests](../src/startup/startup-profile.test.ts), [remote runtime validator](../scripts/ssh/validate-remote-runtime.sh), [ops validator runbook](./operations.md#ssh-runtime-validator-reference), [checkpoint command evidence](../scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md#command-evidence)). + - `A2 tmux fixture harness deterministic replay + multi-pane ordering`: `done` ([tmux fixture harness runbook](../scripts/tmux-fixtures/README.md), [fixture evidence runner](../scripts/tmux-fixtures/run-fixture-evidence.ts), [2026-02-27 fixture harness evidence run](../scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md)). +- Evidence: + - [TODO A2 runtime lane](./TODO.md#a2-runtime-host-state-ownership) + - [W2 replay-ordering acceptance item](./TODO.md#milestone-w2-2026-03-09-to-2026-03-15) +- Operational note: + - Chitragupta co-orchestrator health check result: default path failed (`../chitragupta` not found), explicit path check passed via `scripts/chitragupta/health.sh --chitragupta-dir /mnt/c/sriinnu/personal/Kaala-brahma/AUriva/chitragupta --project /mnt/c/sriinnu/personal/Kaala-brahma/terminal`. + - Delegation smoke test succeeded via `pnpm chitragupta -p "Co-orchestrator delegation smoke test: reply with OK only."` (response: `OK`). + +### CR-P1-008 Harden A2 Runtime Failure Modes as Explicit + Recoverable + +- Owner: `@owner-tbd` +- Priority: `P1` +- Status: `done` +- File scope: + - `src/server/bridge-runtime-failures.ts` + - `src/server/bridge-server.ts` + - `src/bridge/bridge-engine.ts` + - `src/server/bridge-runtime-failures.test.ts` + - `src/server/bridge-server.failure-modes.e2e.test.ts` + - `docs/TODO.md` + - `docs/protocol-v1.md` +- Acceptance criteria: + - [x] Runtime handler failures are classified into explicit client-facing codes with recoverability metadata. + - [x] Attach no longer acks after capture failure; runtime error returns to client with structured code/reason. + - [x] Transport-drop close path emits explicit audit event and lane release remains recoverable. + - [x] Unit + e2e coverage exists for runtime-session loss and transport-drop recovery paths. +- Evidence: + - [Runtime failure classifier](../src/server/bridge-runtime-failures.ts) + - [Bridge handler runtime failure envelope wiring](../src/server/bridge-server.ts) + - [Bridge attach failure propagation](../src/bridge/bridge-engine.ts) + - [Runtime failure classifier tests](../src/server/bridge-runtime-failures.test.ts) + - [Failure-mode e2e tests](../src/server/bridge-server.failure-modes.e2e.test.ts) + - [Protocol error-code matrix update](./protocol-v1.md#8-error-codes-and-validation-contract) diff --git a/docs/getting-started.md b/docs/getting-started.md index 6f154c3..2c68529 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -46,6 +46,52 @@ Notes: 4. `COMMANDRELAY_CMUX_COMMAND` is trimmed at startup; blank values fall back to `cmux`. 5. On startup, the bridge logs each configured backend as available/unavailable. Unavailable backends are warnings. 6. Startup fails only when all configured backends are unavailable in non-tmux-only mode. tmux-only startup behavior remains unchanged. +7. When `COMMANDRELAY_TRANSPORT_MODE=ssh`, runtime backends must be tmux-only (`COMMANDRELAY_RUNTIME_BACKENDS=tmux`). + +## SSH Transport Environment + +Use these env vars for SSH transport startup: + +1. `COMMANDRELAY_TRANSPORT_MODE`: transport mode selector. Allowed values are `ws` (default) and `ssh`. +2. `COMMANDRELAY_SSH_PROFILE`: SSH profile name. Defaults to `primary` only when unset. If set, it must be non-empty and contain only `A-Z`, `a-z`, `0-9`, `.`, `_`, `-`. +3. `COMMANDRELAY_SSH_TARGET`: SSH destination string. Required when `COMMANDRELAY_TRANSPORT_MODE=ssh`. Format must be `[user@]host`, where `host` is `letters/numbers/._-` or bracketed IPv6. +4. `COMMANDRELAY_SSH_COMMAND`: SSH executable/command override. Defaults to `ssh`; used for startup preflight and runtime SSH execution. +5. `COMMANDRELAY_SSH_PORT`: SSH server port. Defaults to `22`; must be an integer between `1` and `65535` when set. +6. `COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS`: SSH connect/runtime command timeout in seconds. Defaults to `8`; must be an integer between `1` and `60` when set. +7. `COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING`: strict host key toggle. Defaults to `true`; accepts `1,true,yes,on,0,false,no,off`. +8. `COMMANDRELAY_RUNTIME_BACKENDS` must be `tmux` when `COMMANDRELAY_TRANSPORT_MODE=ssh`. + +SSH target examples: + +1. Valid: `relay@example.internal`, `example.internal`, `ops@[2001:db8::1]`. +2. Invalid: `relay target`, `relay@@example`, `ops@`. + +SSH profile examples: + +1. Valid: `primary`, `primary.ops-1_2`. +2. Invalid: `primary/profile`, ` `. + +Startup preflight in `ssh` mode: + +1. Runs ` -V` at startup. +2. Fails fast if `ssh` is missing/unusable or returns no version text. +3. After preflight passes, runtime executes tmux commands on the remote SSH target in non-interactive mode (`-T`, `BatchMode=yes`). +4. If `COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING=false`, runtime sets `UserKnownHostsFile=/dev/null` so no known_hosts entries are written. + +Copy-paste startup example (`ssh` mode): + +```bash +cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal +COMMANDRELAY_TRANSPORT_MODE=ssh \ +COMMANDRELAY_SSH_PROFILE=primary \ +COMMANDRELAY_SSH_TARGET=relay@example.internal \ +COMMANDRELAY_SSH_COMMAND=ssh \ +COMMANDRELAY_SSH_PORT=22 \ +COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS=8 \ +COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING=true \ +COMMANDRELAY_RUNTIME_BACKENDS=tmux \ +npm run start +``` ## Web App Route Usage (Current Runtime) @@ -225,9 +271,10 @@ Use this when two or more clients/tabs may attach to the same pane. 2. Keep observer tabs read-only by not calling `enable_input` (or calling `disable_input` after diagnostics). 3. First successful `input` claims that pane's write lane for the writer client; other clients get `error.code=input_lane_conflict`. 4. For handoff, current writer calls `disable_input` and then `detach` or `disconnect`; next writer calls `enable_input` and sends first `input`. -5. If you want to block forced takeovers, run with `COMMANDRELAY_ALLOW_INPUT_OVERRIDE=off`. -6. If command collisions are suspected, restart with `COMMANDRELAY_INPUT_KILL_SWITCH=on`, verify no input is accepted, then restart with `off` and re-enable one writer. -7. During incident review, correlate `clientId` from `hello` with audit log `enable_input`/`disable_input`/`input` entries. +5. Tune stale lane expiry with `COMMANDRELAY_INPUT_LANE_LEASE_MS` (default `30000`, bounds `1000..300000`) for your environment. +6. If you want to block forced takeovers, run with `COMMANDRELAY_ALLOW_INPUT_OVERRIDE=off`. +7. If command collisions are suspected, restart with `COMMANDRELAY_INPUT_KILL_SWITCH=on`, verify no input is accepted, then restart with `off` and re-enable one writer. +8. During incident review, correlate `clientId` from `hello` with audit log `enable_input`/`disable_input`/`input` entries. ## iOS Live Environment diff --git a/docs/observability-evidence-contract.md b/docs/observability-evidence-contract.md new file mode 100644 index 0000000..8245429 --- /dev/null +++ b/docs/observability-evidence-contract.md @@ -0,0 +1,110 @@ +# Observability Metrics + Evidence Pack Contract + +Last updated: 2026-02-27 +Scope: Track A (SSH-first runtime) observability baseline and weekly checkpoint evidence minimum. + +## Metrics Contract v1 + +### Log/Event Envelope Required For Metric Extraction + +Use one normalized envelope for lifecycle and policy events used by weekly metrics rollups. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `ts` | RFC3339 timestamp | yes | UTC event timestamp. | +| `event` | string | yes | Canonical event name (`connect_start`, `connect_ready`, `replay_resume`, `replay_gap_snapshot_fallback`, `input_sent`, `ack`, `policy_reject`). | +| `result` | enum | yes | `ok`, `error`, or `rejected`. | +| `transport` | enum | yes | `ws` or `ssh`. | +| `sessionId` | string | yes | Runtime session identity. | +| `clientId` | string | yes | Caller identity. | +| `paneId` | string | conditional | Required for input/lane events. | +| `requestId` | string | conditional | Required for request/ack pairing. | +| `reason` | string | conditional | Required for `policy_reject` events. | +| `streamSeq` | integer | conditional | Required for replay events. | +| `lastSeq` | integer | conditional | Required for replay events. | +| `durationMs` | number | optional | Explicit latency when available; otherwise derive from paired events. | + +### Canonical Metrics + +| Metric name | Type | Unit | Source event(s) | Labels (low cardinality) | Rollup | +| --- | --- | --- | --- | --- | --- | +| `cr_connect_latency_ms` | histogram | ms | `connect_start` -> `connect_ready` (paired by `clientId`) | `transport`, `result` | p50/p95/p99 per 5m, weekly p95 | +| `cr_replay_lag_events` | gauge | events | `replay_resume` or `replay_gap_snapshot_fallback` | `transport`, `result` | max + p95 per 5m, weekly max | +| `cr_reconnect_total` | counter | count | `connect_start` with reconnect path (`lastSeq > 0`) | `transport`, `result` | rate/5m + weekly sum | +| `cr_input_ack_latency_ms` | histogram | ms | `input_sent` -> `ack` (paired by `requestId`) | `transport`, `result` | p50/p95 per 5m, weekly p95 | +| `cr_lane_conflict_total` | counter | count | `policy_reject` where `reason=input_lane_conflict` | `transport`, `sessionId` | rate/5m + weekly sum | +| `cr_kill_switch_block_total` | counter | count | `policy_reject` where `reason=input_disabled_kill_switch` | `transport` | rate/5m + weekly sum | + +Derived replay lag formula: + +`cr_replay_lag_events = max(0, streamSeq - lastSeq)` at replay decision time. + +## Dashboard Baseline v1 + +Weekly checkpoint review must include these baseline panels. + +| Panel ID | Panel title | Query intent | Weekly gate | +| --- | --- | --- | --- | +| `transport-01` | Connect latency p95 (ms) | `p95(cr_connect_latency_ms)` split by `transport` | Pass if p95 <= 500ms | +| `transport-02` | Reconnect count | `sum(increase(cr_reconnect_total[7d]))` by `transport` | Investigate if > 200/week per env | +| `replay-01` | Replay lag max + p95 | `max` and `p95` of `cr_replay_lag_events` | Pass if max <= 200 | +| `input-01` | Input ack latency p95 (ms) | `p95(cr_input_ack_latency_ms)` | Pass if p95 <= 250ms | +| `safety-01` | Lane conflicts | `sum(increase(cr_lane_conflict_total[7d]))` | Investigate if conflict rate >= 2% | +| `safety-02` | Kill-switch blocks | `sum(increase(cr_kill_switch_block_total[7d]))` | Pass if `0` unless incident/freeze declared | + +### Weekly Threshold Table + +| Metric | Target | Warn | Critical | +| --- | --- | --- | --- | +| Connect latency p95 | <= 500ms | > 500ms | > 1000ms | +| Replay lag max | <= 200 events | > 200 | > 500 | +| Reconnect total (7d) | <= 200 | > 200 | > 500 | +| Input ack latency p95 | <= 250ms | > 250ms | > 500ms | +| Lane conflict rate | < 2% of input attempts | >= 2% | >= 5% | +| Kill-switch blocks (normal ops) | 0 | > 0 | > 0 without declared incident | + +## Minimum Weekly Evidence Pack v1 + +Each weekly checkpoint must include this minimum artifact set under `scripts/checkpoints/runs/`. + +| Artifact | Path pattern | Required content | +| --- | --- | --- | +| Checkpoint summary | `YYYY-MM-DD-weekly-cross-platform-checkpoint.md` | Status, risks, decisions, sign-off. | +| Command evidence log | `YYYY-MM-DD-command-evidence.md` | Commands run, UTC timestamps, exit codes, pass/fail notes. | +| Metrics export | `YYYY-MM-DD-observability-metrics.json` | Metric rollups for six canonical metrics + threshold evaluation. | +| Dashboard baseline snapshot | `YYYY-MM-DD-dashboard-baseline.md` | Panel values for `transport-01`..`safety-02` and exceptions. | +| Soak/incident note | `YYYY-MM-DD-soak-or-incident.md` | 30-minute soak summary or explicit `not-run` reason with owner/date. | + +### Evidence Metadata Schema (JSON) + +```json +{ + "checkpointDate": "YYYY-MM-DD", + "commitSha": "string", + "environment": "dev|staging|prod-like", + "facilitator": "string", + "artifacts": [ + { + "type": "checkpoint_summary|command_evidence|metrics_export|dashboard_snapshot|soak_or_incident", + "path": "scripts/checkpoints/runs/", + "generatedAtUtc": "YYYY-MM-DDTHH:MM:SSZ", + "sourceCommand": "string", + "status": "pass|warn|fail|not-run" + } + ] +} +``` + +## Command-To-Evidence Mapping v1 + +| Command | Evidence artifact | Acceptance signal | Related metric/check | +| --- | --- | --- | --- | +| `npm run check` | `YYYY-MM-DD-command-evidence.md` | exit code `0` | build/type baseline | +| `npm test` | `YYYY-MM-DD-command-evidence.md` | exit code `0` | regression baseline | +| `npm run test:ci:all` | `YYYY-MM-DD-command-evidence.md` | exit code `0` | integrated CI parity | +| `node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge-server.policy.test.ts src/server/input-policy.test.ts` | `YYYY-MM-DD-command-evidence.md` | output contains `# fail 0` | contract/policy safety baseline | +| `node --import tsx --test src/bridge/bridge-engine.replay.test.ts src/server/bridge-server.replay.e2e.test.ts` | `YYYY-MM-DD-command-evidence.md` | output contains `# fail 0` | replay lag + reconnect correctness | +| `npm run bench:input -- --iterations 5` | `YYYY-MM-DD-dashboard-baseline.md` + `YYYY-MM-DD-soak-or-incident.md` | exit code `0`, latency summary captured | input ack latency weekly check | +| `npm run bench:input -- --iterations 3` (kill-switch validation) | `YYYY-MM-DD-soak-or-incident.md` | non-zero exit with input blocked evidence | kill-switch block confirmation | + +Operational runbook reference: [Operations weekly evidence flow](./operations.md#weekly-observability-baseline-and-evidence-pack). diff --git a/docs/operations.md b/docs/operations.md index 01c6b53..c27f062 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -19,6 +19,7 @@ Runtime backend selection is controlled by `COMMANDRELAY_RUNTIME_BACKENDS`: 2. Multi-backend example: `tmux,cmux` 3. Supported backend values: `tmux`, `cmux` 4. In multi-backend mode, pane IDs are backend-namespaced (for example `tmux:%1`, `cmux:`). tmux-only mode keeps existing tmux pane IDs. +5. When `COMMANDRELAY_TRANSPORT_MODE=ssh`, runtime backends must be tmux-only (`COMMANDRELAY_RUNTIME_BACKENDS=tmux`). `cmux` executable override: @@ -32,6 +33,63 @@ Startup availability behavior: 3. Startup fails only when every configured backend is unavailable and runtime mode is not tmux-only. 4. tmux-only startup behavior is unchanged. +SSH transport startup env contract: + +1. `COMMANDRELAY_TRANSPORT_MODE` accepts `ws` (default) or `ssh`. +2. `COMMANDRELAY_SSH_PROFILE` selects the SSH profile name; default is `primary` only when unset. If provided, it must be non-empty and match `[A-Za-z0-9._-]+`. +3. `COMMANDRELAY_SSH_TARGET` is required when `COMMANDRELAY_TRANSPORT_MODE=ssh`; format must match `[user@]host` where host is `letters/numbers/._-` or bracketed IPv6. +4. `COMMANDRELAY_SSH_COMMAND` overrides the SSH executable/command; default is `ssh`. +5. `COMMANDRELAY_SSH_PORT` defaults to `22`; when set, it must be an integer in range `1..65535`. +6. `COMMANDRELAY_SSH_CONNECT_TIMEOUT_SECONDS` sets SSH connect/runtime command timeout in seconds; default is `8`, valid range is `1..60`. +7. `COMMANDRELAY_SSH_STRICT_HOST_KEY_CHECKING` defaults to `true` and accepts `1,true,yes,on,0,false,no,off`. +8. Startup preflight for `ssh` mode runs ` -V` and requires a version string; missing/unusable SSH command fails startup. +9. After preflight, `ssh` mode executes tmux runtime operations on the remote SSH target in non-interactive mode (`-T`, `BatchMode=yes`). +10. When strict host key checking is disabled, runtime suppresses known_hosts writes (`UserKnownHostsFile=/dev/null`). +11. `ssh` mode requires `COMMANDRELAY_RUNTIME_BACKENDS=tmux`. + +Format examples: + +1. Valid SSH targets: `relay@example.internal`, `example.internal`, `ops@[2001:db8::1]`. +2. Invalid SSH targets: `relay target`, `relay@@example`, `ops@`. +3. Valid SSH profiles: `primary`, `primary.ops-1_2`. +4. Invalid SSH profiles: `primary/profile`, ` `. + +## SSH-First Tunnel Runbook + +Use the local helper at [`scripts/ssh/open-tunnel.sh`](../scripts/ssh/open-tunnel.sh) to reach a remote CommandRelay instance over SSH before exposing any direct network listener. + +Quick start (macOS/Linux): + +```bash +cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal +./scripts/ssh/open-tunnel.sh --target +``` + +Tunnel defaults: + +1. Local endpoint: `127.0.0.1:8787` +2. Remote endpoint: `127.0.0.1:8787` +3. Local URLs: `http://127.0.0.1:8787` and `ws://127.0.0.1:8787/ws` + +For extra examples and option details, see [`scripts/ssh/README.md`](../scripts/ssh/README.md). + +## SSH Runtime Validator Reference + +Use [`scripts/ssh/validate-remote-runtime.sh`](../scripts/ssh/validate-remote-runtime.sh) before relying on remote tmux runtime operations. + +Quick check: + +```bash +cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal +./scripts/ssh/validate-remote-runtime.sh --target +``` + +Behavior: + +1. Uses a single non-interactive SSH command set to check `tmux` and `node`. +2. Supports strict host key checking toggle (`--strict-host-key-checking on|off`), timeout, identity, and repeatable `--ssh-option`. +3. Prints concise `PASS`/`FAIL` lines and returns deterministic exit codes (`0`, `2`, `3`, `4`). + ## Web App Runtime Surface and Checks Implemented gateway routes: @@ -56,6 +114,7 @@ curl -i http://127.0.0.1:8787/does-not-exist 2. Auth is handled inside WebSocket protocol messages (`auth.payload.token`), not via HTTP `Authorization` headers. 3. Rotate tokens by updating env and restarting the bridge process. 4. Keep token values out of shell history and operator notes; audit logs store auth outcomes, not submitted token values. +5. SSH runtime hardening is always non-interactive (`-T`, `BatchMode=yes`); if strict host key checking is off, known_hosts writes are suppressed (`UserKnownHostsFile=/dev/null`). ## Multi-Tab Safe Writer Operations @@ -64,7 +123,8 @@ curl -i http://127.0.0.1:8787/does-not-exist 3. Pane write ownership is acquired on first successful `input`. 4. Handoff: old writer `disable_input` then `detach`/`disconnect`; new writer `enable_input` and send first command. 5. Optional hardening: set `COMMANDRELAY_ALLOW_INPUT_OVERRIDE=off` to block forced ownership takeover. -6. Emergency freeze: restart with `COMMANDRELAY_INPUT_KILL_SWITCH=on`; resume by restarting with it off. +6. Lease tuning: set `COMMANDRELAY_INPUT_LANE_LEASE_MS` (default `30000`, bounds `1000..300000`) to control stale-lane expiry timing. +7. Emergency freeze: restart with `COMMANDRELAY_INPUT_KILL_SWITCH=on`; resume by restarting with it off. ## Keyboard/Input Operational Notes @@ -73,43 +133,15 @@ curl -i http://127.0.0.1:8787/does-not-exist 3. Very large pasted payloads can fail with `input_too_large` (default `maxInputBytes=4096`). 4. Rapid key/send loops can fail with `input_rate_limited` (`COMMANDRELAY_MAX_INPUT_PER_MIN`). -## Local Chitragupta Bootstrap + Health - -Use the local scripts in `scripts/chitragupta` to validate and run MCP safely. - -Bootstrap (dependencies + entrypoint readiness): - -```bash -cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -scripts/chitragupta/bootstrap.sh \ - --chitragupta-dir /mnt/c/sriinnu/personal/Kaala-brahma/chitragupta \ - --project /mnt/c/sriinnu/personal/Kaala-brahma/terminal -``` - -Health diagnostics (includes `--check` from MCP entrypoint): +## Controlled-Input Audit Behavior -```bash -cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -scripts/chitragupta/health.sh \ - --chitragupta-dir /mnt/c/sriinnu/personal/Kaala-brahma/chitragupta \ - --project /mnt/c/sriinnu/personal/Kaala-brahma/terminal -``` - -Start command (EPERM-safe, uses `node --import tsx`): +1. Controlled-input events are metadata-only (no raw command payloads). +2. Current action contract is documented in [controlled-input-audit.md](controlled-input-audit.md). -```bash -cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -scripts/chitragupta/start-mcp.sh \ - --chitragupta-dir /mnt/c/sriinnu/personal/Kaala-brahma/chitragupta \ - --project /mnt/c/sriinnu/personal/Kaala-brahma/terminal \ - --name terminal -``` - -Operational notes: +## Local Chitragupta Bootstrap + Health -1. `start-mcp.sh` avoids direct `tsx` execution to prevent `EPERM`. -2. If `tsx` is unavailable, it falls back to `packages/cli/dist/mcp-entry.js` when present. -3. Keep `CHITRAGUPTA_MCP_AGENT=true` and `CHITRAGUPTA_MCP_PROJECT=/mnt/c/sriinnu/personal/Kaala-brahma/terminal`. +Use scripts in `scripts/chitragupta` for bootstrap, health checks, and start flows. +Operational details are maintained with the script implementations to avoid drift. ## Distilled Capsule + Brief + Dispatch Operations @@ -295,16 +327,11 @@ Kill-switch toggle guidance (runtime config sanity): COMMANDRELAY_INPUT_KILL_SWITCH must be one of: 1,true,yes,on,0,false,no,off ``` -## Controlled-Input Operator Runbook - -This runbook verifies: +## Controlled-Input Safety Incident Runbook -1. `enable_input` can transition policy to input-enabled when kill switch is off. -2. `input` is accepted only while input-enabled. -3. `disable_input` returns policy to read-only and blocks later `input`. -4. Kill switch blocks `enable_input` and all `input`. +Use this runbook for operator incidents in the write path: `kill-switch engaged unexpectedly` and `lane lockout` (`input_lane_conflict` blocks intended writer). -### A) Contract and policy gate (fast verification) +### A) Fast policy verification gate ```bash cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal @@ -313,50 +340,42 @@ node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge Pass signal: -1. Test run ends with `# fail 0`. -2. `ws-contract-matrix` includes `enable -> input -> disable` and kill-switch policy assertions. +1. Run ends with `# fail 0`. +2. Suites cover `enable -> input -> disable`, kill-switch enforcement, and lane/takeover policy behavior. -### B) Live smoke with kill switch off (input should work) +### B) Incident: kill switch is blocking input -Terminal 1: +1. Contain: restart bridge with `COMMANDRELAY_INPUT_KILL_SWITCH=on` to freeze all writers while triaging. +2. Verify block: ```bash cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -COMMANDRELAY_INPUT_KILL_SWITCH=off npm run start -``` - -Terminal 2: - -```bash -cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -npm run bench:input -- --iterations 5 +npm run bench:input -- --iterations 3 ``` -Pass signal: - -1. Benchmark exits `0`. -2. Output includes input ack latency summary. +3. Pass signal: benchmark exits non-zero and reports input disabled after `enable_input`. +4. Recover: only after explicit operator approval, restart with `COMMANDRELAY_INPUT_KILL_SWITCH=off` and re-run `npm run bench:input -- --iterations 5` (must exit `0`). -### C) Live smoke with kill switch on (input must be blocked) +### C) Incident: lane lockout (`input_lane_conflict`) -Terminal 1 (restart bridge): +1. Identify owner from conflict payload (`ownerClientId`) and request owner handoff (`disable_input` then `detach`/disconnect). +2. If owner is stale, wait for lease expiry (`COMMANDRELAY_INPUT_LANE_LEASE_MS`) or perform explicit takeover per policy. +3. If lockout persists across reconnects, temporarily freeze writes with kill switch (`on`), clear stale clients, then resume with kill switch (`off`). +4. Verification gate: ```bash cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -COMMANDRELAY_INPUT_KILL_SWITCH=on npm run start +node --import tsx --test src/server/bridge-server.policy.test.ts ``` -Terminal 2: - -```bash -cd /mnt/c/sriinnu/personal/Kaala-brahma/terminal -npm run bench:input -- --iterations 3 -``` +5. Pass signal: lane conflict and takeover paths pass (`# fail 0`) and intended writer can send input after handoff. -Pass signal: +### D) Evidence to record in checkpoint artifacts -1. Benchmark exits non-zero. -2. Failure message reports that input remained disabled after `enable_input` (kill switch effective). +1. Incident UTC timestamp, active commit SHA, and trigger symptom. +2. Containment action (`kill switch on/off`, handoff/takeover path) and operator identity. +3. Verification commands executed and pass/fail outcome. +4. Link the incident note in `scripts/checkpoints/runs/*-checkpoint.md`. ## iOS Protocol Mock Package Usage @@ -387,6 +406,23 @@ What this validates: 4. Input dispatch latency. 5. Reconnect and replay success rate. +## Weekly Observability Baseline and Evidence Pack + +Use [`docs/observability-evidence-contract.md`](./observability-evidence-contract.md) as the normative weekly checkpoint contract. + +Required weekly artifact flow: + +1. Keep all artifacts in `scripts/checkpoints/runs/` using date-prefixed filenames. +2. Produce these files for each weekly checkpoint: + - `YYYY-MM-DD-weekly-cross-platform-checkpoint.md` + - `YYYY-MM-DD-command-evidence.md` + - `YYYY-MM-DD-observability-metrics.json` + - `YYYY-MM-DD-dashboard-baseline.md` + - `YYYY-MM-DD-soak-or-incident.md` +3. Use canonical metric names exactly as specified (`cr_connect_latency_ms`, `cr_replay_lag_events`, `cr_reconnect_total`, `cr_input_ack_latency_ms`, `cr_lane_conflict_total`, `cr_kill_switch_block_total`). +4. Record command outputs and pass signals in the command evidence file using the mapping table in the contract doc. +5. Any missing artifact must be explicitly marked `not-run` with owner and next action in the checkpoint summary. + ## Logs Minimum log fields: diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index a6f6ff4..53bed3e 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -335,11 +335,15 @@ Message handling and policy stage: Streaming/runtime failure stage: 23. `pane_poll_failed` -24. `handler_failed` +24. `runtime_session_unavailable` +25. `transport_drop` +26. `invalid_pane_target` +27. `handler_failed` Ownership note: 1. Current runtime emits `input_lane_conflict` when another client owns a pane input lane and takeover is not requested/allowed. +2. Recoverable rejection payloads include `recoverable=true` and should be treated as retriable by clients once user action/environment changes. ## 9. End-to-End Example @@ -375,3 +379,9 @@ Web Tab A Server 1. Runtime supports strict and compatibility parser modes. 2. Runtime default is strict parse mode (`COMMANDRELAY_STRICT_PROTOCOL_PARSING=true`), with optional compatibility mode when disabled. 3. Clients should treat unknown server events as ignorable unless operating in strict test mode. + +## 11. Contract Compatibility Test Plan References + +1. `TP-WS-MATRIX-COMPAT`: compatibility operation coverage is defined by [SSH transport operation matrix](./ssh-transport-contract.md#operation-contract-matrix). +2. `TP-WS-MATRIX-RECONNECT`: reconnect-resume coverage is defined by [SSH transport reconnect semantics](./ssh-transport-contract.md#explicit-reconnect-semantics). +3. `TP-WS-MATRIX-ASSERTIONS`: executable conformance assertions are implemented in `src/server/ws-contract-matrix.test.ts`. diff --git a/docs/proxy-ecosystem-roadmap.md b/docs/proxy-ecosystem-roadmap.md index 6da1a2a..cdda190 100644 --- a/docs/proxy-ecosystem-roadmap.md +++ b/docs/proxy-ecosystem-roadmap.md @@ -1,6 +1,6 @@ # Proxy Ecosystem Roadmap -Last updated: 2026-02-26 +Last updated: 2026-02-27 This roadmap expands `@commandrelay/proxy-*` for external reuse in other projects (`termina/*`, proxy-agents-style stacks, service SDKs). @@ -57,6 +57,17 @@ Priority scale: 3. `@termina/proxy-fetch`: complete and internally ready. 4. `P2` can proceed (`proxy-axios`, `proxy-got`) once publish/release gates are cleared. +## Milestone Decision Mirror 2026-02-27 CR-P1-002 + +This section mirrors the 2026-02-27 weekly evidence-lane decisions into the proxy roadmap without claiming blocked execution steps as complete. + +| Decision | Date | Status | Evidence | Blocker/Next Step | +| --- | --- | --- | --- | --- | +| W2 Track B docs/examples pack for `@commandrelay/proxy-*` remains complete. | 2026-02-27 | `done` | [TODO B2 status](./TODO.md#b2-productization-readiness), [package docs coverage matrix](./proxy/package-docs-matrix.md) | Maintain coverage as package APIs change. | +| W2 publish dry-run evidence contains selector + dist-tag, but remains blocked from clean dry-run success. | 2026-02-27 | `partial` | [2026-02-27 proxy publish local dry-run checkpoint](../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md), [TODO W2 acceptance status](./TODO.md#milestone-w2-2026-03-09-to-2026-03-15) | Clear local npm cache ownership issue (`EACCES`) and rerun `npm pack/publish --dry-run` artifact capture. | +| W2 replay-ordering acceptance is now proven by a fixture-harness pass artifact. | 2026-02-27 | `done` | [2026-02-27 fixture harness evidence run](../scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md), [tmux fixture runbook](../scripts/tmux-fixtures/README.md), [TODO W2 acceptance status](./TODO.md#milestone-w2-2026-03-09-to-2026-03-15) | Keep run in weekly checkpoint rotation and alert on assertion regressions. | +| CR-P1-002 docs synchronization is complete for this cycle. | 2026-02-27 | `done` | [CR-P1-002 ticket status](./execution-owned-tickets.md#cr-p1-002-update-weekly-checkpoint--mirror-milestone-decisions), [2026-02-27 weekly evidence lane artifact](../scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md) | Re-open next cycle if milestones change. | + ## Package Discovery and Use Strategy 1. Start with `@commandrelay/proxy-core` for environment parsing and route decisions. diff --git a/docs/proxy/package-docs-matrix.md b/docs/proxy/package-docs-matrix.md new file mode 100644 index 0000000..c779b3c --- /dev/null +++ b/docs/proxy/package-docs-matrix.md @@ -0,0 +1,27 @@ +# Proxy Package Docs Coverage Matrix (B2 Evidence) + +Last reviewed: 2026-02-27 +Scope: `packages/*` proxy ecosystem documentation coverage for B2 productization checks. + +## Criteria + +- `README usage matrix`: README should provide explicit usage mapping across supported integration modes. +- `NOTES + migration/compat/troubleshooting`: package docs should include integration notes plus migration/compatibility and troubleshooting guidance. +- `Runnable examples + expected output snapshots`: examples should be executable and include expected output/result snapshots. + +## Per-Package Coverage + +| Package | README usage matrix | NOTES + migration/compat/troubleshooting | Runnable examples + expected output snapshots | Evidence | Remaining gaps | +| --- | --- | --- | --- | --- | --- | +| `@termina/cli-proxy` | `done` | `done` | `done` | [README usage matrix](../../packages/cli-proxy/README.md#usage-matrix), [README migration](../../packages/cli-proxy/README.md#migration), [README troubleshooting](../../packages/cli-proxy/README.md#troubleshooting), [NOTES](../../packages/cli-proxy/NOTES.md), [examples index](../../packages/cli-proxy/docs/examples/README.md), [env human snapshot](../../packages/cli-proxy/docs/examples/snapshots/env.human.expected.txt) | None. | +| `@commandrelay/proxy-agent` | `done` | `done` | `done` | [README usage matrix](../../packages/proxy-agent/README.md#usage-matrix), [README migration/compat](../../packages/proxy-agent/README.md#migration-and-compatibility), [README troubleshooting](../../packages/proxy-agent/README.md#troubleshooting), [NOTES](../../packages/proxy-agent/NOTES.md), [examples index](../../packages/proxy-agent/docs/examples/README.md), [axios snapshot](../../packages/proxy-agent/docs/examples/snapshots/axios.expected.json), [undici snapshot](../../packages/proxy-agent/docs/examples/snapshots/undici.expected.json), [got snapshot](../../packages/proxy-agent/docs/examples/snapshots/got.expected.json), [fetch snapshot](../../packages/proxy-agent/docs/examples/snapshots/fetch.expected.json) | None. | +| `@commandrelay/proxy-core` | `done` | `done` | `done` | [README usage matrix](../../packages/proxy-core/README.md#usage-matrix), [README migration/compat](../../packages/proxy-core/README.md#migration-and-compatibility), [README troubleshooting](../../packages/proxy-core/README.md#troubleshooting), [NOTES](../../packages/proxy-core/NOTES.md), [examples index](../../packages/proxy-core/docs/examples/README.md), [settings snapshot](../../packages/proxy-core/docs/examples/snapshots/settings.expected.json), [resolve snapshot](../../packages/proxy-core/docs/examples/snapshots/resolve.expected.json) | None. | +| `@termina/proxy-fetch` | `done` | `done` | `done` | [README usage matrix](../../packages/proxy-fetch/README.md#usage-matrix), [README migration](../../packages/proxy-fetch/README.md#migration), [README troubleshooting](../../packages/proxy-fetch/README.md#troubleshooting), [NOTES](../../packages/proxy-fetch/NOTES.md), [examples index](../../packages/proxy-fetch/docs/examples/README.md), [one-shot snapshot](../../packages/proxy-fetch/docs/examples/snapshots/one-shot.expected.json), [client snapshot](../../packages/proxy-fetch/docs/examples/snapshots/client.expected.json) | None. | +| `@commandrelay/proxy-http-client` | `done` | `done` | `done` | [README usage matrix](../../packages/proxy-http-client/README.md#usage-matrix), [README migration/compat](../../packages/proxy-http-client/README.md#migration-and-compatibility), [README troubleshooting](../../packages/proxy-http-client/README.md#troubleshooting), [NOTES](../../packages/proxy-http-client/NOTES.md), [examples index](../../packages/proxy-http-client/docs/examples/README.md), [axios snapshot](../../packages/proxy-http-client/docs/examples/snapshots/axios.expected.json), [undici snapshot](../../packages/proxy-http-client/docs/examples/snapshots/undici.expected.json), [got snapshot](../../packages/proxy-http-client/docs/examples/snapshots/got.expected.json), [fetch snapshot](../../packages/proxy-http-client/docs/examples/snapshots/fetch.expected.json) | None. | +| `@termina/proxy-undici` | `done` | `done` | `done` | [README usage matrix](../../packages/proxy-undici/README.md#usage-matrix), [README migration](../../packages/proxy-undici/README.md#migration), [README troubleshooting](../../packages/proxy-undici/README.md#troubleshooting), [NOTES](../../packages/proxy-undici/NOTES.md), [examples index](../../packages/proxy-undici/docs/examples/README.md), [request snapshot](../../packages/proxy-undici/docs/examples/snapshots/request.expected.json), [fetch snapshot](../../packages/proxy-undici/docs/examples/snapshots/fetch.expected.json) | None. | + +## B2 Decision Inputs + +- B2 docs-pack status: `done` (all six packages include README usage matrix, NOTES, migration/compatibility guidance, and troubleshooting guidance). +- B2 runnable-examples status: `done` (all six packages include runnable examples with expected output snapshots in package example docs). +- Evidence above is limited to repository files and now reflects the latest package doc/example snapshot updates. diff --git a/docs/proxy/package-model.md b/docs/proxy/package-model.md index fd8e218..fdf32e9 100644 --- a/docs/proxy/package-model.md +++ b/docs/proxy/package-model.md @@ -86,3 +86,13 @@ Caller - `npm --prefix packages/proxy-core test` - `npm --prefix packages/proxy-agent test` - `npm --prefix packages/proxy-http-client test` + +## Failure-Mode Matrix + +| Scenario | Input source | Expected behavior | +| --- | --- | --- | +| Malformed credential-bearing proxy URL (for example `http://user:pass@:8080`) | Explicit `ProxyAgentFactory` settings | `resolve()` throws `invalid_proxy_url` (no silent direct fallback). | +| Unsupported proxy scheme (for example `ftp://proxy.local:21`) | Explicit settings | `resolve()` throws `unsupported_proxy_protocol:*`. | +| Unsupported target scheme with selected proxy (for example target `ftp://...` + `ALL_PROXY=http://...`) | Target URL + explicit settings | `resolve()` throws `unsupported_target_protocol:*`. | +| PAC resolver/network/script failure | `pac+*` proxy URL | Default behavior is fail-closed (`fallbackToDirect: false`), so runtime should not silently downgrade to direct mode. | +| Invalid or unsupported env proxy values | Environment (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) | Values are sanitized to `null`; factory resolves in direct mode (`viaProxy=false`). | diff --git a/docs/proxy/security-performance.md b/docs/proxy/security-performance.md index 8fb3085..0dba441 100644 --- a/docs/proxy/security-performance.md +++ b/docs/proxy/security-performance.md @@ -25,6 +25,17 @@ This guide captures current security and performance behavior for `@commandrelay - `src/index.ts` initializes proxy settings/factory and logs detection, but does not make outbound control-plane requests in startup flow. - Malformed proxy env values are sanitized to `null`; they do not hard-fail startup. +## Negative-Case Guarantees + +- Malformed or unsupported proxy URL env values are sanitized to `null` and never throw during settings load. +- Malformed `NO_PROXY` tokens are ignored safely; valid tokens in the same list still apply. +- `NO_PROXY` suffix matching enforces label boundaries (`badexample.com` does not match `example.com`). +- Invalid `NO_PROXY` ports (for example `:99999`) degrade to host-only matching without parser failure. +- Fallback behavior is explicit: + - `https`/`wss`: `HTTPS_PROXY -> HTTP_PROXY -> ALL_PROXY` + - `http`/`ws`: `HTTP_PROXY -> ALL_PROXY` + - unknown schemes: `ALL_PROXY` only (otherwise direct) + ## Production Security Guidance - Keep `COMMANDRELAY_HOST` loopback unless `COMMANDRELAY_AUTH_TOKEN` is set. diff --git a/docs/release/proxy-publish.md b/docs/release/proxy-publish.md index 90255ae..248c959 100644 --- a/docs/release/proxy-publish.md +++ b/docs/release/proxy-publish.md @@ -91,9 +91,9 @@ Use this only when your release process already guarantees approval and version - `proxy-agent` TAP `/` - `proxy-http-client` TAP `/` - [ ] Run full validation on home Mac: `npm run check && npm test && npm run test:ci:all`. -- [ ] Trigger dry-run publish (`mode=dry-run`, `package_selector=@commandrelay/proxy-*`, `dist_tag=latest`). +- [ ] Trigger dry-run publish (`mode=dry-run`, `package_selector=@commandrelay/proxy-*`, `dist_tag=latest`). Status (2026-02-27): local CLI dry-run evidence captured; GitHub Actions dry-run not triggered in this step. - [ ] Verify GitHub policy (`NPM_TOKEN`, `npm-publish` reviewers, default-branch protections). -- [ ] Capture dry-run run URL + artifact summary in checkpoint/release notes before any publish-mode trigger. +- [x] Capture dry-run artifact summary in checkpoint/release notes before any publish-mode trigger. Artifact: [`scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md`](../../scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md). Status: `partial` (`check/build/test` passed for all selected packages; `npm pack --dry-run --json` and `npm publish --dry-run` blocked by npm cache `EACCES` on `/home/sriinnu/.npm`). ## Internal v0.1 Gate Checklist (tag prep only) diff --git a/docs/ssh-transport-contract.md b/docs/ssh-transport-contract.md new file mode 100644 index 0000000..dc5bb3f --- /dev/null +++ b/docs/ssh-transport-contract.md @@ -0,0 +1,78 @@ +## SSH Transport Contract (CommandRelay) + +Status: Active runtime contract. + +This contract defines runtime and client behavior when CommandRelay is operated over SSH transport. + +## Transport assumptions +1. SSH is the only transport from bridge to backend; tmux sockets are never exposed directly. +2. Connection target is `COMMANDRELAY_SSH_TARGET` and must match `[user@]host` (hostname or bracketed IPv6). +3. SSH command is `COMMANDRELAY_SSH_COMMAND` (`ssh` default) and is used for startup preflight and runtime execution. +4. Host key verification policy is explicit per environment; production must keep strict checking enabled. +5. SSH execution is non-interactive and bounded by connection and command timeouts. +6. Transport carries terminal stream data and control messages only; file transfer is out of scope. + +## Startup/runtime constraints +1. `COMMANDRELAY_TRANSPORT_MODE=ssh` requires `COMMANDRELAY_RUNTIME_BACKENDS=tmux`. +2. Runtime operations in `ssh` mode execute tmux commands on the remote SSH target. +3. Startup preflight must validate SSH client availability (` -V`) before runtime starts. + +## Operation Contract Matrix +1. `connect` (`C<->S`): client opens WebSocket `/ws`; server immediately emits `hello` with `clientId`, `requiresAuth`, and read-only policy baseline (`inputEnabled=false`). +2. `auth` (`C->S`): request carries `payload.token` when token mode is enabled; server returns `auth_ok` or `auth_error(code=invalid_token)`. +3. `list` (`C->S`): client sends `list_sessions`; server returns `session_list` with pane/session inventory. +4. `attach` (`C->S`): client sends `attach` with required `paneId` and optional `lastSeq`; server attaches pane stream and returns `ack(action=attach)`. +5. `replay` (`S->C output flow`): there is no standalone `replay` message type; replay is negotiated through `attach(lastSeq)` and delivered as `output` events (`streamSeq > lastSeq`) or snapshot fallback. +6. `input` (`C->S`): accepted only when policy allows write input (`inputEnabled && !globalInputDisabled`), pane is attached, ownership rules pass, and payload size/rate limits pass. +7. `ack` (`S->C`): success envelope for command-like actions (`attach`, `detach`, `input`, `disconnect`) with original `requestId`. +8. `error` (`S->C`): rejection envelope for parse/auth/policy/runtime failures, including request correlation via `requestId` when available. + +## Session and runtime model (tmux persistence) +1. tmux is long-lived and survives bridge/client disconnects. +2. Bridge attaches and detaches from existing tmux sessions without terminating the shell process tree. +3. Session identity is stable across reconnects and unique per backend runtime. +4. Pane attachment is explicit; one client attachment context maps to one active pane target. +5. Runtime tracks output sequence state (`streamSeq`) to support bounded replay after reconnect. + +## Explicit reconnect semantics +1. Reconnect starts with a new transport connection and new `hello.payload.clientId`; prior connection-scoped write lane ownership does not carry over. +2. Client re-runs auth when `hello.payload.requiresAuth=true` before non-auth operations. +3. Client reattaches each pane using `attach` and SHOULD include previous `lastSeq` cursor for replay continuity. +4. Runtime replays buffered `output` events where `streamSeq > lastSeq`; when full range is unavailable, runtime falls back to current snapshot at latest sequence. +5. Reconnect never re-enables write mode automatically; client must explicitly request `enable_input` again. +6. Transport reconnect uses bounded exponential backoff with jitter and retry limits; repeated exhaustion marks backend unavailable until explicit retry. + +## Safety controls +1. Default mode is read-only; write input requires explicit per-client enablement. +2. Auth is mandatory before non-auth operations when token mode is enabled. +3. Input ownership is exclusive per pane; conflicting writes are rejected unless override policy allows takeover. +4. Input path enforces max payload size and per-client rate limits before dispatch. +5. Audit logs capture auth results, attachment changes, write-lane toggles, and input attempts. +6. Production transport must not run SSH with verbose (`-v`) logging. +7. Termination paths must prefer graceful session close; avoid `kill -9` as normal control flow. + +## Failure classification +1. `transport`: socket/SSH lifecycle errors, reconnect exhaustion, or handshake failure. +2. `runtime`: attach/list/output/input execution failures against tmux runtime. +3. `policy`: auth-required, input-disabled, ownership-conflict, rate-limit, or payload-size violations. + +## Client UX states +1. `connecting`: transport/auth bootstrap is in progress; controls are disabled. +2. `read_only`: output stream active, write lane disabled. +3. `write_enabled`: client owns pane write lane and input is accepted. +4. `conflict`: another client owns write lane; takeover is policy-gated. +5. `reconnecting`: transport dropped; replay/reattach pending. +6. `degraded`: attached but replay continuity is partial or unavailable. +7. `disconnected`: no active transport; explicit reconnect required. + +## Non-goals +1. Multi-writer collaborative command entry on the same pane. +2. Infinite or lossless replay guarantees beyond bounded in-memory history. +3. Built-in SSH key lifecycle management or secret vault behavior. +4. Shell intent parsing, semantic key translation, or command policy inference. +5. Zero-downtime guarantees across backend host restarts or tmux daemon crashes. + +## Protocol v1 test-plan references +1. `TP-WS-MATRIX-COMPAT`: align operation compatibility with [Bridge Protocol v1](./protocol-v1.md#11-contract-compatibility-test-plan-references) and enforce via `ws-contract-matrix` assertions. +2. `TP-WS-MATRIX-RECONNECT`: align reconnect semantics with protocol `attach(lastSeq)` replay expectations and enforce via `ws-contract-matrix` assertions. +3. `TP-WS-MATRIX-ASSERTIONS`: source-of-truth tests are in `src/server/ws-contract-matrix.test.ts`. diff --git a/packages/cli-proxy/NOTES.md b/packages/cli-proxy/NOTES.md index b5ff091..d976977 100644 --- a/packages/cli-proxy/NOTES.md +++ b/packages/cli-proxy/NOTES.md @@ -2,6 +2,19 @@ Use `@termina/cli-proxy` for proxy diagnostics in CI jobs, runtime startup checks, or support bundles. +## Compatibility Checklist + +- Node.js `>=18`. +- ESM package consumption/runtime. +- Optional `@commandrelay/proxy-agent` peer dependency only for agent enrichment in `explain`. + +## Migration Checklist + +1. Replace one-off shell parsing with `env --json` output. +2. Replace custom route checks with `explain --json --no-agent` for deterministic CI artifacts. +3. Store output snapshots in build artifacts for incident triage. +4. Treat CLI parse errors as usage/configuration failures (exit code `2`). + ## Quick start checklist 1. Run `termina-cli-proxy env` during startup validation. @@ -13,12 +26,16 @@ Use `@termina/cli-proxy` for proxy diagnostics in CI jobs, runtime startup check ```bash termina-cli-proxy env --json > proxy-env-report.json -termina-cli-proxy explain --json https://api.example.com https://telemetry.example.com > proxy-route-report.json +termina-cli-proxy explain --json --no-agent https://api.example.com https://telemetry.example.com > proxy-route-report.json ``` -## Operational notes +## Troubleshooting Playbook -- Lowercase proxy variables override uppercase values. -- In CGI mode (`REQUEST_METHOD` set), uppercase `HTTP_PROXY` is ignored. -- Invalid URL inputs are reported per-route with `decision=error` and `error=invalid_target_url`. -- `NO_PROXY` matches are surfaced explicitly in explain output. +- Unexpected uppercase precedence: + - Lowercase proxy variables override uppercase values. +- CGI behavior surprises: + - In CGI mode (`REQUEST_METHOD` set), uppercase `HTTP_PROXY` is ignored. +- Bad URL input: + - Invalid URL inputs are reported per-route with `decision=error` and `error=invalid_target_url`. +- `NO_PROXY` ambiguity: + - `NO_PROXY` matches are surfaced explicitly in explain output (`matchedNoProxyRule`). diff --git a/packages/cli-proxy/README.md b/packages/cli-proxy/README.md index 718d61e..2cff99a 100644 --- a/packages/cli-proxy/README.md +++ b/packages/cli-proxy/README.md @@ -22,6 +22,22 @@ npm install @commandrelay/proxy-agent - npm `>=9` - ESM package (`"type": "module"`) +## Compatibility + +- CLI/runtime package for Node environments. +- `@commandrelay/proxy-agent` is an optional peer dependency used only for agent-level explain details. +- Lowercase proxy env vars override uppercase variants. +- In CGI mode (`REQUEST_METHOD` set), uppercase `HTTP_PROXY` is ignored for safety. + +## Migration + +`@termina/cli-proxy` is currently `0.1.x`; there is no prior package-specific breaking release. Typical migration is from shell scripts or custom debug tooling. + +1. Replace custom env debug scripts with `termina-cli-proxy env --json`. +2. Replace hand-written routing checks with `termina-cli-proxy explain --json `. +3. If you do not need optional agent metadata, run with `--no-agent` for deterministic output in CI. +4. Update automation to treat parse/usage failures as exit code `2`. + ## CLI Binary names: @@ -63,9 +79,18 @@ For each URL, reports: Use `--json` for machine-readable output: ```bash -termina-cli-proxy explain --json https://example.com https://api.internal.local +termina-cli-proxy explain --json --no-agent https://example.com https://api.internal.local ``` +## Usage Matrix + +| Operational need | Command/API path | Why | +| --- | --- | --- | +| Validate effective proxy env in CI or containers | `termina-cli-proxy env --json` | Emits normalized settings from runtime env with stable machine output | +| Explain route decisions for specific outbound URLs | `termina-cli-proxy explain [--json] ` | Shows proxy/direct choice, source, and matched `NO_PROXY` rule | +| Keep output deterministic without optional agent dependency | `termina-cli-proxy explain --no-agent ...` | Avoids optional peer loading and agent metadata variance | +| Embed diagnostics in Node scripts | Programmatic `inspectProxyEnvironment` / `explainProxyRoutes` | Reuses CLI logic without shelling out | + ## Programmatic API ```ts @@ -85,11 +110,22 @@ const explain = await explainProxyRoutes(["https://example.com"], { console.log(inspection.settings.httpProxy, explain.routes[0]?.decision); ``` +## Troubleshooting + +- `Unknown command` or `Unknown option`: + - Use `termina-cli-proxy help`; parse failures return exit code `2`. +- Route output does not match expected proxy: + - Re-check `NO_PROXY` inputs and whether lowercase env vars shadow uppercase values. +- `agentSupport: unavailable` in explain output: + - Install optional `@commandrelay/proxy-agent` or run with `--no-agent`. +- Invalid URL route entries: + - `decision=error` and `error=invalid_target_url` indicate malformed URL input. + ## Examples - Overview: [docs/examples/README.md](./docs/examples/README.md) -- Environment inspection: [docs/examples/env.md](./docs/examples/env.md) -- Route explanation: [docs/examples/explain.md](./docs/examples/explain.md) +- Environment inspection + snapshots: [docs/examples/env.md](./docs/examples/env.md) +- Route explanation + snapshots: [docs/examples/explain.md](./docs/examples/explain.md) ## Notes diff --git a/packages/cli-proxy/docs/examples/README.md b/packages/cli-proxy/docs/examples/README.md index 1c2038f..3dc37bd 100644 --- a/packages/cli-proxy/docs/examples/README.md +++ b/packages/cli-proxy/docs/examples/README.md @@ -1,4 +1,10 @@ # cli-proxy Examples +All examples below include expected output snapshots under [`./snapshots`](./snapshots/). + - [Environment inspection](./env.md) + - Human snapshot: [`./snapshots/env.human.expected.txt`](./snapshots/env.human.expected.txt) + - JSON snapshot: [`./snapshots/env.json.expected.json`](./snapshots/env.json.expected.json) - [Route explanation](./explain.md) + - Human snapshot: [`./snapshots/explain.human.expected.txt`](./snapshots/explain.human.expected.txt) + - JSON snapshot: [`./snapshots/explain.json.expected.json`](./snapshots/explain.json.expected.json) diff --git a/packages/cli-proxy/docs/examples/env.md b/packages/cli-proxy/docs/examples/env.md index 602c050..ede66b6 100644 --- a/packages/cli-proxy/docs/examples/env.md +++ b/packages/cli-proxy/docs/examples/env.md @@ -3,11 +3,126 @@ ## Human output ```bash +env -i PATH="$PATH" \ +http_proxy=http://proxy.local:8080 \ +no_proxy=internal.local \ termina-cli-proxy env ``` +Expected snapshot file: [`./snapshots/env.human.expected.txt`](./snapshots/env.human.expected.txt) + +```text +Proxy Environment Inspection + +CGI mode: no +Environment variables: + http_proxy=http://proxy.local:8080 + HTTP_PROXY= + https_proxy= + HTTPS_PROXY= + all_proxy= + ALL_PROXY= + no_proxy=internal.local + NO_PROXY= + REQUEST_METHOD= + request_method= + +Resolution: + httpProxy: http_proxy=http://proxy.local:8080 + httpsProxy: + allProxy: + noProxy: no_proxy=internal.local + +Effective settings: + httpProxy=http://proxy.local:8080/ + httpsProxy= + allProxy= + noProxyRules: + - *.internal.local +``` + ## JSON output ```bash +env -i PATH="$PATH" \ +http_proxy=http://proxy.local:8080 \ +no_proxy=internal.local \ termina-cli-proxy env --json ``` + +Expected snapshot file: [`./snapshots/env.json.expected.json`](./snapshots/env.json.expected.json) + +```json +{ + "command": "env", + "inspection": { + "cgiMode": false, + "variables": { + "http_proxy": "http://proxy.local:8080", + "HTTP_PROXY": null, + "https_proxy": null, + "HTTPS_PROXY": null, + "all_proxy": null, + "ALL_PROXY": null, + "no_proxy": "internal.local", + "NO_PROXY": null, + "REQUEST_METHOD": null, + "request_method": null + }, + "resolution": [ + { + "logicalName": "httpProxy", + "selectedKey": "http_proxy", + "selectedValue": "http://proxy.local:8080", + "lowerKey": "http_proxy", + "lowerValue": "http://proxy.local:8080", + "upperKey": "HTTP_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "httpsProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "https_proxy", + "lowerValue": null, + "upperKey": "HTTPS_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "allProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "all_proxy", + "lowerValue": null, + "upperKey": "ALL_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "noProxy", + "selectedKey": "no_proxy", + "selectedValue": "internal.local", + "lowerKey": "no_proxy", + "lowerValue": "internal.local", + "upperKey": "NO_PROXY", + "upperValue": null, + "ignoredUppercase": false + } + ], + "settings": { + "httpProxy": "http://proxy.local:8080/", + "httpsProxy": null, + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + } + ] + } + } +} +``` diff --git a/packages/cli-proxy/docs/examples/explain.md b/packages/cli-proxy/docs/examples/explain.md index 80f490b..912a581 100644 --- a/packages/cli-proxy/docs/examples/explain.md +++ b/packages/cli-proxy/docs/examples/explain.md @@ -3,17 +3,158 @@ ## Explain two URLs in human mode ```bash -termina-cli-proxy explain https://example.com https://api.internal.local +env -i PATH="$PATH" \ +https_proxy=http://secure-proxy.local:8443 \ +no_proxy=internal.local \ +termina-cli-proxy explain --no-agent https://public.example.com https://api.internal.local +``` + +Expected snapshot file: [`./snapshots/explain.human.expected.txt`](./snapshots/explain.human.expected.txt) + +```text +Proxy Route Explain +Agent support: disabled + +[1] https://public.example.com + decision: proxy + target: https://public.example.com/ + protocol: https: + proxyUrl: http://secure-proxy.local:8443/ + proxySource: httpsProxy + matchedNoProxyRule: + reason: Route uses httpsProxy with proxy http://secure-proxy.local:8443/. + +[2] https://api.internal.local + decision: direct + target: https://api.internal.local/ + protocol: https: + proxyUrl: + proxySource: + matchedNoProxyRule: *.internal.local + reason: Direct route due to NO_PROXY rule *.internal.local. ``` ## Explain with JSON output ```bash -termina-cli-proxy explain --json https://example.com https://api.internal.local +env -i PATH="$PATH" \ +https_proxy=http://secure-proxy.local:8443 \ +no_proxy=internal.local \ +termina-cli-proxy explain --json --no-agent https://public.example.com https://api.internal.local ``` -## Skip optional proxy-agent details +Expected snapshot file: [`./snapshots/explain.json.expected.json`](./snapshots/explain.json.expected.json) + +```json +{ + "command": "explain", + "inspection": { + "cgiMode": false, + "variables": { + "http_proxy": null, + "HTTP_PROXY": null, + "https_proxy": "http://secure-proxy.local:8443", + "HTTPS_PROXY": null, + "all_proxy": null, + "ALL_PROXY": null, + "no_proxy": "internal.local", + "NO_PROXY": null, + "REQUEST_METHOD": null, + "request_method": null + }, + "resolution": [ + { + "logicalName": "httpProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "http_proxy", + "lowerValue": null, + "upperKey": "HTTP_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "httpsProxy", + "selectedKey": "https_proxy", + "selectedValue": "http://secure-proxy.local:8443", + "lowerKey": "https_proxy", + "lowerValue": "http://secure-proxy.local:8443", + "upperKey": "HTTPS_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "allProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "all_proxy", + "lowerValue": null, + "upperKey": "ALL_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "noProxy", + "selectedKey": "no_proxy", + "selectedValue": "internal.local", + "lowerKey": "no_proxy", + "lowerValue": "internal.local", + "upperKey": "NO_PROXY", + "upperValue": null, + "ignoredUppercase": false + } + ], + "settings": { + "httpProxy": null, + "httpsProxy": "http://secure-proxy.local:8443/", + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + } + ] + } + }, + "routes": [ + { + "input": "https://public.example.com", + "decision": "proxy", + "targetUrl": "https://public.example.com/", + "targetProtocol": "https:", + "proxyUrl": "http://secure-proxy.local:8443/", + "proxySource": "httpsProxy", + "matchedNoProxyRule": null, + "reason": "Route uses httpsProxy with proxy http://secure-proxy.local:8443/.", + "agent": null, + "error": null + }, + { + "input": "https://api.internal.local", + "decision": "direct", + "targetUrl": "https://api.internal.local/", + "targetProtocol": "https:", + "proxyUrl": null, + "proxySource": null, + "matchedNoProxyRule": { + "host": "internal.local", + "port": null, + "matchSubdomains": true + }, + "reason": "Direct route due to NO_PROXY rule *.internal.local.", + "agent": null, + "error": null + } + ], + "agentSupport": "disabled" +} +``` + +## Invalid URL example ```bash -termina-cli-proxy explain --no-agent https://example.com +termina-cli-proxy explain --no-agent not-a-url ``` + +For invalid entries, each route is reported with `decision=error` and `error=invalid_target_url`. diff --git a/packages/cli-proxy/docs/examples/snapshots/env.human.expected.txt b/packages/cli-proxy/docs/examples/snapshots/env.human.expected.txt new file mode 100644 index 0000000..ff2bf5e --- /dev/null +++ b/packages/cli-proxy/docs/examples/snapshots/env.human.expected.txt @@ -0,0 +1,27 @@ +Proxy Environment Inspection + +CGI mode: no +Environment variables: + http_proxy=http://proxy.local:8080 + HTTP_PROXY= + https_proxy= + HTTPS_PROXY= + all_proxy= + ALL_PROXY= + no_proxy=internal.local + NO_PROXY= + REQUEST_METHOD= + request_method= + +Resolution: + httpProxy: http_proxy=http://proxy.local:8080 + httpsProxy: + allProxy: + noProxy: no_proxy=internal.local + +Effective settings: + httpProxy=http://proxy.local:8080/ + httpsProxy= + allProxy= + noProxyRules: + - *.internal.local diff --git a/packages/cli-proxy/docs/examples/snapshots/env.json.expected.json b/packages/cli-proxy/docs/examples/snapshots/env.json.expected.json new file mode 100644 index 0000000..33d46ef --- /dev/null +++ b/packages/cli-proxy/docs/examples/snapshots/env.json.expected.json @@ -0,0 +1,72 @@ +{ + "command": "env", + "inspection": { + "cgiMode": false, + "variables": { + "http_proxy": "http://proxy.local:8080", + "HTTP_PROXY": null, + "https_proxy": null, + "HTTPS_PROXY": null, + "all_proxy": null, + "ALL_PROXY": null, + "no_proxy": "internal.local", + "NO_PROXY": null, + "REQUEST_METHOD": null, + "request_method": null + }, + "resolution": [ + { + "logicalName": "httpProxy", + "selectedKey": "http_proxy", + "selectedValue": "http://proxy.local:8080", + "lowerKey": "http_proxy", + "lowerValue": "http://proxy.local:8080", + "upperKey": "HTTP_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "httpsProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "https_proxy", + "lowerValue": null, + "upperKey": "HTTPS_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "allProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "all_proxy", + "lowerValue": null, + "upperKey": "ALL_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "noProxy", + "selectedKey": "no_proxy", + "selectedValue": "internal.local", + "lowerKey": "no_proxy", + "lowerValue": "internal.local", + "upperKey": "NO_PROXY", + "upperValue": null, + "ignoredUppercase": false + } + ], + "settings": { + "httpProxy": "http://proxy.local:8080/", + "httpsProxy": null, + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + } + ] + } + } +} diff --git a/packages/cli-proxy/docs/examples/snapshots/explain.human.expected.txt b/packages/cli-proxy/docs/examples/snapshots/explain.human.expected.txt new file mode 100644 index 0000000..5f640bc --- /dev/null +++ b/packages/cli-proxy/docs/examples/snapshots/explain.human.expected.txt @@ -0,0 +1,20 @@ +Proxy Route Explain +Agent support: disabled + +[1] https://public.example.com + decision: proxy + target: https://public.example.com/ + protocol: https: + proxyUrl: http://secure-proxy.local:8443/ + proxySource: httpsProxy + matchedNoProxyRule: + reason: Route uses httpsProxy with proxy http://secure-proxy.local:8443/. + +[2] https://api.internal.local + decision: direct + target: https://api.internal.local/ + protocol: https: + proxyUrl: + proxySource: + matchedNoProxyRule: *.internal.local + reason: Direct route due to NO_PROXY rule *.internal.local. diff --git a/packages/cli-proxy/docs/examples/snapshots/explain.json.expected.json b/packages/cli-proxy/docs/examples/snapshots/explain.json.expected.json new file mode 100644 index 0000000..f2ba4e5 --- /dev/null +++ b/packages/cli-proxy/docs/examples/snapshots/explain.json.expected.json @@ -0,0 +1,103 @@ +{ + "command": "explain", + "inspection": { + "cgiMode": false, + "variables": { + "http_proxy": null, + "HTTP_PROXY": null, + "https_proxy": "http://secure-proxy.local:8443", + "HTTPS_PROXY": null, + "all_proxy": null, + "ALL_PROXY": null, + "no_proxy": "internal.local", + "NO_PROXY": null, + "REQUEST_METHOD": null, + "request_method": null + }, + "resolution": [ + { + "logicalName": "httpProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "http_proxy", + "lowerValue": null, + "upperKey": "HTTP_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "httpsProxy", + "selectedKey": "https_proxy", + "selectedValue": "http://secure-proxy.local:8443", + "lowerKey": "https_proxy", + "lowerValue": "http://secure-proxy.local:8443", + "upperKey": "HTTPS_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "allProxy", + "selectedKey": null, + "selectedValue": null, + "lowerKey": "all_proxy", + "lowerValue": null, + "upperKey": "ALL_PROXY", + "upperValue": null, + "ignoredUppercase": false + }, + { + "logicalName": "noProxy", + "selectedKey": "no_proxy", + "selectedValue": "internal.local", + "lowerKey": "no_proxy", + "lowerValue": "internal.local", + "upperKey": "NO_PROXY", + "upperValue": null, + "ignoredUppercase": false + } + ], + "settings": { + "httpProxy": null, + "httpsProxy": "http://secure-proxy.local:8443/", + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + } + ] + } + }, + "routes": [ + { + "input": "https://public.example.com", + "decision": "proxy", + "targetUrl": "https://public.example.com/", + "targetProtocol": "https:", + "proxyUrl": "http://secure-proxy.local:8443/", + "proxySource": "httpsProxy", + "matchedNoProxyRule": null, + "reason": "Route uses httpsProxy with proxy http://secure-proxy.local:8443/.", + "agent": null, + "error": null + }, + { + "input": "https://api.internal.local", + "decision": "direct", + "targetUrl": "https://api.internal.local/", + "targetProtocol": "https:", + "proxyUrl": null, + "proxySource": null, + "matchedNoProxyRule": { + "host": "internal.local", + "port": null, + "matchSubdomains": true + }, + "reason": "Direct route due to NO_PROXY rule *.internal.local.", + "agent": null, + "error": null + } + ], + "agentSupport": "disabled" +} diff --git a/packages/proxy-agent/NOTES.md b/packages/proxy-agent/NOTES.md index 9cb52d2..6e9553c 100644 --- a/packages/proxy-agent/NOTES.md +++ b/packages/proxy-agent/NOTES.md @@ -40,3 +40,18 @@ export const resolver = { - `fromCache` can be used for lightweight observability. - `viaProxy=false` indicates direct routing (no matching proxy settings). - Invalid env proxy values are sanitized and ignored. + +## Migration and Compatibility + +- Replace per-client proxy setup code with a single process-level `ProxyAgentFactory`. +- Migrate call sites to `factory.resolve(target).agent ?? undefined` and keep routing logic in one boundary. +- Disable any conflicting native proxy toggles in downstream clients. +- Use package root exports only; avoid `dist/*` deep imports. +- While pre-`1.0`, pin minor versions (`~0.1.x`) for safer upgrades. + +## Troubleshooting + +- Agent not applied: confirm target URL protocol and that caller actually forwards resolved `agent`. +- Unexpected direct traffic: inspect `NO_PROXY` matches and ensure env vars are loaded in the running process. +- Behavior stale after env rotation: run `reloadFromEnvironment()` or recreate the factory. +- Resource leaks on shutdown: call `destroy()`/`dispose()` during graceful termination. diff --git a/packages/proxy-agent/README.md b/packages/proxy-agent/README.md index 8f1edcd..8f915e3 100644 --- a/packages/proxy-agent/README.md +++ b/packages/proxy-agent/README.md @@ -65,6 +65,15 @@ export function resolveAgent(target: string | URL) { - Got: [docs/examples/got.md](./docs/examples/got.md) - Fetch (Node.js): [docs/examples/fetch.md](./docs/examples/fetch.md) +## Usage Matrix + +| Integration context | Recommended package | Reason | +| --- | --- | --- | +| Axios, Got, or custom `http`/`https` clients that accept Node agents | `@commandrelay/proxy-agent` | Returns protocol-correct `http.Agent`/`https.Agent` with cache + env routing | +| Node `fetch`/Undici code expecting a `dispatcher` | Prefer `@termina/proxy-undici` or `@termina/proxy-fetch` | Those integrations are dispatcher-native | +| Need SOCKS or PAC proxy URL support in Node clients | `@commandrelay/proxy-agent` | Supports `socks*` and `pac+*` schemes | +| Need policy-only decision logic without creating agents | Prefer `@commandrelay/proxy-core` | Keeps routing logic decoupled from transport runtime | + ## API ```ts @@ -111,6 +120,21 @@ Also exported: - Do not log proxy URLs containing credentials - PAC URLs are executable policy; only use trusted PAC sources +## Migration and Compatibility + +- Runtime baseline: Node.js `>=18`, npm `>=9`, ESM package usage. +- If migrating from client-specific proxy flags, centralize routing with one `ProxyAgentFactory`. +- Disable overlapping built-in proxy layers in clients (for example Axios `proxy: false`) to avoid double-proxy behavior. +- Use root imports only (`@commandrelay/proxy-agent`); deep imports are not compatibility-safe. +- While pre-`1.0`, pin minor versions (`~0.1.x`) before production rollouts. + +## Troubleshooting + +- Traffic unexpectedly direct (`viaProxy=false`): verify `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` and `NO_PROXY` rules for the target. +- Axios requests fail or ignore agent: ensure request config sets `proxy: false` when passing agents. +- Settings changed but behavior did not: call `reloadFromEnvironment()` and/or `clear()` to refresh cache decisions. +- PAC/SOCKS proxy not working: confirm proxy URL scheme is valid and supported (`pac+*`, `socks*`, `http`, `https`). + ## Performance - Default cache size is `256` diff --git a/packages/proxy-agent/docs/examples/README.md b/packages/proxy-agent/docs/examples/README.md index f9c6c16..623f775 100644 --- a/packages/proxy-agent/docs/examples/README.md +++ b/packages/proxy-agent/docs/examples/README.md @@ -2,12 +2,19 @@ Copy-paste-ready client integrations for `@commandrelay/proxy-agent`. +Each example includes a runnable snippet and an expected output snapshot. +Snapshots are stored under [`./snapshots`](./snapshots/). + ## Choose an adapter - [Axios](./axios.md) + - Snapshot: [`./snapshots/axios.expected.json`](./snapshots/axios.expected.json) - [Undici](./undici.md) + - Snapshot: [`./snapshots/undici.expected.json`](./snapshots/undici.expected.json) - [Got](./got.md) + - Snapshot: [`./snapshots/got.expected.json`](./snapshots/got.expected.json) - [Fetch (Node.js)](./fetch.md) + - Snapshot: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) ## Notes diff --git a/packages/proxy-agent/docs/examples/axios.md b/packages/proxy-agent/docs/examples/axios.md index 83c0ab0..2e9aea2 100644 --- a/packages/proxy-agent/docs/examples/axios.md +++ b/packages/proxy-agent/docs/examples/axios.md @@ -6,31 +6,92 @@ npm install axios @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import axios from "axios"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; -const factory = new ProxyAgentFactory(); -const target = "https://httpbin.org/get"; +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "axios-example" })); +}); -try { - const { agent } = factory.resolve(target); +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} + +const target = `http://127.0.0.1:${address.port}/health`; +const factory = new ProxyAgentFactory({ + env: { + http_proxy: "http://proxy.local:8080", + no_proxy: "127.0.0.1,localhost" + } +}); - const response = await axios.get(target, { +try { + const localRouting = factory.resolve(target); + const response = await axios.get<{ service: string }>(target, { proxy: false, - httpAgent: agent ?? undefined, - httpsAgent: agent ?? undefined, - timeout: 10_000 + httpAgent: localRouting.agent ?? undefined, + httpsAgent: localRouting.agent ?? undefined, + timeout: 5_000 }); - console.log(response.status, response.data.url); + const cacheProbeFirst = factory.resolve("http://public.example.com"); + const cacheProbeSecond = factory.resolve("http://public.example.com"); + + console.log( + JSON.stringify( + { + status: response.status, + service: response.data.service, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); } finally { factory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); } +TS ``` -## Why `proxy: false`? +## Expected output snapshot + +Snapshot file: [`./snapshots/axios.expected.json`](./snapshots/axios.expected.json) -Axios has built-in proxy handling. Disable it so `@commandrelay/proxy-agent` is the single routing source. +```json +{ + "status": 200, + "service": "axios-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} +``` diff --git a/packages/proxy-agent/docs/examples/fetch.md b/packages/proxy-agent/docs/examples/fetch.md index e53d1e0..cb120d0 100644 --- a/packages/proxy-agent/docs/examples/fetch.md +++ b/packages/proxy-agent/docs/examples/fetch.md @@ -6,9 +6,11 @@ npm install undici @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent, @@ -16,14 +18,34 @@ import { } from "undici"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; -const routingFactory = new ProxyAgentFactory(); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "fetch-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} + +const target = `http://127.0.0.1:${address.port}/health`; +const routingFactory = new ProxyAgentFactory({ + env: { + https_proxy: "http://secure-proxy.local:8443", + no_proxy: "127.0.0.1,localhost" + } +}); const directDispatcher = new UndiciAgent(); const proxyDispatchers = new Map(); type NodeFetchRequestInit = RequestInit & { dispatcher?: Dispatcher }; -function resolveDispatcher(target: string | URL): Dispatcher { - const { proxyUrl } = routingFactory.resolve(target); +function resolveDispatcher(targetUrl: string | URL): Dispatcher { + const { proxyUrl } = routingFactory.resolve(targetUrl); if (!proxyUrl) { return directDispatcher; } @@ -37,9 +59,8 @@ function resolveDispatcher(target: string | URL): Dispatcher { return dispatcher; } -const target = "https://httpbin.org/get"; - try { + const localRouting = routingFactory.resolve(target); const response = await fetch( target, { @@ -48,11 +69,59 @@ try { } as NodeFetchRequestInit ); - console.log(response.status, await response.text()); + const payload = (await response.json()) as { service: string }; + const cacheProbeFirst = routingFactory.resolve("https://public.example.com"); + const cacheProbeSecond = routingFactory.resolve("https://public.example.com"); + + console.log( + JSON.stringify( + { + status: response.status, + ok: response.ok, + service: payload.service, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); } finally { await Promise.all(Array.from(proxyDispatchers.values(), (dispatcher) => dispatcher.close())); await directDispatcher.close(); routingFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) + +```json +{ + "status": 200, + "ok": true, + "service": "fetch-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } } ``` diff --git a/packages/proxy-agent/docs/examples/got.md b/packages/proxy-agent/docs/examples/got.md index 42c2851..c05042e 100644 --- a/packages/proxy-agent/docs/examples/got.md +++ b/packages/proxy-agent/docs/examples/got.md @@ -6,28 +6,95 @@ npm install got @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import got from "got"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; -const factory = new ProxyAgentFactory(); -const target = "https://httpbin.org/get"; +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "got-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} + +const target = `http://127.0.0.1:${address.port}/health`; +const factory = new ProxyAgentFactory({ + env: { + http_proxy: "http://proxy.local:8080", + no_proxy: "127.0.0.1,localhost" + } +}); try { - const { agent } = factory.resolve(target); + const localRouting = factory.resolve(target); - const body = await got(target, { - timeout: { request: 10_000 }, + const response = await got(target, { + timeout: { request: 5_000 }, + responseType: "json", agent: { - http: agent ?? undefined, - https: agent ?? undefined + http: localRouting.agent ?? undefined, + https: localRouting.agent ?? undefined } - }).text(); + }); + + const cacheProbeFirst = factory.resolve("http://public.example.com"); + const cacheProbeSecond = factory.resolve("http://public.example.com"); - console.log(body.length); + console.log( + JSON.stringify( + { + statusCode: response.statusCode, + service: (response.body as { service: string }).service, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); } finally { factory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/got.expected.json`](./snapshots/got.expected.json) + +```json +{ + "statusCode": 200, + "service": "got-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } } ``` diff --git a/packages/proxy-agent/docs/examples/snapshots/axios.expected.json b/packages/proxy-agent/docs/examples/snapshots/axios.expected.json new file mode 100644 index 0000000..83e4dae --- /dev/null +++ b/packages/proxy-agent/docs/examples/snapshots/axios.expected.json @@ -0,0 +1,13 @@ +{ + "status": 200, + "service": "axios-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-agent/docs/examples/snapshots/fetch.expected.json b/packages/proxy-agent/docs/examples/snapshots/fetch.expected.json new file mode 100644 index 0000000..c5ea79b --- /dev/null +++ b/packages/proxy-agent/docs/examples/snapshots/fetch.expected.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "ok": true, + "service": "fetch-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-agent/docs/examples/snapshots/got.expected.json b/packages/proxy-agent/docs/examples/snapshots/got.expected.json new file mode 100644 index 0000000..6e2239d --- /dev/null +++ b/packages/proxy-agent/docs/examples/snapshots/got.expected.json @@ -0,0 +1,13 @@ +{ + "statusCode": 200, + "service": "got-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-agent/docs/examples/snapshots/undici.expected.json b/packages/proxy-agent/docs/examples/snapshots/undici.expected.json new file mode 100644 index 0000000..3d9b558 --- /dev/null +++ b/packages/proxy-agent/docs/examples/snapshots/undici.expected.json @@ -0,0 +1,13 @@ +{ + "statusCode": 200, + "service": "undici-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-agent/docs/examples/undici.md b/packages/proxy-agent/docs/examples/undici.md index 57c34fd..4443cab 100644 --- a/packages/proxy-agent/docs/examples/undici.md +++ b/packages/proxy-agent/docs/examples/undici.md @@ -6,9 +6,11 @@ npm install undici @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent, @@ -17,12 +19,32 @@ import { } from "undici"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; -const routingFactory = new ProxyAgentFactory(); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "undici-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} + +const target = `http://127.0.0.1:${address.port}/health`; +const routingFactory = new ProxyAgentFactory({ + env: { + https_proxy: "http://secure-proxy.local:8443", + no_proxy: "127.0.0.1,localhost" + } +}); const directDispatcher = new UndiciAgent(); const proxyDispatchers = new Map(); -function resolveDispatcher(target: string | URL): Dispatcher { - const { proxyUrl } = routingFactory.resolve(target); +function resolveDispatcher(targetUrl: string | URL): Dispatcher { + const { proxyUrl } = routingFactory.resolve(targetUrl); if (!proxyUrl) { return directDispatcher; } @@ -36,21 +58,62 @@ function resolveDispatcher(target: string | URL): Dispatcher { return dispatcher; } -const target = "https://httpbin.org/get"; - try { + const localRouting = routingFactory.resolve(target); const { statusCode, body } = await request(target, { dispatcher: resolveDispatcher(target) }); - console.log(statusCode, await body.text()); + const payload = (await body.json()) as { service: string }; + const cacheProbeFirst = routingFactory.resolve("https://public.example.com"); + const cacheProbeSecond = routingFactory.resolve("https://public.example.com"); + + console.log( + JSON.stringify( + { + statusCode, + service: payload.service, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); } finally { await Promise.all(Array.from(proxyDispatchers.values(), (dispatcher) => dispatcher.close())); await directDispatcher.close(); routingFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); } +TS ``` -## Why not pass `factory.resolve(...).agent` directly? +## Expected output snapshot + +Snapshot file: [`./snapshots/undici.expected.json`](./snapshots/undici.expected.json) -`undici` expects a `Dispatcher`, not a Node `http.Agent`. Use `proxyUrl` from `proxy-agent` as the routing decision and create Undici dispatchers. +```json +{ + "statusCode": 200, + "service": "undici-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} +``` diff --git a/packages/proxy-core/NOTES.md b/packages/proxy-core/NOTES.md index a8a3656..7082f50 100644 --- a/packages/proxy-core/NOTES.md +++ b/packages/proxy-core/NOTES.md @@ -63,3 +63,18 @@ Avoid role overlap (for example two different packages both acting as the primar - Avoid logging proxy URLs that may include credentials. - Lowercase env vars override uppercase variants. - In CGI-like environments, `HTTP_PROXY` is intentionally ignored. + +## Migration and Compatibility + +- Migrate from ad-hoc env parsing to `loadProxySettings(process.env)` once per process. +- Route all outbound target checks through `resolveProxyForUrl(target, settings)` instead of per-adapter matching. +- Keep transport-specific code in companion packages; do not add agent/client coupling in `proxy-core`. +- Import only from `@commandrelay/proxy-core` (no `dist/*` deep imports). +- While pre-`1.0`, pin minor versions (`~0.1.x`) for controlled upgrades. + +## Troubleshooting + +- Inconsistent decisions across services: ensure all services share one parsed settings snapshot at startup. +- Unexpected bypass behavior: inspect `NO_PROXY` token formatting and port-specific entries. +- `HTTP_PROXY` ignored in CI/CGI-like jobs: check whether `REQUEST_METHOD` is present in env. +- `null` proxy for known endpoints: validate proxy URL format and scheme in source env vars. diff --git a/packages/proxy-core/README.md b/packages/proxy-core/README.md index 9623645..5c7cb26 100644 --- a/packages/proxy-core/README.md +++ b/packages/proxy-core/README.md @@ -81,6 +81,21 @@ const oneShotProxy = resolveProxyForUrlFromEnv("https://edge.example.com"); console.log({ controlPlaneProxy, telemetryProxy, oneShotProxy }); ``` +## Examples + +- [Examples index](./docs/examples/README.md) +- [Load and inspect normalized proxy settings + snapshot](./docs/examples/settings.md) +- [Resolve per-target proxy routing + snapshot](./docs/examples/resolve.md) + +## Usage Matrix + +| Integration need | Use `@commandrelay/proxy-core` | Pair with | +| --- | --- | --- | +| Shared proxy policy across multiple HTTP clients | Yes, as the single source of env parsing and `NO_PROXY` matching | `@commandrelay/proxy-agent`, `@termina/proxy-undici`, `@termina/proxy-fetch`, or app wrapper | +| Build a thin adapter for a specific transport | Yes, call `loadProxySettings` once and `resolveProxyForUrl` per target | Transport-specific package in your stack | +| Need ready-to-use runtime agents/dispatchers | Not by itself | `@commandrelay/proxy-agent` or `@termina/proxy-undici` | +| Need operator diagnostics from terminal/CI | Core can power this, but prefer dedicated UX | `@termina/cli-proxy` | + ## API Surface - `loadProxySettings(env?: ProxyEnvironment): ProxySettings` @@ -108,6 +123,21 @@ Export policy: - Current line: `0.1.x` - Until `1.0`, pin minor versions in production (for example `~0.1.0`) +## Migration and Compatibility + +- Runtime baseline: Node.js `>=18`, npm `>=9`, ESM package usage. +- If migrating from custom proxy env parsing, replace local logic with one startup call to `loadProxySettings(process.env)`. +- Replace per-client `NO_PROXY` matching code with `resolveProxyForUrl(target, settings)` to keep behavior consistent. +- Use root imports only (`@commandrelay/proxy-core`); deep imports are not compatibility-safe. +- While pre-`1.0`, pin minor versions (`~0.1.x`) before broad rollout. + +## Troubleshooting + +- Proxy unexpectedly not used: verify target has `http:` or `https:` scheme and matching `*_PROXY` values are set. +- Target unexpectedly bypassed: review `NO_PROXY` entries (host/domain/port rules may match more than expected). +- `HTTP_PROXY` appears ignored: expected when `REQUEST_METHOD` is set (CGI hardening behavior). +- Resolved proxy is `null`: invalid proxy URL inputs are sanitized and treated as unset. + ## Integration Notes See [NOTES.md](./NOTES.md) for external integration guidelines and adapter conventions. diff --git a/packages/proxy-core/docs/examples/README.md b/packages/proxy-core/docs/examples/README.md new file mode 100644 index 0000000..049ec19 --- /dev/null +++ b/packages/proxy-core/docs/examples/README.md @@ -0,0 +1,9 @@ +# proxy-core Examples + +Each example includes a runnable snippet and an expected output snapshot. +Snapshots are stored under [`./snapshots`](./snapshots/). + +- [Load and inspect normalized proxy settings](./settings.md) + - Snapshot: [`./snapshots/settings.expected.json`](./snapshots/settings.expected.json) +- [Resolve per-target proxy routing](./resolve.md) + - Snapshot: [`./snapshots/resolve.expected.json`](./snapshots/resolve.expected.json) diff --git a/packages/proxy-core/docs/examples/resolve.md b/packages/proxy-core/docs/examples/resolve.md new file mode 100644 index 0000000..c3b9453 --- /dev/null +++ b/packages/proxy-core/docs/examples/resolve.md @@ -0,0 +1,73 @@ +# Resolve Per-Target Proxy Routing + +Use this example when you need deterministic routing decisions for multiple target protocols. + +## Run + +```bash +node --import tsx <<'TS' +import { + loadProxySettings, + resolveProxyForUrl, + resolveProxyForUrlFromEnv +} from "@commandrelay/proxy-core"; + +const env = { + http_proxy: "http://edge-proxy.local:8080", + https_proxy: "http://secure-proxy.local:8443", + all_proxy: "socks5://fallback-proxy.local:1080", + no_proxy: "internal.local,localhost" +}; + +const settings = loadProxySettings(env); + +const targets = [ + "http://public.example.com/v1", + "https://public.example.com/v1", + "https://api.internal.local/v1", + "ftp://legacy.example.com/archive" +]; + +console.log( + JSON.stringify( + { + resolved: targets.map((target) => ({ + target, + proxyUrl: resolveProxyForUrl(target, settings) + })), + oneShot: resolveProxyForUrlFromEnv("https://one-shot.example.com", env) + }, + null, + 2 + ) +); +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/resolve.expected.json`](./snapshots/resolve.expected.json) + +```json +{ + "resolved": [ + { + "target": "http://public.example.com/v1", + "proxyUrl": "http://edge-proxy.local:8080/" + }, + { + "target": "https://public.example.com/v1", + "proxyUrl": "http://secure-proxy.local:8443/" + }, + { + "target": "https://api.internal.local/v1", + "proxyUrl": null + }, + { + "target": "ftp://legacy.example.com/archive", + "proxyUrl": "socks5://fallback-proxy.local:1080" + } + ], + "oneShot": "http://secure-proxy.local:8443/" +} +``` diff --git a/packages/proxy-core/docs/examples/settings.md b/packages/proxy-core/docs/examples/settings.md new file mode 100644 index 0000000..567d6f5 --- /dev/null +++ b/packages/proxy-core/docs/examples/settings.md @@ -0,0 +1,59 @@ +# Load and Inspect Proxy Settings + +Use this example to verify environment parsing behavior and `NO_PROXY` normalization. + +## Run + +```bash +node --import tsx <<'TS' +import { loadProxySettings } from "@commandrelay/proxy-core"; + +const settings = loadProxySettings({ + http_proxy: "http://edge-proxy.local:8080", + HTTPS_PROXY: "http://secure-proxy.local:8443", + no_proxy: "internal.local,.svc.cluster.local,127.0.0.1:8080" +}); + +console.log( + JSON.stringify( + { + httpProxy: settings.httpProxy, + httpsProxy: settings.httpsProxy, + allProxy: settings.allProxy, + noProxy: settings.noProxy + }, + null, + 2 + ) +); +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/settings.expected.json`](./snapshots/settings.expected.json) + +```json +{ + "httpProxy": "http://edge-proxy.local:8080/", + "httpsProxy": "http://secure-proxy.local:8443/", + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + }, + { + "host": "svc.cluster.local", + "port": null, + "matchSubdomains": true + }, + { + "host": "127.0.0.1", + "port": 8080, + "matchSubdomains": false + } + ] +} +``` diff --git a/packages/proxy-core/docs/examples/snapshots/resolve.expected.json b/packages/proxy-core/docs/examples/snapshots/resolve.expected.json new file mode 100644 index 0000000..f825e2d --- /dev/null +++ b/packages/proxy-core/docs/examples/snapshots/resolve.expected.json @@ -0,0 +1,21 @@ +{ + "resolved": [ + { + "target": "http://public.example.com/v1", + "proxyUrl": "http://edge-proxy.local:8080/" + }, + { + "target": "https://public.example.com/v1", + "proxyUrl": "http://secure-proxy.local:8443/" + }, + { + "target": "https://api.internal.local/v1", + "proxyUrl": null + }, + { + "target": "ftp://legacy.example.com/archive", + "proxyUrl": "socks5://fallback-proxy.local:1080" + } + ], + "oneShot": "http://secure-proxy.local:8443/" +} diff --git a/packages/proxy-core/docs/examples/snapshots/settings.expected.json b/packages/proxy-core/docs/examples/snapshots/settings.expected.json new file mode 100644 index 0000000..be86680 --- /dev/null +++ b/packages/proxy-core/docs/examples/snapshots/settings.expected.json @@ -0,0 +1,22 @@ +{ + "httpProxy": "http://edge-proxy.local:8080/", + "httpsProxy": "http://secure-proxy.local:8443/", + "allProxy": null, + "noProxy": [ + { + "host": "internal.local", + "port": null, + "matchSubdomains": true + }, + { + "host": "svc.cluster.local", + "port": null, + "matchSubdomains": true + }, + { + "host": "127.0.0.1", + "port": 8080, + "matchSubdomains": false + } + ] +} diff --git a/packages/proxy-fetch/NOTES.md b/packages/proxy-fetch/NOTES.md index 74797c8..45404cf 100644 --- a/packages/proxy-fetch/NOTES.md +++ b/packages/proxy-fetch/NOTES.md @@ -5,6 +5,31 @@ This package adds proxy-aware wrappers around Node fetch with JSON-specific safety defaults. Dispatcher resolution is delegated to `@termina/proxy-undici`. +## Compatibility Checklist + +- Node.js `>=18` with global `fetch` support. +- ESM runtime/package consumption. +- Proxy environment behavior inherited from `@termina/proxy-undici` and `@commandrelay/proxy-core`. +- Dispatcher injection is Node-specific and not a browser feature. + +## Migration Checklist + +1. Replace direct `fetch` + ad-hoc proxy logic with `proxyFetch`/`proxyFetchJson`. +2. For repeated traffic, consolidate into a shared `ProxyFetchClient` instance. +3. Move timeout/body size constants into client defaults (`defaultTimeoutMs`, `defaultMaxResponseBytes`). +4. Translate package errors to domain errors at your API boundary. + +## Troubleshooting Playbook + +- Route is direct when proxy expected: + - Validate `NO_PROXY` matches and inspect normalized settings with `loadProxySettings`. +- Route is proxied when direct expected: + - Check for `ALL_PROXY` fallback values and missing `NO_PROXY` rules. +- Timeout errors on healthy endpoints: + - Revisit timeout budgets and upstream latency expectations. +- JSON parsing/content-type failures: + - Use `fetch()` for non-JSON endpoints, reserve `fetchJson()` for JSON-only contracts. + ## Operational Checklist 1. `npm --prefix packages/proxy-fetch run check` @@ -22,4 +47,5 @@ Dispatcher resolution is delegated to `@termina/proxy-undici`. ## Related - [Package README](./README.md) +- [Examples](./docs/examples/README.md) - [Brand SVG](./docs/assets/proxy-fetch-brand.svg) diff --git a/packages/proxy-fetch/README.md b/packages/proxy-fetch/README.md index 821262c..44b542d 100644 --- a/packages/proxy-fetch/README.md +++ b/packages/proxy-fetch/README.md @@ -19,6 +19,22 @@ npm install @termina/proxy-fetch - npm `>=9` - ESM package (`"type": "module"`) +## Compatibility + +- Node-only package: this wrapper depends on Undici dispatchers and Node `fetch` behavior. +- `fetch` dispatcher injection is supported in Node and is not a browser API. +- Works with explicit `settings` objects or with environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`). +- Compatible with `@termina/proxy-undici@^0.1.0`. + +## Migration + +`@termina/proxy-fetch` is currently `0.1.x`; there is no prior package-specific breaking release. Most migrations are from direct `fetch` usage or custom proxy wrappers. + +1. Replace direct `fetch` calls with `proxyFetch`/`proxyFetchJson` for one-shot calls. +2. For services with repeated outbound calls, switch to one long-lived `ProxyFetchClient`. +3. Move timeout/body-size checks into package options (`timeoutMs`, `maxResponseBytes`). +4. Update error handling to map typed package errors (`invalid_url`, `request_timeout`, `response_size_limit_exceeded`, `non_json_response:*`). + ## Features 1. Proxy routing with `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY`. @@ -45,6 +61,15 @@ console.log(response.body?.ok); client.destroy(); ``` +## Usage Matrix + +| Use case | Recommended entry point | Why | +| --- | --- | --- | +| One-off proxied `fetch` call with routing metadata | `proxyFetch` | Minimal setup for scripts and low-frequency calls | +| One-off JSON call with timeout/size guards | `proxyFetchJson` | Adds typed JSON parse + guardrail errors | +| Service making repeated outbound calls | `new ProxyFetchClient()` | Reuses dispatcher cache and centralizes defaults | +| Need only Undici dispatcher wiring (no fetch wrapper) | Prefer `@termina/proxy-undici` | Lower-level control for custom Undici clients | + ## API ### Reusable client @@ -84,11 +109,25 @@ Pass temporary client options with `options.client`. - `ResponseSizeLimitError` -> `response_size_limit_exceeded:` - `NonJsonResponseError` -> `non_json_response:invalid_content_type` or `non_json_response:invalid_json` +## Troubleshooting + +- Requests unexpectedly bypass proxy: + - Confirm `NO_PROXY` rules and lowercase/uppercase env precedence in your runtime. + - Inspect `result.routing.viaProxy` and `result.routing.proxyUrl` in logs. +- `request_timeout:` errors: + - Increase `timeoutMs` for slow endpoints or set a higher client default timeout. +- `response_size_limit_exceeded:` errors: + - Increase `maxResponseBytes` only for endpoints that are expected to return large JSON payloads. +- `non_json_response:*` errors: + - Verify endpoint `content-type` and whether non-JSON responses should be handled through `fetch()` instead of `fetchJson()`. +- Process shutdown hangs: + - Ensure `client.destroy()` is called when the process or worker exits. + ## Examples - [Examples index](./docs/examples/README.md) -- [One-shot usage](./docs/examples/one-shot.md) -- [Reusable client usage](./docs/examples/client.md) +- [One-shot usage + snapshot](./docs/examples/one-shot.md) +- [Reusable client usage + snapshot](./docs/examples/client.md) ## Notes diff --git a/packages/proxy-fetch/docs/examples/README.md b/packages/proxy-fetch/docs/examples/README.md index abff21d..123e744 100644 --- a/packages/proxy-fetch/docs/examples/README.md +++ b/packages/proxy-fetch/docs/examples/README.md @@ -1,4 +1,9 @@ # @termina/proxy-fetch Examples +Each example includes a runnable snippet and an expected output snapshot. +Snapshots are stored under [`./snapshots`](./snapshots/). + - [One-shot helper usage](./one-shot.md) + - Snapshot: [`./snapshots/one-shot.expected.json`](./snapshots/one-shot.expected.json) - [Reusable client usage](./client.md) + - Snapshot: [`./snapshots/client.expected.json`](./snapshots/client.expected.json) diff --git a/packages/proxy-fetch/docs/examples/client.md b/packages/proxy-fetch/docs/examples/client.md index 5cb017e..9bdf793 100644 --- a/packages/proxy-fetch/docs/examples/client.md +++ b/packages/proxy-fetch/docs/examples/client.md @@ -1,12 +1,25 @@ # Reusable Client Usage -```ts +Use `ProxyFetchClient` when the process performs multiple outbound calls and you want stable dispatcher reuse/lifecycle handling. + +## Run + +```bash +node --import tsx <<'TS' import { ProxyFetchClient, loadProxySettings } from "@termina/proxy-fetch"; const client = new ProxyFetchClient({ - settings: loadProxySettings(process.env), + settings: loadProxySettings({ + https_proxy: "http://proxy.local:8080", + no_proxy: "example.com" + }), defaultTimeoutMs: 4_000, - defaultMaxResponseBytes: 256_000 + defaultMaxResponseBytes: 256_000, + fetchImplementation: async () => + new Response('{"id":"u_123","email":"ada@example.com"}', { + status: 200, + headers: { "content-type": "application/json" } + }) }); try { @@ -14,8 +27,39 @@ try { "https://api.example.com/profile/me" ); - console.log(profile.status, profile.body?.email, profile.routing.proxyUrl); + console.log( + JSON.stringify( + { + status: profile.status, + email: profile.body?.email ?? null, + routing: { + viaProxy: profile.routing.viaProxy, + proxyUrl: profile.routing.proxyUrl, + fromCache: profile.routing.fromCache + } + }, + null, + 2 + ) + ); } finally { client.destroy(); } +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/client.expected.json`](./snapshots/client.expected.json) + +```json +{ + "status": 200, + "email": "ada@example.com", + "routing": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + } +} ``` diff --git a/packages/proxy-fetch/docs/examples/one-shot.md b/packages/proxy-fetch/docs/examples/one-shot.md index 1b5d3b7..798e20b 100644 --- a/packages/proxy-fetch/docs/examples/one-shot.md +++ b/packages/proxy-fetch/docs/examples/one-shot.md @@ -1,15 +1,60 @@ # One-Shot Helper Usage -```ts +Use the one-shot helper when you need a single proxied request without holding a long-lived client. + +## Run + +```bash +node --import tsx <<'TS' import { loadProxySettings, proxyFetchJson } from "@termina/proxy-fetch"; const result = await proxyFetchJson<{ version: string }>("https://api.example.com/version", { timeoutMs: 3_000, maxResponseBytes: 64_000, client: { - settings: loadProxySettings(process.env) + settings: loadProxySettings({ + https_proxy: "http://proxy.local:8080" + }), + fetchImplementation: async () => + new Response('{"version":"2026.02.0"}', { + status: 200, + headers: { "content-type": "application/json" } + }) } }); -console.log(result.body?.version, result.routing.viaProxy); +console.log( + JSON.stringify( + { + status: result.status, + body: result.body, + routing: { + viaProxy: result.routing.viaProxy, + proxyUrl: result.routing.proxyUrl, + fromCache: result.routing.fromCache + } + }, + null, + 2 + ) +); +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/one-shot.expected.json`](./snapshots/one-shot.expected.json) + +```json +{ + "status": 200, + "body": { + "version": "2026.02.0" + }, + "routing": { + "viaProxy": true, + "proxyUrl": "http://proxy.local:8080/", + "fromCache": false + } +} ``` diff --git a/packages/proxy-fetch/docs/examples/snapshots/client.expected.json b/packages/proxy-fetch/docs/examples/snapshots/client.expected.json new file mode 100644 index 0000000..8b1a19a --- /dev/null +++ b/packages/proxy-fetch/docs/examples/snapshots/client.expected.json @@ -0,0 +1,9 @@ +{ + "status": 200, + "email": "ada@example.com", + "routing": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + } +} diff --git a/packages/proxy-fetch/docs/examples/snapshots/one-shot.expected.json b/packages/proxy-fetch/docs/examples/snapshots/one-shot.expected.json new file mode 100644 index 0000000..70a6490 --- /dev/null +++ b/packages/proxy-fetch/docs/examples/snapshots/one-shot.expected.json @@ -0,0 +1,11 @@ +{ + "status": 200, + "body": { + "version": "2026.02.0" + }, + "routing": { + "viaProxy": true, + "proxyUrl": "http://proxy.local:8080/", + "fromCache": false + } +} diff --git a/packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts b/packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts new file mode 100644 index 0000000..9faba00 --- /dev/null +++ b/packages/proxy-fetch/test/proxy-fetch-client-env-matrix.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { InvalidUrlError, ProxyFetchClient, type ProxyEnvironment } from "../src/index.js"; + +interface MatrixExpectation { + readonly target: string; + readonly viaProxy: boolean; + readonly proxyUrl: string | null; +} + +interface MatrixScenario { + readonly name: string; + readonly env: ProxyEnvironment; + readonly expectations: readonly MatrixExpectation[]; +} + +interface FakeDispatcher { + readonly kind: "direct" | "proxy"; + readonly key: string; +} + +interface AdapterHarness { + readonly adapter: { + createDirect: () => never; + createProxy: (proxyUrl: string) => never; + }; + readonly directDispatchers: FakeDispatcher[]; + readonly proxyDispatchers: Array<{ proxyUrl: string; dispatcher: FakeDispatcher }>; +} + +function createAdapterHarness(): AdapterHarness { + const directDispatchers: FakeDispatcher[] = []; + const proxyDispatchers: Array<{ proxyUrl: string; dispatcher: FakeDispatcher }> = []; + + return { + adapter: { + createDirect: () => { + const dispatcher: FakeDispatcher = { + kind: "direct", + key: `direct:${directDispatchers.length + 1}` + }; + directDispatchers.push(dispatcher); + return dispatcher as never; + }, + createProxy: (proxyUrl: string) => { + const dispatcher: FakeDispatcher = { + kind: "proxy", + key: `proxy:${proxyUrl}:${proxyDispatchers.length + 1}` + }; + proxyDispatchers.push({ proxyUrl, dispatcher }); + return dispatcher as never; + } + }, + directDispatchers, + proxyDispatchers + }; +} + +const MATRIX_SCENARIOS: readonly MatrixScenario[] = [ + { + name: "no proxy env uses direct dispatcher", + env: {}, + expectations: [ + { target: "http://service.local/health", viaProxy: false, proxyUrl: null }, + { target: "https://service.local/health", viaProxy: false, proxyUrl: null } + ] + }, + { + name: "lowercase protocol-specific proxies route by protocol", + env: { + http_proxy: "http://http-lower.local:8080", + https_proxy: "http://https-lower.local:8443" + }, + expectations: [ + { + target: "http://public.local/health", + viaProxy: true, + proxyUrl: "http://http-lower.local:8080/" + }, + { + target: "https://public.local/health", + viaProxy: true, + proxyUrl: "http://https-lower.local:8443/" + } + ] + }, + { + name: "all_proxy fallback applies when specific proxies are absent", + env: { + all_proxy: "http://fallback.local:9000" + }, + expectations: [ + { + target: "http://fallback-http.local/health", + viaProxy: true, + proxyUrl: "http://fallback.local:9000/" + }, + { + target: "https://fallback-https.local/health", + viaProxy: true, + proxyUrl: "http://fallback.local:9000/" + } + ] + }, + { + name: "https falls back to http proxy when https proxy is missing", + env: { + http_proxy: "http://http-only.local:3128" + }, + expectations: [ + { + target: "http://http-only-target.local/health", + viaProxy: true, + proxyUrl: "http://http-only.local:3128/" + }, + { + target: "https://https-fallback.local/health", + viaProxy: true, + proxyUrl: "http://http-only.local:3128/" + } + ] + }, + { + name: "lowercase http and https proxies override uppercase variants", + env: { + http_proxy: "http://http-lower.local:8080", + HTTP_PROXY: "http://http-upper.local:18080", + https_proxy: "http://https-lower.local:8443", + HTTPS_PROXY: "http://https-upper.local:18443" + }, + expectations: [ + { + target: "http://precedence-http.local/health", + viaProxy: true, + proxyUrl: "http://http-lower.local:8080/" + }, + { + target: "https://precedence-https.local/health", + viaProxy: true, + proxyUrl: "http://https-lower.local:8443/" + } + ] + }, + { + name: "uppercase proxies are used when lowercase variables are absent", + env: { + HTTP_PROXY: "http://http-upper.local:18080", + HTTPS_PROXY: "http://https-upper.local:18443" + }, + expectations: [ + { + target: "http://upper-http.local/health", + viaProxy: true, + proxyUrl: "http://http-upper.local:18080/" + }, + { + target: "https://upper-https.local/health", + viaProxy: true, + proxyUrl: "http://https-upper.local:18443/" + } + ] + }, + { + name: "cgi request method ignores uppercase HTTP_PROXY for http targets", + env: { + REQUEST_METHOD: "GET", + HTTP_PROXY: "http://ignored-http.local:18080", + HTTPS_PROXY: "http://https-cgi.local:18443", + ALL_PROXY: "http://all-cgi.local:19000" + }, + expectations: [ + { + target: "http://cgi-http.local/health", + viaProxy: true, + proxyUrl: "http://all-cgi.local:19000/" + }, + { + target: "https://cgi-https.local/health", + viaProxy: true, + proxyUrl: "http://https-cgi.local:18443/" + } + ] + }, + { + name: "lowercase no_proxy bypasses matching host suffix", + env: { + https_proxy: "http://https-proxy.local:8443", + no_proxy: "internal.local" + }, + expectations: [ + { + target: "https://api.internal.local/health", + viaProxy: false, + proxyUrl: null + }, + { + target: "https://api.external.local/health", + viaProxy: true, + proxyUrl: "http://https-proxy.local:8443/" + } + ] + }, + { + name: "uppercase NO_PROXY takes precedence over lowercase no_proxy", + env: { + https_proxy: "http://https-proxy.local:8443", + NO_PROXY: "blocked.local", + no_proxy: "ignored.local" + }, + expectations: [ + { + target: "https://api.blocked.local/health", + viaProxy: false, + proxyUrl: null + }, + { + target: "https://api.ignored.local/health", + viaProxy: true, + proxyUrl: "http://https-proxy.local:8443/" + } + ] + }, + { + name: "lowercase all_proxy overrides uppercase ALL_PROXY", + env: { + all_proxy: "http://all-lower.local:9000", + ALL_PROXY: "http://all-upper.local:19000" + }, + expectations: [ + { + target: "http://all-precedence-http.local/health", + viaProxy: true, + proxyUrl: "http://all-lower.local:9000/" + }, + { + target: "https://all-precedence-https.local/health", + viaProxy: true, + proxyUrl: "http://all-lower.local:9000/" + } + ] + } +]; + +test("ProxyFetchClient env matrix resolves routing metadata and dispatcher injection deterministically", async () => { + for (const scenario of MATRIX_SCENARIOS) { + const harness = createAdapterHarness(); + const capturedDispatchers: unknown[] = []; + const seenRouteKeys = new Set(); + + const client = new ProxyFetchClient({ + env: scenario.env, + adapter: harness.adapter, + fetchImplementation: async (_input, init) => { + capturedDispatchers.push((init as { dispatcher?: unknown } | undefined)?.dispatcher); + return new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" } + }); + } + }); + + try { + let index = 0; + for (const expectation of scenario.expectations) { + const result = await client.fetchJson<{ ok: boolean }>(expectation.target); + const dispatcher = capturedDispatchers[index] as FakeDispatcher | undefined; + const routeKey = expectation.proxyUrl ?? "direct"; + const expectedFromCache = seenRouteKeys.has(routeKey); + + assert.equal(result.routing.viaProxy, expectation.viaProxy, `${scenario.name} viaProxy`); + assert.equal(result.routing.proxyUrl, expectation.proxyUrl, `${scenario.name} proxyUrl`); + assert.equal( + result.routing.fromCache, + expectedFromCache, + `${scenario.name} fromCache sequence` + ); + assert.deepEqual(result.body, { ok: true }, `${scenario.name} response body`); + assert.ok(dispatcher, `${scenario.name} dispatcher is defined`); + assert.equal(dispatcher?.kind, expectation.viaProxy ? "proxy" : "direct"); + + seenRouteKeys.add(routeKey); + index += 1; + } + + assert.equal(capturedDispatchers.length, scenario.expectations.length, `${scenario.name} calls`); + + const expectedProxyUrls = new Set( + scenario.expectations + .map((entry) => entry.proxyUrl) + .filter((entry): entry is string => entry !== null) + ); + assert.equal( + harness.proxyDispatchers.length, + expectedProxyUrls.size, + `${scenario.name} proxy dispatcher creations` + ); + const expectedDirectCreated = scenario.expectations.some((entry) => !entry.viaProxy) ? 1 : 0; + assert.equal( + harness.directDispatchers.length, + expectedDirectCreated, + `${scenario.name} direct dispatcher creations` + ); + } finally { + client.destroy(); + } + } +}); + +test("ProxyFetchClient rejects ws and wss targets before dispatch resolution", async () => { + const harness = createAdapterHarness(); + let fetchCalls = 0; + const client = new ProxyFetchClient({ + env: { + http_proxy: "http://http-proxy.local:8080", + https_proxy: "http://https-proxy.local:8443", + all_proxy: "http://fallback.local:9000" + }, + adapter: harness.adapter, + fetchImplementation: async () => { + fetchCalls += 1; + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" } + }); + } + }); + + try { + await assert.rejects( + () => client.fetch("ws://socket.local/live"), + (error: unknown) => { + assert.equal(error instanceof InvalidUrlError, true); + if (!(error instanceof InvalidUrlError)) { + return false; + } + assert.equal(error.cause, "unsupported_protocol:ws:"); + return true; + } + ); + + await assert.rejects( + () => client.fetchJson("wss://secure-socket.local/live"), + (error: unknown) => { + assert.equal(error instanceof InvalidUrlError, true); + if (!(error instanceof InvalidUrlError)) { + return false; + } + assert.equal(error.cause, "unsupported_protocol:wss:"); + return true; + } + ); + + assert.equal(fetchCalls, 0); + assert.equal(harness.directDispatchers.length, 0); + assert.equal(harness.proxyDispatchers.length, 0); + } finally { + client.destroy(); + } +}); diff --git a/packages/proxy-http-client/NOTES.md b/packages/proxy-http-client/NOTES.md index c4a4544..4ddc0af 100644 --- a/packages/proxy-http-client/NOTES.md +++ b/packages/proxy-http-client/NOTES.md @@ -12,6 +12,21 @@ Use this package as your app's HTTP boundary adapter. - Inject `proxyResolver` only when your runtime requires proxy routing. - Avoid logging full response bodies for failures in production. +## Migration and Compatibility + +- Migrate from scattered HTTP calls to one boundary wrapper around `requestJson`. +- Make timeout, payload limit, and error translation explicit in that wrapper to avoid per-call drift. +- Add `proxyResolver` only at boundary level when corporate/network policy requires it. +- Use package root exports only; avoid deep imports for compatibility stability. +- While pre-`1.0`, pin minor versions (`~0.1.x`) during staged rollouts. + +## Troubleshooting + +- `HttpStatusError` handling too aggressive: set `throwOnHttpError: false` when callers need non-2xx payload inspection. +- Missing request cancellation: pass `AbortSignal` through wrapper APIs and upstream call chains. +- Large responses rejected: verify endpoint payload size and adjust `maxResponseBytes` intentionally. +- Intermittent proxy failures: validate resolver behavior and ensure proxy env/agent settings are refreshed. + ## Copy/Paste Starter ```ts diff --git a/packages/proxy-http-client/README.md b/packages/proxy-http-client/README.md index c062b74..ab15213 100644 --- a/packages/proxy-http-client/README.md +++ b/packages/proxy-http-client/README.md @@ -36,6 +36,14 @@ npm install @commandrelay/proxy-http-client - Convert library errors into app-specific domain errors at one place. - For rollout guidance, use [NOTES.md](./NOTES.md). +## Migration and Compatibility + +- Runtime baseline: Node.js `>=18`, npm `>=9`, ESM package usage. +- If migrating from direct `fetch`/`axios` calls, move request policy (`timeoutMs`, `maxResponseBytes`, error mapping) into one wrapper module. +- Integrate proxy routing via `proxyResolver` when needed instead of embedding proxy logic per call site. +- Use root imports only (`@commandrelay/proxy-http-client`); deep imports are not compatibility-safe. +- While pre-`1.0`, pin minor versions (`~0.1.x`) before broad deployments. + ## Quick Start ```ts @@ -122,6 +130,15 @@ export async function fetchProfile(userId: string) { - Got-style adapter: [docs/examples/got.md](./docs/examples/got.md) - Fetch-style adapter: [docs/examples/fetch.md](./docs/examples/fetch.md) +## Usage Matrix + +| Integration scenario | Recommended usage | Why | +| --- | --- | --- | +| Service-layer JSON client with standardized errors | `requestJson()` via one app wrapper module | Consistent timeout/size/error policy at the boundary | +| CLI/tooling call that needs strict JSON + proxy support | Direct `requestJson()` call | Minimal API with explicit controls (`timeoutMs`, `maxResponseBytes`) | +| Existing proxy stack using `@commandrelay/proxy-agent` | Pass `proxyResolver` | Reuses your established routing + agent lifecycle | +| Need generic raw HTTP streaming client behavior | Prefer a lower-level transport directly | This package is intentionally JSON-first | + ## API Summary ```ts @@ -170,6 +187,14 @@ Exported error classes: - Proxy resolver failures are wrapped into `ProxyResolutionError` with a `cause`. - Avoid logging raw payloads when handling `HttpStatusError` or `JsonParseError`. +## Troubleshooting + +- `UnsupportedProtocolError`: ensure request URLs use `http:` or `https:` only. +- Frequent `RequestTimeoutError`: tune `timeoutMs` per endpoint SLA and check upstream latency. +- `ResponseSizeLimitError`: increase `maxResponseBytes` for expected payloads or narrow response shape upstream. +- `JsonParseError`: upstream returned non-JSON content; inspect `rawBody` safely in controlled logs. +- `ProxyResolutionError`: validate `proxyResolver` wiring and underlying proxy-agent configuration. + ## Docs and Assets - [Integration note](./NOTES.md) diff --git a/packages/proxy-http-client/docs/examples/README.md b/packages/proxy-http-client/docs/examples/README.md index 172909e..de9f3d5 100644 --- a/packages/proxy-http-client/docs/examples/README.md +++ b/packages/proxy-http-client/docs/examples/README.md @@ -4,12 +4,19 @@ Copy-paste-ready adapters for `@commandrelay/proxy-http-client`. These examples are useful when you want `requestJson(...)` as the single HTTP boundary, while keeping familiar call shapes from other clients. +Each example includes a runnable snippet and an expected output snapshot. +Snapshots are stored under [`./snapshots`](./snapshots/). + ## Choose an adapter - [Axios-style adapter](./axios.md) + - Snapshot: [`./snapshots/axios.expected.json`](./snapshots/axios.expected.json) - [Undici-style adapter](./undici.md) + - Snapshot: [`./snapshots/undici.expected.json`](./snapshots/undici.expected.json) - [Got-style adapter](./got.md) + - Snapshot: [`./snapshots/got.expected.json`](./snapshots/got.expected.json) - [Fetch-style adapter](./fetch.md) + - Snapshot: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) ## Shared setup diff --git a/packages/proxy-http-client/docs/examples/axios.md b/packages/proxy-http-client/docs/examples/axios.md index 598e3f5..28340d7 100644 --- a/packages/proxy-http-client/docs/examples/axios.md +++ b/packages/proxy-http-client/docs/examples/axios.md @@ -6,9 +6,11 @@ npm install @commandrelay/proxy-http-client @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; import { requestJson } from "@commandrelay/proxy-http-client"; @@ -19,9 +21,14 @@ type AxiosLikeResponse = { headers: IncomingHttpHeaders; }; -const proxyFactory = new ProxyAgentFactory(); +const proxyFactory = new ProxyAgentFactory({ + env: { + http_proxy: "http://proxy.local:8080", + no_proxy: "127.0.0.1,localhost" + } +}); -export async function axiosLikeGet( +async function axiosLikeGet( url: string, options: { headers?: Record; @@ -44,8 +51,72 @@ export async function axiosLikeGet( }; } -const response = await axiosLikeGet<{ url: string }>("https://httpbin.org/get"); -console.log(response.status, response.data?.url); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "axios-like-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} -proxyFactory.destroy(); +const target = `http://127.0.0.1:${address.port}/health`; + +try { + const localRouting = proxyFactory.resolve(target); + const response = await axiosLikeGet<{ service: string }>(target); + const cacheProbeFirst = proxyFactory.resolve("http://public.example.com"); + const cacheProbeSecond = proxyFactory.resolve("http://public.example.com"); + + console.log( + JSON.stringify( + { + status: response.status, + service: response.data?.service ?? null, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); +} finally { + proxyFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/axios.expected.json`](./snapshots/axios.expected.json) + +```json +{ + "status": 200, + "service": "axios-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} ``` diff --git a/packages/proxy-http-client/docs/examples/fetch.md b/packages/proxy-http-client/docs/examples/fetch.md index 7122b4b..8632347 100644 --- a/packages/proxy-http-client/docs/examples/fetch.md +++ b/packages/proxy-http-client/docs/examples/fetch.md @@ -6,9 +6,11 @@ npm install @commandrelay/proxy-http-client @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; import { requestJson } from "@commandrelay/proxy-http-client"; @@ -21,9 +23,14 @@ type FetchLikeResponse = { text: () => Promise; }; -const proxyFactory = new ProxyAgentFactory(); +const proxyFactory = new ProxyAgentFactory({ + env: { + https_proxy: "http://secure-proxy.local:8443", + no_proxy: "127.0.0.1,localhost" + } +}); -export async function fetchLikeJson( +async function fetchLikeJson( url: string, init: { method?: string; @@ -51,8 +58,75 @@ export async function fetchLikeJson( }; } -const response = await fetchLikeJson<{ url: string }>("https://httpbin.org/get"); -console.log(response.status, response.ok, await response.json()); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "fetch-like-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} -proxyFactory.destroy(); +const target = `http://127.0.0.1:${address.port}/health`; + +try { + const localRouting = proxyFactory.resolve(target); + const response = await fetchLikeJson<{ service: string }>(target); + const body = await response.json(); + const cacheProbeFirst = proxyFactory.resolve("https://public.example.com"); + const cacheProbeSecond = proxyFactory.resolve("https://public.example.com"); + + console.log( + JSON.stringify( + { + status: response.status, + ok: response.ok, + service: body?.service ?? null, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); +} finally { + proxyFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) + +```json +{ + "status": 200, + "ok": true, + "service": "fetch-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} ``` diff --git a/packages/proxy-http-client/docs/examples/got.md b/packages/proxy-http-client/docs/examples/got.md index ad70ffc..69d6458 100644 --- a/packages/proxy-http-client/docs/examples/got.md +++ b/packages/proxy-http-client/docs/examples/got.md @@ -6,15 +6,22 @@ npm install @commandrelay/proxy-http-client @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; import { requestJson } from "@commandrelay/proxy-http-client"; -const proxyFactory = new ProxyAgentFactory(); +const proxyFactory = new ProxyAgentFactory({ + env: { + http_proxy: "http://proxy.local:8080", + no_proxy: "127.0.0.1,localhost" + } +}); -export async function gotLikeJson( +async function gotLikeJson( url: string, options: { method?: string; @@ -36,8 +43,70 @@ export async function gotLikeJson( return result.body; } -const payload = await gotLikeJson<{ url: string }>("https://httpbin.org/get"); -console.log(payload?.url); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "got-like-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} -proxyFactory.destroy(); +const target = `http://127.0.0.1:${address.port}/health`; + +try { + const localRouting = proxyFactory.resolve(target); + const payload = await gotLikeJson<{ service: string }>(target); + const cacheProbeFirst = proxyFactory.resolve("http://public.example.com"); + const cacheProbeSecond = proxyFactory.resolve("http://public.example.com"); + + console.log( + JSON.stringify( + { + service: payload?.service ?? null, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); +} finally { + proxyFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/got.expected.json`](./snapshots/got.expected.json) + +```json +{ + "service": "got-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} ``` diff --git a/packages/proxy-http-client/docs/examples/snapshots/axios.expected.json b/packages/proxy-http-client/docs/examples/snapshots/axios.expected.json new file mode 100644 index 0000000..91a4393 --- /dev/null +++ b/packages/proxy-http-client/docs/examples/snapshots/axios.expected.json @@ -0,0 +1,13 @@ +{ + "status": 200, + "service": "axios-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-http-client/docs/examples/snapshots/fetch.expected.json b/packages/proxy-http-client/docs/examples/snapshots/fetch.expected.json new file mode 100644 index 0000000..45b3395 --- /dev/null +++ b/packages/proxy-http-client/docs/examples/snapshots/fetch.expected.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "ok": true, + "service": "fetch-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-http-client/docs/examples/snapshots/got.expected.json b/packages/proxy-http-client/docs/examples/snapshots/got.expected.json new file mode 100644 index 0000000..92878aa --- /dev/null +++ b/packages/proxy-http-client/docs/examples/snapshots/got.expected.json @@ -0,0 +1,12 @@ +{ + "service": "got-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-http-client/docs/examples/snapshots/undici.expected.json b/packages/proxy-http-client/docs/examples/snapshots/undici.expected.json new file mode 100644 index 0000000..4016ed0 --- /dev/null +++ b/packages/proxy-http-client/docs/examples/snapshots/undici.expected.json @@ -0,0 +1,13 @@ +{ + "statusCode": 200, + "service": "undici-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} diff --git a/packages/proxy-http-client/docs/examples/undici.md b/packages/proxy-http-client/docs/examples/undici.md index 60efc1a..d264c4a 100644 --- a/packages/proxy-http-client/docs/examples/undici.md +++ b/packages/proxy-http-client/docs/examples/undici.md @@ -6,9 +6,11 @@ npm install @commandrelay/proxy-http-client @commandrelay/proxy-agent ``` -## Example +## Run -```ts +```bash +node --import tsx <<'TS' +import { createServer } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import { ProxyAgentFactory } from "@commandrelay/proxy-agent"; import { requestJson } from "@commandrelay/proxy-http-client"; @@ -20,9 +22,14 @@ type UndiciLikeResult = { rawBody: string; }; -const proxyFactory = new ProxyAgentFactory(); +const proxyFactory = new ProxyAgentFactory({ + env: { + https_proxy: "http://secure-proxy.local:8443", + no_proxy: "127.0.0.1,localhost" + } +}); -export async function undiciLikeRequest( +async function undiciLikeRequest( url: string, options: { method?: string; @@ -48,8 +55,72 @@ export async function undiciLikeRequest( }; } -const response = await undiciLikeRequest<{ url: string }>("https://httpbin.org/get"); -console.log(response.statusCode, response.body?.url); +const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ service: "undici-like-example" })); +}); + +await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("address_unavailable"); +} -proxyFactory.destroy(); +const target = `http://127.0.0.1:${address.port}/health`; + +try { + const localRouting = proxyFactory.resolve(target); + const response = await undiciLikeRequest<{ service: string }>(target); + const cacheProbeFirst = proxyFactory.resolve("https://public.example.com"); + const cacheProbeSecond = proxyFactory.resolve("https://public.example.com"); + + console.log( + JSON.stringify( + { + statusCode: response.statusCode, + service: response.body?.service ?? null, + localRouting: { + viaProxy: localRouting.viaProxy, + proxyUrl: localRouting.proxyUrl, + fromCache: localRouting.fromCache + }, + cacheProbe: { + firstFromCache: cacheProbeFirst.fromCache, + secondFromCache: cacheProbeSecond.fromCache + } + }, + null, + 2 + ) + ); +} finally { + proxyFactory.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/undici.expected.json`](./snapshots/undici.expected.json) + +```json +{ + "statusCode": 200, + "service": "undici-like-example", + "localRouting": { + "viaProxy": false, + "proxyUrl": null, + "fromCache": false + }, + "cacheProbe": { + "firstFromCache": false, + "secondFromCache": true + } +} ``` diff --git a/packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts b/packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts new file mode 100644 index 0000000..314c23e --- /dev/null +++ b/packages/proxy-http-client/test/request-json-proxy-agent-interoperability.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import * as nodeHttp from "node:http"; +import test from "node:test"; +import { + loadProxySettings, + resolveProxyForUrl, + type ProxyEnvironment +} from "@commandrelay/proxy-core"; +import { + requestJson, + type JsonRequestTransport, + type ProxyAgentResolver +} from "../src/index.js"; + +type TargetProtocol = "http:" | "https:"; + +interface ResolutionRecord { + target: string; + proxyUrl: string | null; + bypassed: boolean; +} + +class FakeClientRequest extends EventEmitter { + readonly options: nodeHttp.RequestOptions; + private readonly callback: (response: nodeHttp.IncomingMessage) => void; + + constructor( + options: nodeHttp.RequestOptions, + callback: (response: nodeHttp.IncomingMessage) => void + ) { + super(); + this.options = options; + this.callback = callback; + } + + end(): void { + const response = new EventEmitter() as nodeHttp.IncomingMessage; + (response as { statusCode?: number }).statusCode = 200; + (response as { headers: nodeHttp.IncomingHttpHeaders }).headers = { + "content-type": "application/json" + }; + + this.callback(response); + response.emit("data", Buffer.from('{"ok":true}', "utf8")); + response.emit("end"); + } + + destroy(error?: Error): this { + if (error) { + this.emit("error", error); + } + return this; + } +} + +test("requestJson applies proxy-agent-style protocol selection for http and https", async () => { + const harness = createTransportHarness(); + const resolverHarness = createProxyAgentSemanticResolver({ + http_proxy: "http://proxy-http.local:8080", + https_proxy: "http://proxy-https.local:8443" + }); + + await requestJson("http://service.local/http-route", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + await requestJson("https://service.local/https-route", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + + const httpRequest = harness.requireRequestAt(0).options; + const httpsRequest = harness.requireRequestAt(1).options; + + assert.equal(httpRequest.protocol, "http:"); + assert.equal(httpsRequest.protocol, "https:"); + assert.equal( + httpRequest.agent, + resolverHarness.requireCachedAgent("http://proxy-http.local:8080/", "http:") + ); + assert.equal( + httpsRequest.agent, + resolverHarness.requireCachedAgent("http://proxy-https.local:8443/", "https:") + ); + assert.deepEqual( + resolverHarness.records.map((record) => record.proxyUrl), + ["http://proxy-http.local:8080/", "http://proxy-https.local:8443/"] + ); +}); + +test("requestJson keeps protocol-scoped agents when https falls back to http_proxy", async () => { + const harness = createTransportHarness(); + const resolverHarness = createProxyAgentSemanticResolver({ + http_proxy: "http://shared-proxy.local:8080" + }); + + await requestJson("http://service.local/http-fallback", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + await requestJson("https://service.local/https-fallback", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + + const sharedProxyUrl = "http://shared-proxy.local:8080/"; + const httpAgent = resolverHarness.requireCachedAgent(sharedProxyUrl, "http:"); + const httpsAgent = resolverHarness.requireCachedAgent(sharedProxyUrl, "https:"); + const httpRequest = harness.requireRequestAt(0).options; + const httpsRequest = harness.requireRequestAt(1).options; + + assert.equal(httpRequest.agent, httpAgent); + assert.equal(httpsRequest.agent, httpsAgent); + assert.notEqual(httpAgent, httpsAgent); + assert.deepEqual( + resolverHarness.records.map((record) => record.proxyUrl), + [sharedProxyUrl, sharedProxyUrl] + ); +}); + +test("requestJson forwards NO_PROXY bypass as direct agentless request options", async () => { + const harness = createTransportHarness(); + const resolverHarness = createProxyAgentSemanticResolver({ + http_proxy: "http://proxy-http.local:8080", + https_proxy: "http://proxy-https.local:8443", + no_proxy: "bypass-http.local,bypass-https.local" + }); + + await requestJson("http://bypass-http.local/direct-http", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + await requestJson("https://bypass-https.local/direct-https", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + await requestJson("https://external.local/proxied-https", { + proxyResolver: resolverHarness.resolver, + transport: harness.transport + }); + + const bypassHttpRequest = harness.requireRequestAt(0).options; + const bypassHttpsRequest = harness.requireRequestAt(1).options; + const proxiedHttpsRequest = harness.requireRequestAt(2).options; + + assert.equal(bypassHttpRequest.agent, undefined); + assert.equal(bypassHttpsRequest.agent, undefined); + assert.equal( + proxiedHttpsRequest.agent, + resolverHarness.requireCachedAgent("http://proxy-https.local:8443/", "https:") + ); + assert.deepEqual( + resolverHarness.records.map((record) => record.bypassed), + [true, true, false] + ); +}); + +function createTransportHarness(): { + transport: JsonRequestTransport; + requireRequestAt: (index: number) => FakeClientRequest; +} { + const capturedRequests: FakeClientRequest[] = []; + + const requestMock = ( + options: nodeHttp.RequestOptions, + callback: (response: nodeHttp.IncomingMessage) => void + ): nodeHttp.ClientRequest => { + const request = new FakeClientRequest(options, callback); + capturedRequests.push(request); + return request as unknown as nodeHttp.ClientRequest; + }; + + return { + transport: { + httpRequest: requestMock, + httpsRequest: requestMock + }, + requireRequestAt(index: number): FakeClientRequest { + const request = capturedRequests[index]; + if (!request) { + throw new Error(`expected_captured_request_at_${index}`); + } + return request; + } + }; +} + +function createProxyAgentSemanticResolver(env: ProxyEnvironment): { + resolver: ProxyAgentResolver; + records: ResolutionRecord[]; + requireCachedAgent: (proxyUrl: string, protocol: TargetProtocol) => nodeHttp.Agent; +} { + const settings = loadProxySettings(env); + const records: ResolutionRecord[] = []; + const cachedAgents = new Map(); + + const resolver: ProxyAgentResolver = { + resolve(target: URL) { + const proxyUrl = resolveProxyForUrl(target, settings); + records.push({ + target: target.toString(), + proxyUrl, + bypassed: proxyUrl === null + }); + + if (!proxyUrl) { + return { agent: null }; + } + + // Match proxy-agent cache behavior: same proxy URL with different target protocol + // yields separate agent instances. + const cacheKey = `${proxyUrl}|${target.protocol}`; + let agent = cachedAgents.get(cacheKey); + if (!agent) { + agent = new nodeHttp.Agent(); + cachedAgents.set(cacheKey, agent); + } + + return { agent }; + } + }; + + return { + resolver, + records, + requireCachedAgent(proxyUrl: string, protocol: TargetProtocol): nodeHttp.Agent { + const agent = cachedAgents.get(`${proxyUrl}|${protocol}`); + if (!agent) { + throw new Error(`expected_cached_agent:${proxyUrl}:${protocol}`); + } + return agent; + } + }; +} diff --git a/packages/proxy-undici/NOTES.md b/packages/proxy-undici/NOTES.md index 0de5ae5..5837614 100644 --- a/packages/proxy-undici/NOTES.md +++ b/packages/proxy-undici/NOTES.md @@ -5,12 +5,37 @@ This package binds proxy routing decisions to Undici dispatchers. It does not implement SOCKS or PAC dispatchers. +## Compatibility Checklist + +- Node.js `>=18` with Undici runtime support. +- ESM runtime/package consumption. +- Supports HTTP/HTTPS targets and HTTP/HTTPS proxies. +- Requires `@commandrelay/proxy-agent` for SOCKS/PAC proxy protocols. + +## Migration Checklist + +1. Move from per-request `ProxyAgent` construction to a shared `ProxyUndiciDispatcherFactory`. +2. Keep URL-to-dispatcher resolution close to request creation (`factory.resolve(target)`). +3. Use `updateSettings`/`reloadFromEnvironment` for runtime proxy changes. +4. Always call `destroy()` when the process exits. + ## Security and Safety 1. Honors `NO_PROXY` bypass rules from `@commandrelay/proxy-core`. 2. Rejects unsupported proxy protocols early (`socks:*`, `pac+*`). 3. Supports bounded cache + explicit teardown to avoid descriptor leaks. +## Troubleshooting Playbook + +- `invalid_target_url`: + - Input is not a parseable URL; validate before calling `resolve`. +- `invalid_proxy_url`: + - One of the selected proxy settings contains malformed URL data. +- `unsupported_target_protocol:*`: + - Only `http:` and `https:` targets are supported. +- Dispatcher cache churn: + - Increase `maxCacheEntries` if your service uses many unique proxy URLs. + ## Operational Checklist 1. Run `npm --prefix packages/proxy-undici run check`. diff --git a/packages/proxy-undici/README.md b/packages/proxy-undici/README.md index aac5d02..bd72bcd 100644 --- a/packages/proxy-undici/README.md +++ b/packages/proxy-undici/README.md @@ -10,6 +10,29 @@ It reuses `@commandrelay/proxy-core` for environment parsing and `NO_PROXY` matc npm install @termina/proxy-undici undici ``` +## Runtime support + +- Node.js `>=18` +- npm `>=9` +- ESM package (`"type": "module"`) + +## Compatibility + +- Node-only package; dispatchers are Undici runtime primitives. +- Compatible with `undici@^7.16.0`. +- Supports HTTP/HTTPS target URLs. +- Supports HTTP/HTTPS proxy URLs. +- SOCKS and PAC proxy protocols are intentionally unsupported in this package. + +## Migration + +`@termina/proxy-undici` is currently `0.1.x`; there is no prior package-specific breaking release. Typical migration is from ad-hoc `ProxyAgent` allocation or direct `Agent` wiring. + +1. Replace per-request agent construction with one shared `ProxyUndiciDispatcherFactory`. +2. Resolve dispatchers per target URL (`factory.resolve(target)`) and pass the dispatcher to Undici clients. +3. Keep explicit lifecycle cleanup by calling `factory.destroy()` on shutdown. +4. If you need SOCKS/PAC support, migrate those routes to `@commandrelay/proxy-agent`. + ## Features 1. Direct vs proxy dispatcher selection via `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY`. @@ -37,6 +60,15 @@ console.log(await response.body.json()); factory.destroy(); ``` +## Usage Matrix + +| Undici integration need | Recommended package | Reason | +| --- | --- | --- | +| Reusable dispatcher routing for Undici `request`/`fetch` | `@termina/proxy-undici` | Resolves direct vs proxy dispatcher with bounded cache | +| Need Node agent integration (`http.Agent`/`https.Agent`) | Prefer `@commandrelay/proxy-agent` | Agent-oriented clients do not consume Undici dispatchers | +| Need SOCKS or PAC proxy protocols | Prefer `@commandrelay/proxy-agent` | `@termina/proxy-undici` intentionally supports only HTTP/HTTPS proxy URLs | +| Need policy-only parsing and `NO_PROXY` matching | Prefer `@commandrelay/proxy-core` | Keeps decision logic transport-agnostic | + ## API ### `new ProxyUndiciDispatcherFactory(options?)` @@ -77,11 +109,22 @@ Returns: Unsupported proxy protocols in this package: SOCKS and PAC. Use `@commandrelay/proxy-agent` for those protocols. +## Troubleshooting + +- `unsupported_proxy_protocol:*` for SOCKS/PAC URLs: + - This is expected in `@termina/proxy-undici`; route those cases to `@commandrelay/proxy-agent`. +- Unexpected direct routing: + - Inspect `NO_PROXY` and ensure the target protocol has matching proxy settings. +- Unexpected proxy routing: + - Check for `ALL_PROXY` fallback and stale runtime env values. +- Growing open handles in long-lived processes: + - Reuse one factory and call `factory.destroy()` during shutdown. + ## Examples - [Examples index](./docs/examples/README.md) -- [Undici request](./docs/examples/request.md) -- [Node fetch dispatcher](./docs/examples/fetch.md) +- [Undici request routing snapshot](./docs/examples/request.md) +- [Node fetch dispatcher routing snapshot](./docs/examples/fetch.md) ## Notes diff --git a/packages/proxy-undici/docs/examples/README.md b/packages/proxy-undici/docs/examples/README.md index bbfc606..7a9daa6 100644 --- a/packages/proxy-undici/docs/examples/README.md +++ b/packages/proxy-undici/docs/examples/README.md @@ -1,4 +1,9 @@ # proxy-undici Examples -1. [Undici request](./request.md) -2. [Node fetch with dispatcher](./fetch.md) +Each example includes a runnable snippet and an expected output snapshot. +Snapshots are stored under [`./snapshots`](./snapshots/). + +1. [Undici request routing and cache behavior](./request.md) + Snapshot: [`./snapshots/request.expected.json`](./snapshots/request.expected.json) +2. [Node fetch dispatcher routing](./fetch.md) + Snapshot: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) diff --git a/packages/proxy-undici/docs/examples/fetch.md b/packages/proxy-undici/docs/examples/fetch.md index 2870bb3..cb1b192 100644 --- a/packages/proxy-undici/docs/examples/fetch.md +++ b/packages/proxy-undici/docs/examples/fetch.md @@ -1,18 +1,57 @@ # Node fetch with Undici dispatcher -```ts -import { ProxyUndiciDispatcherFactory } from "@termina/proxy-undici"; +This example focuses on deterministic routing output (no network calls) so the snapshot is stable. -const factory = new ProxyUndiciDispatcherFactory(); -const resolved = factory.resolve("https://httpbin.org/json"); +## Run + +```bash +node --import tsx <<'TS' +import { + ProxyUndiciDispatcherFactory, + loadProxySettings, + type UndiciDispatcherAdapter +} from "@termina/proxy-undici"; + +const adapter: UndiciDispatcherAdapter = { + createDirect: () => ({ kind: "direct" } as never), + createProxy: (proxyUrl) => ({ kind: "proxy", proxyUrl } as never) +}; -const response = await fetch("https://httpbin.org/json", { - dispatcher: resolved.dispatcher as never +const factory = new ProxyUndiciDispatcherFactory({ + settings: loadProxySettings({ + https_proxy: "http://secure-proxy.local:8443" + }), + adapter }); -console.log(resolved.viaProxy, resolved.proxyUrl); -console.log(await response.json()); +const resolved = factory.resolve("https://httpbin.org/json"); + +console.log( + JSON.stringify( + { + viaProxy: resolved.viaProxy, + proxyUrl: resolved.proxyUrl, + fromCache: resolved.fromCache + }, + null, + 2 + ) +); + factory.destroy(); +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/fetch.expected.json`](./snapshots/fetch.expected.json) + +```json +{ + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": false +} ``` `dispatcher` is a Node/Undici option and is not supported in browsers. diff --git a/packages/proxy-undici/docs/examples/request.md b/packages/proxy-undici/docs/examples/request.md index 8b5f35f..65ac106 100644 --- a/packages/proxy-undici/docs/examples/request.md +++ b/packages/proxy-undici/docs/examples/request.md @@ -1,18 +1,70 @@ -# Undici request +# Undici request routing and cache behavior -```ts -import { request } from "undici"; -import { ProxyUndiciDispatcherFactory } from "@termina/proxy-undici"; +This example demonstrates deterministic routing and cache behavior for repeated proxy-resolved targets. -const factory = new ProxyUndiciDispatcherFactory(); -const resolved = factory.resolve("https://httpbin.org/get"); +## Run -const response = await request("https://httpbin.org/get", { - method: "GET", - dispatcher: resolved.dispatcher +```bash +node --import tsx <<'TS' +import { + ProxyUndiciDispatcherFactory, + loadProxySettings, + type UndiciDispatcherAdapter +} from "@termina/proxy-undici"; + +const adapter: UndiciDispatcherAdapter = { + createDirect: () => ({ kind: "direct" } as never), + createProxy: (proxyUrl) => ({ kind: "proxy", proxyUrl } as never) +}; + +const factory = new ProxyUndiciDispatcherFactory({ + settings: loadProxySettings({ + https_proxy: "http://secure-proxy.local:8443" + }), + adapter }); -console.log(resolved.viaProxy, resolved.proxyUrl); -console.log(await response.body.json()); +const first = factory.resolve("https://api.example.com/get"); +const second = factory.resolve("https://api.example.com/health"); + +console.log( + JSON.stringify( + { + first: { + viaProxy: first.viaProxy, + proxyUrl: first.proxyUrl, + fromCache: first.fromCache + }, + second: { + viaProxy: second.viaProxy, + proxyUrl: second.proxyUrl, + fromCache: second.fromCache + } + }, + null, + 2 + ) +); + factory.destroy(); +TS +``` + +## Expected output snapshot + +Snapshot file: [`./snapshots/request.expected.json`](./snapshots/request.expected.json) + +```json +{ + "first": { + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": false + }, + "second": { + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": true + } +} ``` diff --git a/packages/proxy-undici/docs/examples/snapshots/fetch.expected.json b/packages/proxy-undici/docs/examples/snapshots/fetch.expected.json new file mode 100644 index 0000000..1b00037 --- /dev/null +++ b/packages/proxy-undici/docs/examples/snapshots/fetch.expected.json @@ -0,0 +1,5 @@ +{ + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": false +} diff --git a/packages/proxy-undici/docs/examples/snapshots/request.expected.json b/packages/proxy-undici/docs/examples/snapshots/request.expected.json new file mode 100644 index 0000000..8bae9bd --- /dev/null +++ b/packages/proxy-undici/docs/examples/snapshots/request.expected.json @@ -0,0 +1,12 @@ +{ + "first": { + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": false + }, + "second": { + "viaProxy": true, + "proxyUrl": "http://secure-proxy.local:8443/", + "fromCache": true + } +} diff --git a/packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts b/packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts index 0b15f97..78c0296 100644 --- a/packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts +++ b/packages/proxy-undici/test/proxy-undici-dispatcher-factory.test.ts @@ -26,6 +26,139 @@ function createFakeDispatcher(id: string, destroyed: string[]): FakeDispatcher { }; } +function createNoopDispatcher(): FakeDispatcher { + return { + id: "noop", + destroy: () => {}, + close: () => {} + }; +} + +test("interoperability matrix resolves deterministic env proxy routing for http/https targets", () => { + interface MatrixCase { + name: string; + env: Record; + target: string; + expectedProxyUrl: string | null; + } + + const cases: MatrixCase[] = [ + { + name: "no proxy vars -> direct for http target", + env: {}, + target: "http://service.local", + expectedProxyUrl: null + }, + { + name: "http_proxy routes http target", + env: { http_proxy: "http://proxy-http.local:8080" }, + target: "http://service.local", + expectedProxyUrl: "http://proxy-http.local:8080/" + }, + { + name: "http_proxy fallback routes https target", + env: { http_proxy: "http://proxy-http.local:8080" }, + target: "https://service.local", + expectedProxyUrl: "http://proxy-http.local:8080/" + }, + { + name: "https_proxy does not route http target", + env: { https_proxy: "http://proxy-https.local:8443" }, + target: "http://service.local", + expectedProxyUrl: null + }, + { + name: "https_proxy routes https target", + env: { https_proxy: "http://proxy-https.local:8443" }, + target: "https://service.local", + expectedProxyUrl: "http://proxy-https.local:8443/" + }, + { + name: "all_proxy routes http target", + env: { all_proxy: "http://proxy-all.local:9000" }, + target: "http://service.local", + expectedProxyUrl: "http://proxy-all.local:9000/" + }, + { + name: "all_proxy routes https target", + env: { all_proxy: "http://proxy-all.local:9000" }, + target: "https://service.local", + expectedProxyUrl: "http://proxy-all.local:9000/" + }, + { + name: "https_proxy takes precedence over http_proxy for https target", + env: { + http_proxy: "http://proxy-http.local:8080", + https_proxy: "http://proxy-https.local:8443" + }, + target: "https://service.local", + expectedProxyUrl: "http://proxy-https.local:8443/" + }, + { + name: "http_proxy takes precedence over all_proxy for http target", + env: { + http_proxy: "http://proxy-http.local:8080", + all_proxy: "http://proxy-all.local:9000" + }, + target: "http://service.local", + expectedProxyUrl: "http://proxy-http.local:8080/" + }, + { + name: "lowercase proxy variable wins over uppercase", + env: { + http_proxy: "http://proxy-lower.local:8080", + HTTP_PROXY: "http://proxy-upper.local:8080" + }, + target: "http://service.local", + expectedProxyUrl: "http://proxy-lower.local:8080/" + }, + { + name: "cgi REQUEST_METHOD ignores uppercase HTTP_PROXY", + env: { + REQUEST_METHOD: "GET", + HTTP_PROXY: "http://proxy-upper.local:8080" + }, + target: "http://service.local", + expectedProxyUrl: null + }, + { + name: "no_proxy bypass wins over matching proxy", + env: { + https_proxy: "http://proxy-https.local:8443", + no_proxy: "service.local" + }, + target: "https://service.local", + expectedProxyUrl: null + } + ]; + + for (const matrixCase of cases) { + let directCreateCount = 0; + let proxyCreateCount = 0; + const factory = new ProxyUndiciDispatcherFactory({ + env: matrixCase.env, + adapter: { + createDirect: () => { + directCreateCount += 1; + return createNoopDispatcher() as unknown as never; + }, + createProxy: () => { + proxyCreateCount += 1; + return createNoopDispatcher() as unknown as never; + } + } + }); + + const result = factory.resolve(matrixCase.target); + const expectProxy = matrixCase.expectedProxyUrl !== null; + assert.equal(result.fromCache, false, matrixCase.name); + assert.equal(result.viaProxy, expectProxy, matrixCase.name); + assert.equal(result.proxyUrl, matrixCase.expectedProxyUrl, matrixCase.name); + assert.equal(directCreateCount, expectProxy ? 0 : 1, matrixCase.name); + assert.equal(proxyCreateCount, expectProxy ? 1 : 0, matrixCase.name); + } +}); + test("resolve returns direct dispatcher when no proxy is configured", () => { const destroyed: string[] = []; let directCreated = 0; @@ -224,3 +357,36 @@ test("throws typed errors for invalid targets and unsupported protocols", () => (error: unknown) => error instanceof InvalidProxyUrlError ); }); + +test("socks and pac proxies are explicitly unsupported (chaining not supported)", () => { + const baseAdapter: UndiciDispatcherAdapter = { + createDirect: () => createNoopDispatcher() as unknown as never, + createProxy: () => createNoopDispatcher() as unknown as never + }; + + const socksFactory = new ProxyUndiciDispatcherFactory({ + settings: loadProxySettings({ + http_proxy: "socks5://proxy.local:1080" + }), + adapter: baseAdapter + }); + + assert.throws(() => socksFactory.resolve("http://service.local"), (error: unknown) => { + assert.ok(error instanceof UnsupportedProxyProtocolError); + assert.equal(error.message, "unsupported_proxy_protocol:socks5:"); + return true; + }); + + const pacFactory = new ProxyUndiciDispatcherFactory({ + settings: loadProxySettings({ + http_proxy: "pac+http://proxy-config.local/proxy.pac" + }), + adapter: baseAdapter + }); + + assert.throws(() => pacFactory.resolve("http://service.local"), (error: unknown) => { + assert.ok(error instanceof UnsupportedProxyProtocolError); + assert.equal(error.message, "unsupported_proxy_protocol:pac+http:"); + return true; + }); +}); diff --git a/scripts/checkpoints/README.md b/scripts/checkpoints/README.md index edb9afe..2031f87 100644 --- a/scripts/checkpoints/README.md +++ b/scripts/checkpoints/README.md @@ -15,6 +15,18 @@ Optional flags: - `--output ` - `--force` +## Generate A2 tmux fixture harness evidence + +```bash +scripts/checkpoints/run-a2-tmux-fixture-evidence.sh +``` + +Optional flags are forwarded to `scripts/tmux-fixtures/run-fixture-evidence.ts`, for example: + +```bash +scripts/checkpoints/run-a2-tmux-fixture-evidence.sh --session fixture_a2_ci --panes 4 --cycles 6 +``` + ## Tracking location Generated checkpoint notes are stored in: diff --git a/scripts/checkpoints/run-a2-tmux-fixture-evidence.sh b/scripts/checkpoints/run-a2-tmux-fixture-evidence.sh new file mode 100644 index 0000000..359b188 --- /dev/null +++ b/scripts/checkpoints/run-a2-tmux-fixture-evidence.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: scripts/checkpoints/run-a2-tmux-fixture-evidence.sh [options] + +Run deterministic A2 tmux fixture harness evidence automation and write +a checkpoint artifact under scripts/checkpoints/runs/. + +This wrapper forwards all options to: + node --import tsx scripts/tmux-fixtures/run-fixture-evidence.ts + +Examples: + scripts/checkpoints/run-a2-tmux-fixture-evidence.sh + scripts/checkpoints/run-a2-tmux-fixture-evidence.sh --session fixture_a2_ci --panes 4 --cycles 6 +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +cd "${PROJECT_ROOT}" +exec node --import tsx scripts/tmux-fixtures/run-fixture-evidence.ts "$@" diff --git a/scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md b/scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md new file mode 100644 index 0000000..4b2d7c7 --- /dev/null +++ b/scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md @@ -0,0 +1,42 @@ +# A2 tmux Fixture Harness Evidence - 2026-02-27 + +- Captured (UTC): 2026-02-27T15:45:57.396Z +- Status: `pass` +- Artifact path: `/mnt/c/sriinnu/personal/Kaala-brahma/terminal/scripts/checkpoints/runs/2026-02-27-a2-tmux-fixture-harness-evidence.md` + +## Command + +`node --import tsx scripts/tmux-fixtures/run-fixture-evidence.ts --session fixture_a2_diag3 --window fixture --panes 3 --profile replay --cycles 2 --lines-per-cycle 2 --delay-ms 0` + +## Command Stages + +| Stage | Status | Duration (ms) | Invocation | +| --- | --- | --- | --- | +| create-fixture | pass | 58 | `bash /mnt/c/sriinnu/personal/Kaala-brahma/terminal/scripts/tmux-fixtures/create-fixture.sh --session fixture_a2_diag3 --window fixture --panes 3 --force-recreate` | +| emit-fixture-output | pass | 91 | `bash /mnt/c/sriinnu/personal/Kaala-brahma/terminal/scripts/tmux-fixtures/emit-fixture-output.sh --session fixture_a2_diag3 --window fixture --profile replay --cycles 2 --lines-per-cycle 2 --delay-ms 0` | +| teardown-fixture | pass | 26 | `bash /mnt/c/sriinnu/personal/Kaala-brahma/terminal/scripts/tmux-fixtures/teardown-fixture.sh --session fixture_a2_diag3 --if-missing-ok` | + +## Pane Capture Summary + +| Pane Index | Pane ID | Fixture Events Captured | +| --- | --- | --- | +| 0 | %9 | 4 | +| 1 | %11 | 4 | +| 2 | %10 | 4 | + +## Replay Ordering Assertions + +| Assertion | Status | Detail | +| --- | --- | --- | +| pane_count_matches_expected | pass | expected 3, actual 3 | +| total_event_count | pass | expected 12, actual 12 | +| pane_0_event_count | pass | expected 4, actual 4 | +| pane_1_event_count | pass | expected 4, actual 4 | +| pane_2_event_count | pass | expected 4, actual 4 | +| global_sequence_continuous | pass | validated 12 events | +| replay_order_matches_emit_schedule | pass | all events matched expected replay ordering | + +## Operator Notes + +- This evidence run is deterministic when tmux availability and fixture scripts are unchanged. +- The harness is idempotent for the same session name because it force-recreates only marked fixture sessions. diff --git a/scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md b/scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md new file mode 100644 index 0000000..e164a2b --- /dev/null +++ b/scripts/checkpoints/runs/2026-02-27-cr-p1-002-weekly-evidence-lane.md @@ -0,0 +1,47 @@ +# CR-P1-002 Weekly Evidence Lane Checkpoint - 2026-02-27 + +- Ticket: `CR-P1-002` +- Cycle date: `2026-02-27` +- Week: `2026-W09` +- Branch: `feat/ssh-exploration` +- Commit baseline: `8ae6999` +- Captured (UTC): `2026-02-27T15:45:57Z` +- Scope: docs-only weekly evidence synchronization and roadmap milestone mirroring. + +## Constraints Applied + +- Edit scope restricted to docs evidence files and `scripts/checkpoints/runs/` artifacts. +- No code-path or CI behavior was changed in this checkpoint. +- Open execution blockers are recorded explicitly and left unresolved. + +## Acceptance Criteria Status + +| Criterion | Status | Evidence | +| --- | --- | --- | +| Weekly checkpoint artifact is generated or updated for the current cycle. | `pass` | [this artifact](./2026-02-27-cr-p1-002-weekly-evidence-lane.md) | +| Milestone decisions are mirrored into roadmap docs with concrete dates and statuses. | `pass` | [proxy roadmap mirror section](../../../docs/proxy-ecosystem-roadmap.md#milestone-decision-mirror-2026-02-27-cr-p1-002), [TODO P1 mirror](../../../docs/TODO.md#p1-this-week) | +| Evidence links in this checkpoint resolve to existing files/artifacts. | `pass` | [2026-02-27 validation checkpoint](./2026-02-27-feat-ssh-exploration-validation-checkpoint.md), [2026-02-27 proxy publish dry-run checkpoint](./2026-02-27-proxy-publish-dry-run.md), [2026-02-25 weekly cross-platform checkpoint](./2026-02-25-weekly-cross-platform-checkpoint.md) | + +## Milestone Decision Snapshot (Mirrored) + +| Milestone Decision | Status on 2026-02-27 | Evidence | +| --- | --- | --- | +| W2 audit-log acceptance remains complete. | `done` | [TODO W2 acceptance](../../../docs/TODO.md#milestone-w2-2026-03-09-to-2026-03-15), [bridge e2e assertions](../../../src/server/bridge-server.e2e.test.ts), [policy assertions](../../../src/server/bridge-server.policy.test.ts) | +| W2 replay-ordering acceptance is proven by fixture run artifact. | `done` | [2026-02-27 fixture harness evidence run](./2026-02-27-a2-tmux-fixture-harness-evidence.md), [tmux fixture runbook](../../../scripts/tmux-fixtures/README.md), [TODO W2 item](../../../docs/TODO.md#milestone-w2-2026-03-09-to-2026-03-15) | +| W2 publish dry-run selector/dist-tag evidence exists but dry-run publish remains blocked. | `partial` | [2026-02-27 dry-run checkpoint](./2026-02-27-proxy-publish-dry-run.md), [TODO W2 item](../../../docs/TODO.md#milestone-w2-2026-03-09-to-2026-03-15) | +| B2 docs/examples pack for `@commandrelay/proxy-*` remains complete. | `done` | [TODO B2 readiness](../../../docs/TODO.md#b2-productization-readiness), [package docs matrix](../../../docs/proxy/package-docs-matrix.md) | + +## Open Blockers (Not Resolved Here) + +1. Local publish dry-run remains blocked by npm cache permission error (`EACCES` under `/home/sriinnu/.npm`). + +## Co-Orchestrator Check (This Cycle) + +- Health check: `pass` (`Sattva=0.6000`, `Rajas=0.3000`, `Tamas=0.1000`; alerts: none). +- Delegation attempt (`chitragupta_prompt`): `failed` due provider spawn error (`E2BIG`). +- Deliberation fallback (`sabha_deliberate`): returned `escalated` / `no-consensus`; local evidence-only doc update path used. + +## Outcome + +- `CR-P1-002` is complete for docs synchronization in this cycle. +- Execution milestones still carry `partial`/`blocked` statuses where publish governance and dry-run environment blockers remain. diff --git a/scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md b/scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md new file mode 100644 index 0000000..d851dac --- /dev/null +++ b/scripts/checkpoints/runs/2026-02-27-feat-ssh-exploration-validation-checkpoint.md @@ -0,0 +1,38 @@ +# Branch Validation Checkpoint - 2026-02-27 + +- Branch: `feat/ssh-exploration` +- Commit baseline: `b30944a` +- Captured (UTC): `2026-02-27T14:28:14Z` +- Scope: latest branch-local validation evidence for P1 checkpoint tracking. + +## Validation Summary + +- Pass: `5` +- Fail: `0` +- Pending: `0` + +## Command Evidence + +| Command | Status | Evidence | +| --- | --- | --- | +| `npm run check` | `pass` | Exit code `0`; ran `check:root` (`tsc --noEmit`) and `check:orchestration` (`tsc --noEmit -p tsconfig.orchestration.json`). | +| `npm test` | `pass` | Exit code `0`; `node --import tsx --test src/**/*.test.ts` reported `tests 195`, `pass 195`, `fail 0`. | +| `npm run test:ci:all` | `pass` | Exit code `0`; CI targets passed (`root`, `web-smoke`, `package:cli-proxy`, `package:proxy-agent`, `package:proxy-core`, `package:proxy-fetch`, `package:proxy-http-client`, `package:proxy-undici`). TAP artifacts written to `.ci-artifacts/tap/`. | +| `node --import tsx --test src/server/ws-contract-matrix.test.ts src/server/bridge-server.policy.test.ts src/server/input-policy.test.ts` | `pass` | Exit code `0`; `tests 3`, `pass 3`, `fail 0`. | +| `node --import tsx --test src/control-plane/control-plane-client.test.ts src/net/proxy-agent-factory.test.ts src/net/proxy-router.test.ts` | `pass` | Exit code `0`; `tests 3`, `pass 3`, `fail 0`. | + +## Operator Safety Runbook Evidence + +- [Controlled-input safety incident runbook (kill switch + lane lockout)](../../../docs/operations.md#controlled-input-safety-incident-runbook) +- [TODO safety milestone link (A4)](../../../docs/TODO.md#a4-safety) +- [Execution-owned ticket `CR-P1-004`](../../../docs/execution-owned-tickets.md#cr-p1-004-publish-operator-safety-runbook-kill-switch--lane-lockout) + +## Co-Orchestrator Check + +- Chitragupta health check passed (`Sattva=0.6000`, `Rajas=0.3000`, `Tamas=0.1000`; alerts: none). +- Delegated prompt calls failed with provider spawn error (`spawn E2BIG`); docs update completed via direct local authoring fallback. + +## Next Blockers + +- Audit metadata + lane-release work is no longer blocked (audit contract documented in `docs/controlled-input-audit.md`; `lane_owner_released` emitted for `detach`/`disconnect`/socket close in `src/server/bridge-server.ts`; related policy/contract suites are green in this checkpoint). +- Remaining blocker: publish dry-run commands are still blocked by local npm cache `EACCES` on `/home/sriinnu/.npm` (see [2026-02-27-proxy-publish-dry-run.md](./2026-02-27-proxy-publish-dry-run.md) and `docs/execution-owned-tickets.md` ticket `CR-P1-003` status `blocked`). diff --git a/scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md b/scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md new file mode 100644 index 0000000..fca1a93 --- /dev/null +++ b/scripts/checkpoints/runs/2026-02-27-proxy-publish-dry-run.md @@ -0,0 +1,52 @@ +# 2026-02-27 Proxy Publish Local Dry-Run Checkpoint + +## Scope + +- Branch: `feat/ssh-exploration` +- Selector: `@commandrelay/proxy-*` +- Dist-tag: `latest` +- Mode: local `npm publish --dry-run` evidence only (no actual publish) + +## Environment + +- `pwd`: `/mnt/c/sriinnu/personal/Kaala-brahma/terminal` +- `git branch --show-current`: `feat/ssh-exploration` +- `node -v`: `v22.20.0` +- `npm -v`: `10.9.3` + +## Selected Packages + +1. `@commandrelay/proxy-core@0.1.0` +2. `@commandrelay/proxy-agent@0.1.0` +3. `@commandrelay/proxy-http-client@0.1.0` + +## Validation Results + +| Package | `check` | `build` | `test` | TAP summary | +| --- | --- | --- | --- | --- | +| `@commandrelay/proxy-core` | pass | pass | pass | `1/1` pass | +| `@commandrelay/proxy-agent` | pass | pass | pass | `3/3` pass | +| `@commandrelay/proxy-http-client` | pass | pass | pass | `4/4` pass | + +## Dry-Run Publish Results + +For all three packages, both commands failed with the same environment blocker: + +1. `(cd packages/ && npm pack --dry-run --json)` -> `exit 243` +2. `(cd packages/ && npm publish --dry-run --access public --tag latest)` -> `exit 243` + +Common error details: + +- `npm ERR! code EACCES` +- cache path under `/home/sriinnu/.npm/_cacache/tmp/*` +- remediation suggested by npm: + +```bash +sudo chown -R 1000:1000 "/home/sriinnu/.npm" +``` + +## Conclusion + +- Local dry-run verification is `partial`. +- Package quality gates (`check/build/test`) are green. +- Publish dry-run commands are blocked by local npm cache ownership, not package logic. diff --git a/scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md b/scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md new file mode 100644 index 0000000..f3729f1 --- /dev/null +++ b/scripts/checkpoints/runs/proxy-negative-input-report-2026-02-27.md @@ -0,0 +1,11 @@ +# Proxy Negative Input Report (2026-02-27) + +- Command: `node --import tsx --test src/net/proxy-agent-factory.test.ts` +- Result: pass (`# pass 1`, `# fail 0`) + +| Scenario | Test Name | Expected | Actual | Status | +| --- | --- | --- | --- | --- | +| Malformed URL handling | `throws invalid_proxy_url for malformed credential-bearing proxy URLs` | Factory rejects malformed proxy URLs with `invalid_proxy_url` instead of silently proxying. | `factory.resolve("https://example.com")` throws and matches `/invalid_proxy_url/` for malformed HTTP, SOCKS, and PAC credential-bearing URLs. | PASS | +| NO_PROXY edge precedence | `keeps uppercase NO_PROXY precedence over lowercase no_proxy` | Uppercase `NO_PROXY` should control matching when both vars are set; lowercase wildcard should not override it. | `http://example.com` bypasses proxy, while `http://external.local` still proxies via `HTTP_PROXY`. | PASS | +| PAC failure path (explicit runtime assertion) | `surfaces PAC resolver failures during connect attempts` | PAC resolver/script failures should surface as connection errors when the PAC agent is used, not appear as successful direct routing. | A `PacProxyAgent` is created from `pac+data:` input, and `connect()` rejects on invalid PAC source with a non-empty error message. | PASS | +| Fallback behavior for invalid env proxies | `falls back to direct mode when env proxy values are invalid or unsupported` | Invalid/unsupported env proxy values should degrade to direct mode (`viaProxy=false`, no agent) rather than throw at call sites. | For both `http://example.com` and `https://example.com`, resolution is direct (`viaProxy=false`, `proxyUrl=null`, `agent=null`). | PASS | diff --git a/scripts/ssh/README.md b/scripts/ssh/README.md new file mode 100644 index 0000000..c2169da --- /dev/null +++ b/scripts/ssh/README.md @@ -0,0 +1,134 @@ +# SSH Tunnel Runbook (CommandRelay) + +Use [`open-tunnel.sh`](./open-tunnel.sh) to open a local SSH tunnel to a remote CommandRelay instance without exposing remote ports publicly. +Use [`validate-remote-runtime.sh`](./validate-remote-runtime.sh) to preflight a remote host for tmux + Node runtime readiness. + +## What this does + +1. Forwards local `127.0.0.1:` to remote `127.0.0.1:` through SSH. +2. Validates required arguments and port ranges before opening the tunnel. +3. Fails early when local port is already in use. +4. Prints only operational details (no auth tokens or secret values). + +## Requirements (macOS/Linux) + +1. `ssh` installed and available in `PATH`. +2. SSH access to the target host. +3. CommandRelay running on the remote host (default assumed on `127.0.0.1:8787`). + +## Quick start + +From repo root: + +```bash +./scripts/ssh/open-tunnel.sh --target +``` + +Then connect local clients to: + +1. `http://127.0.0.1:8787` +2. `ws://127.0.0.1:8787/ws` + +## Examples + +macOS/Linux default tunnel: + +```bash +./scripts/ssh/open-tunnel.sh --target dev@relay-host +``` + +Use a different local port: + +```bash +./scripts/ssh/open-tunnel.sh --target dev@relay-host --local-port 9878 +``` + +Use a specific SSH key: + +```bash +./scripts/ssh/open-tunnel.sh --target relay-prod --identity ~/.ssh/id_ed25519 +``` + +Through bastion/proxy jump: + +```bash +./scripts/ssh/open-tunnel.sh \ + --target relay-prod \ + --ssh-option ProxyJump=bastion-user@bastion-host +``` + +Preview command without opening the tunnel: + +```bash +./scripts/ssh/open-tunnel.sh --target dev@relay-host --dry-run +``` + +## Help + +```bash +./scripts/ssh/open-tunnel.sh --help +``` + +## Remote runtime validator + +Use this before opening tunnels or enabling SSH runtime mode. + +What it checks in one non-interactive SSH command set: + +1. `command -v tmux` +2. `tmux -V` +3. `node -v` + +### Quick start + +```bash +./scripts/ssh/validate-remote-runtime.sh --target +``` + +### Examples + +Use a non-default SSH key: + +```bash +./scripts/ssh/validate-remote-runtime.sh \ + --target relay-prod \ + --identity ~/.ssh/id_ed25519 +``` + +Custom SSH command/port and options: + +```bash +./scripts/ssh/validate-remote-runtime.sh \ + --target relay-prod \ + --ssh-command ssh \ + --ssh-port 2222 \ + --ssh-option ProxyJump=bastion-user@bastion-host \ + --ssh-option ServerAliveInterval=30 +``` + +Disable strict host key checking (for controlled temporary diagnostics only): + +```bash +./scripts/ssh/validate-remote-runtime.sh \ + --target relay-prod \ + --strict-host-key-checking off +``` + +Dry-run local validation: + +```bash +./scripts/ssh/validate-remote-runtime.sh --target relay-prod --dry-run +``` + +### Exit codes + +1. `0`: success (or valid dry-run) +2. `2`: invalid usage/arguments +3. `3`: local SSH command/setup issue +4. `4`: remote runtime validation failed + +### Help + +```bash +./scripts/ssh/validate-remote-runtime.sh --help +``` diff --git a/scripts/ssh/open-tunnel.sh b/scripts/ssh/open-tunnel.sh new file mode 100755 index 0000000..0c2f21c --- /dev/null +++ b/scripts/ssh/open-tunnel.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SCRIPT_NAME="$(basename "$0")" + +LOCAL_HOST="127.0.0.1" +LOCAL_PORT="8787" +REMOTE_HOST="127.0.0.1" +REMOTE_PORT="8787" +SSH_PORT="22" +SSH_TARGET="" +IDENTITY_FILE="" +DRY_RUN="false" +SSH_OPTIONS=() + +print_help() { + cat <<'EOF' +Open an SSH local tunnel for CommandRelay HTTP/WebSocket access. + +Usage: + open-tunnel.sh --target [options] + +Required: + -t, --target SSH target (user@host or SSH config host alias) + +Options: + --local-host Local bind host (default: 127.0.0.1) + -l, --local-port Local bind port (default: 8787) + --remote-host Remote forward host (default: 127.0.0.1) + -r, --remote-port Remote forward port (default: 8787) + -p, --ssh-port SSH server port (default: 22) + -i, --identity SSH identity key path + --ssh-option