Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/cli-qr-code.md
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions .changeset/cloud-relay-keepalive.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 16 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/adr/017-cloud-sdk-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
71 changes: 71 additions & 0 deletions docs/adr/019-cloud-sdk-trust-policy.md
Original file line number Diff line number Diff line change
@@ -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`)
1 change: 1 addition & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ sideband --version # print version
sideband --help # show help
```

```
```text
$ npx sideband

Sideband daemon running
Expand All @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -36,7 +44,8 @@
],
"dependencies": {
"@sideband/cloud": "workspace:^",
"@sideband/secure-relay": "workspace:^"
"@sideband/secure-relay": "workspace:^",
"qr": "^0.5.5"
},
"publishConfig": {
"access": "public"
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -108,6 +108,10 @@ export async function runStart(args: StartArgs): Promise<void> {
daemonId: server.daemonId,
name,
version: cliVersion,
platform: platform(),
arch: arch(),
nodeVersion: process.version,
uptime: process.uptime(),
capabilities,
};
});
Expand Down
Loading