Skip to content

Commit 00fca3e

Browse files
authored
feat(cli,cloud): QR code at startup + relay keepalive fix (#56)
1 parent 557a8fc commit 00fca3e

14 files changed

Lines changed: 351 additions & 32 deletions

File tree

‎.changeset/cli-qr-code.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"sideband": patch
3+
---
4+
5+
Print a QR code in the terminal at daemon startup alongside the Quick Connect URL.
6+
7+
- Renders below the URL/code block using Unicode half-block characters (`▀▄█ `) — halves
8+
the height vs full-size QR, works on light and dark terminals without ANSI color codes
9+
- Skipped when the terminal is too narrow to fit the QR without line-wrapping (dynamic
10+
guard based on actual matrix width + 2-char left margin)
11+
- Silently skipped on error — QR rendering never blocks startup
12+
- Not reprinted on QC renewal (would spam the terminal); only URL + code are shown
13+
- Zero new transitive dependencies (`qr` package, 0 deps, 7 KB gzipped)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@sideband/cloud": patch
3+
---
4+
5+
Fix relay keepalive lifecycle on both client and daemon sides.
6+
7+
- **Daemon (`listen`):** add SBRP Ping every 45s in `runMux` to prevent the relay from
8+
closing idle daemon connections (relay sweeps at 90s; cleared in `finally` on disconnect)
9+
- **Client (`connect`):** fix keepalive timer race — capture interval handle in a local
10+
`const timer` so a stale in-flight `send` failure from a previous connection cannot clear
11+
the active connection's timer; `terminate()` now clears explicitly before transport close

‎bun.lock‎

Lines changed: 16 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/adr/017-cloud-sdk-design.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ stale even if the daemon ID was omitted from `ListenOptions`.
133133

134134
- ADR-013: Peer SDK Core Design Decisions
135135
- ADR-016: Relay Server Design
136+
- ADR-019: Cloud SDK Trust Policy Defaults
136137
- `packages/runtime/src/session/types.ts` — `Negotiator`, `NegotiatorConnectionParams`
137138
- `packages/cloud/src/connect.ts` — `CloudClientNegotiator`
138139
- `packages/cloud/src/listen.ts` — `RelayDaemonTransport`, `RelayVirtualConn`, `runMux`, `CloudServer`
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# ADR-019: Cloud SDK Trust Policy Defaults
2+
3+
- **Date**: 2026-03-08
4+
- **Status**: Accepted
5+
- **Affects**: SDK
6+
7+
## Context
8+
9+
`@sideband/cloud`'s `connect()` wraps `relayClientNegotiator`, which requires a `trustPolicy`
10+
telling it how to handle first connections and identity mismatches. Three policies exist:
11+
12+
- `"auto"` — accept and pin on first connection; silently re-pin on identity mismatch (TOFR)
13+
- `"pinned-only"` — require a pre-existing pin; hard-reject on mismatch or if the store is empty
14+
- `"prompt"` — invoke caller-provided callbacks on first connection and on mismatch (full TOFU)
15+
16+
The cloud relay authenticates daemons at registration time via API key — the control plane holds
17+
the daemon's `identityPublicKey` from `daemon.register`. A caller connecting via `quickConnectCode`
18+
has an additional trust proof: the QC code itself proves the operator intentionally shared access.
19+
20+
## Decision
21+
22+
**Default: `"auto"` for both auth paths.**
23+
24+
- **Quick Connect**: `"auto"` is correct. The QC code IS the out-of-band trust proof —
25+
prompting for a fingerprint immediately after entry adds friction without security benefit.
26+
On identity mismatch, re-pinning is intentional: a fresh QC code proves the operator
27+
regenerated access, covering the common "wiped `~/.sideband/`" case.
28+
29+
- **Account path**: `"auto"` is a pragmatic interim default. The intended future default is
30+
`"pinned-only"` verified against a control-plane-provided `expectedFingerprint` from
31+
`daemon.listAccessible`. That fingerprint is not yet plumbed through the current API, so
32+
`"pinned-only"` would reject first-time account connections against an empty store.
33+
`"auto"` keeps the account path functional until `expectedFingerprint` is available.
34+
35+
The relay's API key authentication provides a meaningful baseline: only registered daemons can
36+
receive SBRP sessions. `"auto"` adds TOFR (Trust On First Registration) on top — weaker than
37+
strict TOFU but not unconditional trust.
38+
39+
**Future direction**: when `daemon.listAccessible` provides `identityPublicKey`, the account
40+
path will default to `"pinned-only"` using the CP-provided fingerprint — silent verification
41+
with strong identity guarantees. A key-rotation fallback (prompt on mismatch) will handle the
42+
`~/.sideband/` wipe case. When that lands, this ADR should be superseded.
43+
44+
**Callback no-op rule**: `onFirstConnection` and `onIdentityMismatch` are accepted by the
45+
type system for all policies but only invoked when `trustPolicy === "prompt"`. Passing them
46+
with `"auto"` or `"pinned-only"` has no effect.
47+
48+
## Invariants
49+
50+
- `"pinned-only"` MUST NOT be used as a default without a pre-populated identity store or
51+
a CP-provided expected fingerprint — it rejects immediately on an empty store.
52+
- Callbacks MUST be required at the type level when `trustPolicy: "prompt"` (enforced via
53+
discriminated union in `ConnectOptions`).
54+
- The trust policy used by `CloudClientNegotiator.negotiate()` is always the caller-supplied
55+
value or the default computed at negotiate-time — never cached across reconnects.
56+
57+
## Alternatives Considered
58+
59+
**Auth-mode-aware default (QC → `"auto"`, account → `"pinned-only"`)**
60+
Rejected: `"pinned-only"` fails on first account connection — the pin is established by the
61+
first successful connection, so an empty store at that point silently breaks the happy path.
62+
63+
**Default to `"prompt"` on account path**
64+
Rejected: the type system mandates callbacks for `"prompt"` mode, forcing every caller to
65+
supply them even without interactive trust management intent.
66+
67+
## References
68+
69+
- `packages/cloud/src/connect.ts` — `ConnectOptions`, `CloudClientNegotiator.negotiate()`
70+
- `packages/peer/src/negotiator/client.ts` — `relayClientNegotiator`, `trustPolicy` implementation
71+
- ADR-017: Cloud SDK Design (auth modes, `getConnectionParams`)

‎docs/adr/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This section documents significant architectural decisions for the Sideband proj
2424
| [016](./016-relay-server-design.md) | Relay Server Design | Accepted |
2525
| [017](./017-cloud-sdk-design.md) | Cloud SDK Design | Accepted |
2626
| [018](./018-daemon-capabilities.md) | Daemon Built-in Capabilities | Accepted |
27+
| [019](./019-cloud-sdk-trust-policy.md) | Cloud SDK Trust Policy Defaults | Accepted |
2728

2829
## Creating a new ADR
2930

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"@changesets/cli": "^2.30.0",
2424
"@eslint/js": "^10.0.1",
2525
"@types/bun": "^1.3.10",
26-
"eslint": "^10.0.2",
26+
"eslint": "^10.0.3",
2727
"gh-pages": "^6.3.0",
2828
"husky": "^9.1.7",
2929
"jiti": "^2.6.1",

‎packages/cli/README.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ sideband --version # print version
2929
sideband --help # show help
3030
```
3131

32-
```
32+
```text
3333
$ npx sideband
3434
3535
Sideband daemon running
@@ -39,6 +39,14 @@ $ npx sideband
3939
Quick Connect: https://sideband.cloud/connect#qc=abcd-efgh-ijkl
4040
Code: abcd-efgh-ijkl
4141
42+
Scan to connect:
43+
████████████████████████████████
44+
██ ▄▄▄▄▄ █▀▄ █▄▀▄▀▄▄▀ █ ▄▄▄▄▄ ██
45+
██ █ █ █▀▀▄█▄▄█▄▀ ▀▄█ █ █ ██
46+
██ █▄▄▄█ █▀▄▀▄▀▀▄▀▄▄▄▀█ █▄▄▄█ ██
47+
██▄▄▄▄▄▄▄█▄█ █▄▀▄▄█ █ █▄▄▄▄▄▄▄██
48+
████████████████████████████████
49+
4250
Waiting for connections...
4351
4452
+ Connected (8f3kN2p1) [12:34:05]
@@ -47,6 +55,10 @@ $ npx sideband
4755
- Disconnected (8f3kN2p1) [12:34:12]
4856
```
4957

58+
A QR code is rendered below the Quick Connect URL when the terminal is wide enough to fit
59+
it without line-wrapping (skipped silently otherwise). In `--json` mode, QR rendering is
60+
disabled — use the `quickConnectUrl` field from the `ready` event instead.
61+
5062
## API key resolution
5163

5264
Highest priority wins:
@@ -79,7 +91,9 @@ Two methods are always available under the reserved `$sideband/` namespace:
7991
{"event":"error","message":"Quick Connect renewal failed: rate limited"}
8092
```
8193

82-
Errors are written to stderr in both modes.
94+
In human mode, errors go to stderr. In `--json` mode, errors are emitted as
95+
`{ event: "error" }` to stdout (authoritative for automation) and also written to stderr
96+
as a human-readable mirror.
8397

8498
## Quick Connect renewal
8599

‎packages/cli/package.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
"rpc"
2626
],
2727
"type": "module",
28+
"types": "./dist/index.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./dist/index.d.ts",
32+
"default": "./dist/index.js"
33+
},
34+
"./package.json": "./package.json"
35+
},
2836
"engines": {
2937
"node": ">=22"
3038
},
@@ -36,7 +44,8 @@
3644
],
3745
"dependencies": {
3846
"@sideband/cloud": "workspace:^",
39-
"@sideband/secure-relay": "workspace:^"
47+
"@sideband/secure-relay": "workspace:^",
48+
"qr": "^0.5.5"
4049
},
4150
"publishConfig": {
4251
"access": "public"

‎packages/cli/src/commands/start.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { CloudServer, ConnectedPeer } from "@sideband/cloud";
44
import { listen } from "@sideband/cloud";
55
import { promises as fsPromises, readFileSync } from "node:fs";
6-
import { hostname } from "node:os";
6+
import { arch, hostname, platform } from "node:os";
77
import { loadIdentityKeyPair } from "../config.js";
88
import { fsDisplayName, fsMeta, registerFsHandlers } from "../handlers/fs.js";
99
import type { MethodMeta } from "../handlers/rpc-meta.js";
@@ -108,6 +108,10 @@ export async function runStart(args: StartArgs): Promise<void> {
108108
daemonId: server.daemonId,
109109
name,
110110
version: cliVersion,
111+
platform: platform(),
112+
arch: arch(),
113+
nodeVersion: process.version,
114+
uptime: process.uptime(),
111115
capabilities,
112116
};
113117
});

0 commit comments

Comments
 (0)