diff --git a/.changeset/cli-qr-code.md b/.changeset/cli-qr-code.md new file mode 100644 index 0000000..2095d1b --- /dev/null +++ b/.changeset/cli-qr-code.md @@ -0,0 +1,13 @@ +--- +"sideband": patch +--- + +Print a QR code in the terminal at daemon startup alongside the Quick Connect URL. + +- Renders below the URL/code block using Unicode half-block characters (`▀▄█ `) — halves + the height vs full-size QR, works on light and dark terminals without ANSI color codes +- Skipped when the terminal is too narrow to fit the QR without line-wrapping (dynamic + guard based on actual matrix width + 2-char left margin) +- Silently skipped on error — QR rendering never blocks startup +- Not reprinted on QC renewal (would spam the terminal); only URL + code are shown +- Zero new transitive dependencies (`qr` package, 0 deps, 7 KB gzipped) diff --git a/.changeset/cloud-relay-keepalive.md b/.changeset/cloud-relay-keepalive.md new file mode 100644 index 0000000..0d0d241 --- /dev/null +++ b/.changeset/cloud-relay-keepalive.md @@ -0,0 +1,11 @@ +--- +"@sideband/cloud": patch +--- + +Fix relay keepalive lifecycle on both client and daemon sides. + +- **Daemon (`listen`):** add SBRP Ping every 45s in `runMux` to prevent the relay from + closing idle daemon connections (relay sweeps at 90s; cleared in `finally` on disconnect) +- **Client (`connect`):** fix keepalive timer race — capture interval handle in a local + `const timer` so a stale in-flight `send` failure from a previous connection cannot clear + the active connection's timer; `terminate()` now clears explicitly before transport close diff --git a/bun.lock b/bun.lock index b5d8be0..3207cd2 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@changesets/cli": "^2.30.0", "@eslint/js": "^10.0.1", "@types/bun": "^1.3.10", - "eslint": "^10.0.2", + "eslint": "^10.0.3", "gh-pages": "^6.3.0", "husky": "^9.1.7", "jiti": "^2.6.1", @@ -25,18 +25,19 @@ }, "packages/cli": { "name": "sideband", - "version": "0.4.0", + "version": "0.6.1", "bin": { "sideband": "./dist/bin.js", }, "dependencies": { "@sideband/cloud": "workspace:^", "@sideband/secure-relay": "workspace:^", + "qr": "^0.5.5", }, }, "packages/cloud": { "name": "@sideband/cloud", - "version": "0.5.3", + "version": "0.6.0", "dependencies": { "@sideband/peer": "workspace:^", "@sideband/secure-relay": "workspace:^", @@ -49,7 +50,7 @@ }, "packages/peer": { "name": "@sideband/peer", - "version": "0.5.0", + "version": "0.6.1", "dependencies": { "@sideband/protocol": "workspace:^", "@sideband/rpc": "workspace:^", @@ -234,17 +235,17 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.2", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="], + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.2", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], - "@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="], + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.2", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], "@googleapis/drive": ["@googleapis/drive@20.1.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-8/gapeLuZ3igooEGLE1AL3Zp5fjqigZQNmyjehb4QF7JVJh6FXF+72AcQGGsFypwJb2S68HIuQFomHBw4RJSOg=="], @@ -394,7 +395,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -604,13 +605,13 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.0.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw=="], + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], - "eslint-scope": ["eslint-scope@9.1.1", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@11.1.1", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -988,6 +989,8 @@ "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "qr": ["qr@0.5.5", "", {}, "sha512-iQBvKj7MRKO+co+MY0IZpyLO+ezvttxsmV86WywrgPuAmgBkv0pytyi03wourniSoPgzffeBW6cBgIkpqcvjTg=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], diff --git a/docs/adr/017-cloud-sdk-design.md b/docs/adr/017-cloud-sdk-design.md index a357f2f..e985816 100644 --- a/docs/adr/017-cloud-sdk-design.md +++ b/docs/adr/017-cloud-sdk-design.md @@ -133,6 +133,7 @@ stale even if the daemon ID was omitted from `ListenOptions`. - ADR-013: Peer SDK Core Design Decisions - ADR-016: Relay Server Design +- ADR-019: Cloud SDK Trust Policy Defaults - `packages/runtime/src/session/types.ts` — `Negotiator`, `NegotiatorConnectionParams` - `packages/cloud/src/connect.ts` — `CloudClientNegotiator` - `packages/cloud/src/listen.ts` — `RelayDaemonTransport`, `RelayVirtualConn`, `runMux`, `CloudServer` diff --git a/docs/adr/019-cloud-sdk-trust-policy.md b/docs/adr/019-cloud-sdk-trust-policy.md new file mode 100644 index 0000000..1aedfc2 --- /dev/null +++ b/docs/adr/019-cloud-sdk-trust-policy.md @@ -0,0 +1,71 @@ +# ADR-019: Cloud SDK Trust Policy Defaults + +- **Date**: 2026-03-08 +- **Status**: Accepted +- **Affects**: SDK + +## Context + +`@sideband/cloud`'s `connect()` wraps `relayClientNegotiator`, which requires a `trustPolicy` +telling it how to handle first connections and identity mismatches. Three policies exist: + +- `"auto"` — accept and pin on first connection; silently re-pin on identity mismatch (TOFR) +- `"pinned-only"` — require a pre-existing pin; hard-reject on mismatch or if the store is empty +- `"prompt"` — invoke caller-provided callbacks on first connection and on mismatch (full TOFU) + +The cloud relay authenticates daemons at registration time via API key — the control plane holds +the daemon's `identityPublicKey` from `daemon.register`. A caller connecting via `quickConnectCode` +has an additional trust proof: the QC code itself proves the operator intentionally shared access. + +## Decision + +**Default: `"auto"` for both auth paths.** + +- **Quick Connect**: `"auto"` is correct. The QC code IS the out-of-band trust proof — + prompting for a fingerprint immediately after entry adds friction without security benefit. + On identity mismatch, re-pinning is intentional: a fresh QC code proves the operator + regenerated access, covering the common "wiped `~/.sideband/`" case. + +- **Account path**: `"auto"` is a pragmatic interim default. The intended future default is + `"pinned-only"` verified against a control-plane-provided `expectedFingerprint` from + `daemon.listAccessible`. That fingerprint is not yet plumbed through the current API, so + `"pinned-only"` would reject first-time account connections against an empty store. + `"auto"` keeps the account path functional until `expectedFingerprint` is available. + +The relay's API key authentication provides a meaningful baseline: only registered daemons can +receive SBRP sessions. `"auto"` adds TOFR (Trust On First Registration) on top — weaker than +strict TOFU but not unconditional trust. + +**Future direction**: when `daemon.listAccessible` provides `identityPublicKey`, the account +path will default to `"pinned-only"` using the CP-provided fingerprint — silent verification +with strong identity guarantees. A key-rotation fallback (prompt on mismatch) will handle the +`~/.sideband/` wipe case. When that lands, this ADR should be superseded. + +**Callback no-op rule**: `onFirstConnection` and `onIdentityMismatch` are accepted by the +type system for all policies but only invoked when `trustPolicy === "prompt"`. Passing them +with `"auto"` or `"pinned-only"` has no effect. + +## Invariants + +- `"pinned-only"` MUST NOT be used as a default without a pre-populated identity store or + a CP-provided expected fingerprint — it rejects immediately on an empty store. +- Callbacks MUST be required at the type level when `trustPolicy: "prompt"` (enforced via + discriminated union in `ConnectOptions`). +- The trust policy used by `CloudClientNegotiator.negotiate()` is always the caller-supplied + value or the default computed at negotiate-time — never cached across reconnects. + +## Alternatives Considered + +**Auth-mode-aware default (QC → `"auto"`, account → `"pinned-only"`)** +Rejected: `"pinned-only"` fails on first account connection — the pin is established by the +first successful connection, so an empty store at that point silently breaks the happy path. + +**Default to `"prompt"` on account path** +Rejected: the type system mandates callbacks for `"prompt"` mode, forcing every caller to +supply them even without interactive trust management intent. + +## References + +- `packages/cloud/src/connect.ts` — `ConnectOptions`, `CloudClientNegotiator.negotiate()` +- `packages/peer/src/negotiator/client.ts` — `relayClientNegotiator`, `trustPolicy` implementation +- ADR-017: Cloud SDK Design (auth modes, `getConnectionParams`) diff --git a/docs/adr/index.md b/docs/adr/index.md index 256ffbd..33450b9 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -24,6 +24,7 @@ This section documents significant architectural decisions for the Sideband proj | [016](./016-relay-server-design.md) | Relay Server Design | Accepted | | [017](./017-cloud-sdk-design.md) | Cloud SDK Design | Accepted | | [018](./018-daemon-capabilities.md) | Daemon Built-in Capabilities | Accepted | +| [019](./019-cloud-sdk-trust-policy.md) | Cloud SDK Trust Policy Defaults | Accepted | ## Creating a new ADR diff --git a/package.json b/package.json index 5150624..d1c1c28 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@changesets/cli": "^2.30.0", "@eslint/js": "^10.0.1", "@types/bun": "^1.3.10", - "eslint": "^10.0.2", + "eslint": "^10.0.3", "gh-pages": "^6.3.0", "husky": "^9.1.7", "jiti": "^2.6.1", diff --git a/packages/cli/README.md b/packages/cli/README.md index 3595716..32f744f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -29,7 +29,7 @@ sideband --version # print version sideband --help # show help ``` -``` +```text $ npx sideband Sideband daemon running @@ -39,6 +39,14 @@ $ npx sideband Quick Connect: https://sideband.cloud/connect#qc=abcd-efgh-ijkl Code: abcd-efgh-ijkl + Scan to connect: + ████████████████████████████████ + ██ ▄▄▄▄▄ █▀▄ █▄▀▄▀▄▄▀ █ ▄▄▄▄▄ ██ + ██ █ █ █▀▀▄█▄▄█▄▀ ▀▄█ █ █ ██ + ██ █▄▄▄█ █▀▄▀▄▀▀▄▀▄▄▄▀█ █▄▄▄█ ██ + ██▄▄▄▄▄▄▄█▄█ █▄▀▄▄█ █ █▄▄▄▄▄▄▄██ + ████████████████████████████████ + Waiting for connections... + Connected (8f3kN2p1) [12:34:05] @@ -47,6 +55,10 @@ $ npx sideband - Disconnected (8f3kN2p1) [12:34:12] ``` +A QR code is rendered below the Quick Connect URL when the terminal is wide enough to fit +it without line-wrapping (skipped silently otherwise). In `--json` mode, QR rendering is +disabled — use the `quickConnectUrl` field from the `ready` event instead. + ## API key resolution Highest priority wins: @@ -79,7 +91,9 @@ Two methods are always available under the reserved `$sideband/` namespace: {"event":"error","message":"Quick Connect renewal failed: rate limited"} ``` -Errors are written to stderr in both modes. +In human mode, errors go to stderr. In `--json` mode, errors are emitted as +`{ event: "error" }` to stdout (authoritative for automation) and also written to stderr +as a human-readable mirror. ## Quick Connect renewal diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ca1efc..2a5f1c4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,14 @@ "rpc" ], "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "engines": { "node": ">=22" }, @@ -36,7 +44,8 @@ ], "dependencies": { "@sideband/cloud": "workspace:^", - "@sideband/secure-relay": "workspace:^" + "@sideband/secure-relay": "workspace:^", + "qr": "^0.5.5" }, "publishConfig": { "access": "public" diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4309773..b3acc37 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -3,7 +3,7 @@ import type { CloudServer, ConnectedPeer } from "@sideband/cloud"; import { listen } from "@sideband/cloud"; import { promises as fsPromises, readFileSync } from "node:fs"; -import { hostname } from "node:os"; +import { arch, hostname, platform } from "node:os"; import { loadIdentityKeyPair } from "../config.js"; import { fsDisplayName, fsMeta, registerFsHandlers } from "../handlers/fs.js"; import type { MethodMeta } from "../handlers/rpc-meta.js"; @@ -108,6 +108,10 @@ export async function runStart(args: StartArgs): Promise { daemonId: server.daemonId, name, version: cliVersion, + platform: platform(), + arch: arch(), + nodeVersion: process.version, + uptime: process.uptime(), capabilities, }; }); diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index 4c4d9f1..6240c28 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -5,12 +5,14 @@ import { emitConnected, emitDisconnected, emitEcho, + emitError, emitQcRenewed, emitReady, emitRpc, + printQr, } from "./output.js"; -/** Capture one stdout write, run fn, restore. */ +/** Capture stdout writes produced by fn. */ function captureStdout(fn: () => void): string { let captured = ""; const orig = process.stdout.write.bind(process.stdout); @@ -27,6 +29,43 @@ function captureStdout(fn: () => void): string { return captured; } +/** Capture stderr writes produced by fn. */ +function captureStderr(fn: () => void): string { + let captured = ""; + const orig = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array) => { + captured += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + return true; + }; + try { + fn(); + } finally { + process.stderr.write = orig; + } + return captured; +} + +/** Override process.stdout.columns for the duration of fn. */ +function withColumns(cols: number, fn: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + Object.defineProperty(process.stdout, "columns", { + value: cols, + configurable: true, + writable: true, + }); + try { + fn(); + } finally { + if (descriptor) { + Object.defineProperty(process.stdout, "columns", descriptor); + } else { + // @ts-expect-error restoring to undefined + process.stdout.columns = undefined; + } + } +} + /** Parse the first NDJSON line from captured output. */ function parseEvent(output: string): Record { return JSON.parse(output.split("\n")[0]!) as Record; @@ -136,4 +175,84 @@ describe("NDJSON event contract", () => { // First line must be parseable JSON — critical for scripting consumers expect(() => JSON.parse(out.split("\n")[0]!)).not.toThrow(); }); + + it("emitError writes JSON to stdout and mirrors to stderr", () => { + // Contract: stdout is authoritative for automation; stderr is a human mirror. + // Both must fire on every error so piped consumers and operators both see it. + let stdout = ""; + let stderr = ""; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + return true; + }; + process.stderr.write = (chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); + return true; + }; + try { + emitError("something went wrong"); + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } + const ev = JSON.parse(stdout.split("\n")[0]!) as Record; + expect(ev["event"]).toBe("error"); + expect(ev["message"]).toBe("something went wrong"); + expect(stderr).toContain("something went wrong"); + }); +}); + +describe("printQr width guard", () => { + const url = "https://sideband.cloud/connect#qc=abcd-efgh-ijkl"; + + it("skips rendering when terminal is too narrow", () => { + // columns=10 is always narrower than any valid QR matrix width + let out = ""; + withColumns(10, () => { + out = captureStdout(() => printQr(url)); + }); + expect(out).toBe(""); + }); + + it("renders when terminal is wide enough", () => { + // columns=200 fits any reasonable QR at this URL length + let out = ""; + withColumns(200, () => { + out = captureStdout(() => printQr(url)); + }); + expect(out).toContain("Scan to connect:"); + // Half-block characters confirm the matrix was rendered + expect(out).toMatch(/[▀▄█ ]/); + }); + + it("skips at exactly matrix-width + 1 columns (one short)", async () => { + // Compute the actual matrix width so the guard boundary is tested precisely. + // This directly validates the w + 2 formula (not a hardcoded constant). + const { default: encodeQR } = await import("qr"); + const matrix = encodeQR(url, "raw"); + const w = matrix[0]?.length ?? 0; + expect(w).toBeGreaterThan(0); + + let out = ""; + withColumns(w + 1, () => { + out = captureStdout(() => printQr(url)); + }); + expect(out).toBe(""); // one column short of required w + 2 + }); + + it("renders at exactly matrix-width + 2 columns (minimum required)", async () => { + const { default: encodeQR } = await import("qr"); + const matrix = encodeQR(url, "raw"); + const w = matrix[0]?.length ?? 0; + + let out = ""; + withColumns(w + 2, () => { + out = captureStdout(() => printQr(url)); + }); + expect(out).toContain("Scan to connect:"); + }); }); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 148a81e..2a09140 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -4,12 +4,16 @@ * Output formatting for the Sideband CLI. * * Human mode (default): indented, readable text to stdout; errors to stderr. - * JSON mode (--json): NDJSON to stdout (one JSON object per line); errors to stderr. + * JSON mode (--json): NDJSON to stdout (one JSON object per line); errors go + * to stdout as `{ event: "error" }` AND to stderr as a human-readable mirror + * — stdout is the authoritative stream for automation; stderr is best-effort. * * Activity lines use a consistent format: ` {sigil} {message} [{HH:MM:SS}]` * Sigils: + connected, - disconnected, → RPC call, ← echo reply, ⚠ warning. */ +import encodeQR from "qr"; + function timestamp(): string { return new Date().toTimeString().slice(0, 8); // HH:MM:SS } @@ -52,11 +56,44 @@ export function printReady(info: ReadyInfo): void { "", ` Quick Connect: ${info.quickConnectUrl}`, ` Code: ${info.quickConnectCode}`, - "", - " Waiting for connections...", - "", ); - process.stdout.write(lines.join("\n")); + process.stdout.write(lines.join("\n") + "\n"); + printQr(info.quickConnectUrl); + process.stdout.write("\n Waiting for connections...\n\n"); +} + +/** + * Print a QR code for the given URL to stdout. + * + * Skipped when the terminal is too narrow to fit the QR (matrix width + 2-char + * left margin) or when `encodeQR` throws — startup must not fail due to QR rendering. + * + * Renders using Unicode half-block characters (▀▄█ ) to halve the height — + * full-size QRs for URLs of this length are ~35 lines, too tall for most + * terminal windows. Works on both light and dark terminal themes without ANSI + * color codes (contrast comes from the block glyph shapes, not background color). + */ +export function printQr(url: string): void { + try { + const matrix = encodeQR(url, "raw"); + const w = matrix[0]?.length ?? 0; + // Guard after computing w: actual rendered width is w + 2 (left margin). + // A line-wrapped QR is unscannable, so skip rather than render partially. + if ((process.stdout.columns ?? 0) < w + 2) return; + const rows: string[] = ["\n Scan to connect:"]; + for (let y = 0; y < matrix.length; y += 2) { + let row = " "; + for (let x = 0; x < w; x++) { + const top = matrix[y]?.[x] ?? false; + const bot = matrix[y + 1]?.[x] ?? false; + row += top && bot ? "█" : top ? "▀" : bot ? "▄" : " "; + } + rows.push(row); + } + process.stdout.write(rows.join("\n") + "\n"); + } catch { + // Never let QR rendering abort startup + } } export function printConnected(peerId: string): void { diff --git a/packages/cloud/src/connect.ts b/packages/cloud/src/connect.ts index f5c05df..80a4f90 100644 --- a/packages/cloud/src/connect.ts +++ b/packages/cloud/src/connect.ts @@ -23,7 +23,7 @@ import type { import { classifySbrpError, relayClientNegotiator } from "@sideband/peer/sbrp"; import type { IdentityKeyStore } from "@sideband/peer/sbrp"; import type { TransportConnection } from "@sideband/transport"; -import { asDaemonId } from "@sideband/secure-relay"; +import { asDaemonId, encodePing } from "@sideband/secure-relay"; import { classifyApiError, CloudApiError, @@ -98,16 +98,20 @@ interface ConnectOptionsAccount extends ConnectOptionsCommon { export type ConnectOptions = | ((ConnectOptionsQC | ConnectOptionsAccount) & { /** - * TOFU trust policy. Defaults to `"auto"` in the cloud context because - * the control plane has already authenticated the daemon via API key at - * registration time. + * TOFU trust policy. Defaults to `"auto"` for both auth paths because + * the relay's API key auth provides a baseline trust anchor — the daemon + * identity is control-plane-registered, not arbitrary. * - * ⚠ `"auto"` weakens TOFU guarantees: it silently re-pins on identity - * mismatch, making it TOFR (Trust On First Registration) rather than - * strict TOFU. Use `"prompt"` or `"pinned-only"` for higher-assurance scenarios. + * ⚠ `"auto"` is TOFR (Trust On First Registration): it accepts any + * identity on first connect and silently re-pins on mismatch. Use + * `"pinned-only"` when you have a pre-populated `identityKeyStore` + * (it rejects immediately if no key is pinned), or `"prompt"` to let + * the user approve first connections and mismatches interactively. */ trustPolicy?: "auto" | "pinned-only"; + /** No-op unless `trustPolicy === "prompt"`. */ onFirstConnection?: (info: { fingerprint: string }) => Promise; + /** No-op unless `trustPolicy === "prompt"`. */ onIdentityMismatch?: (info: { expectedFingerprint: string; receivedFingerprint: string; @@ -211,6 +215,8 @@ export class CloudClientNegotiator { private qcRedeemed = false; /** DaemonId resolved from the QC redeem response; undefined in account path. */ private resolvedDaemonId: string | undefined; + /** Active keepalive interval — cleared by terminate() before transport close. */ + private keepalive: ReturnType | null = null; constructor(private readonly opts: ConnectOptions) {} @@ -307,10 +313,30 @@ export class CloudClientNegotiator { onFirstConnection: opts.onFirstConnection, onIdentityMismatch: opts.onIdentityMismatch, }); - return inner.negotiate(transport); + const result = await inner.negotiate(transport); + // Relay closes idle connections after 90s (liveness sweep every 30s). + // Send SBRP Ping every 45s to keep lastSeenAt fresh. Ping is sent on the + // raw transport — the relay echoes Pong and does not forward it to the daemon. + const ping = encodePing(); + // Close over `timer` (not `this.keepalive`) so the catch handler always + // clears the interval it owns — not whatever `this.keepalive` points to + // at the time the async send fails (which may be a newer connection's timer). + const timer = setInterval(() => { + transport.send(ping).catch(() => { + clearInterval(timer); + if (this.keepalive === timer) this.keepalive = null; + }); + }, 45_000); + (timer as { unref?: () => void }).unref?.(); + this.keepalive = timer; + return result; } async terminate(transport: TransportConnection): Promise { + if (this.keepalive !== null) { + clearInterval(this.keepalive); + this.keepalive = null; + } try { await transport.close(); } catch { diff --git a/packages/cloud/src/listen.ts b/packages/cloud/src/listen.ts index 35b4a8d..8d61491 100644 --- a/packages/cloud/src/listen.ts +++ b/packages/cloud/src/listen.ts @@ -40,6 +40,7 @@ import { asDaemonId, decodeControl, decodeFrame, + encodePing, encodePong, fromWireControlCode, FrameType, @@ -492,6 +493,14 @@ async function runMux( let consecutiveDecodeErrors = 0; const MAX_CONSECUTIVE_DECODE_ERRORS = 10; + // Relay liveness sweep closes connections idle for 90s (checked every 30s). + // Send SBRP Ping every 45s so the daemon's relay connection never goes idle. + const ping = encodePing(); + const keepalive = setInterval(() => { + relayConn.send(ping).catch(() => clearInterval(keepalive)); + }, 45_000); + (keepalive as { unref?: () => void }).unref?.(); + try { for await (const bytes of relayConn.inbound) { let frame: ReturnType; @@ -585,6 +594,7 @@ async function runMux( vconn.deliver(bytes); } } finally { + clearInterval(keepalive); // Relay connection closed — terminate all sessions so their inbound // iterators complete and ConnectedPeer instances transition to "closed". for (const [, conn] of sessions) conn.terminate(false);