Skip to content
Open
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
89 changes: 78 additions & 11 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,12 @@ Public PTY env:
folder. Unset under the standalone app (no workspace concept) and for an
empty VS Code window.
- `DORMOUSE_CONTROL_SOCKET` and `DORMOUSE_CONTROL_TOKEN` — private control
endpoint credentials. The token is the socket's sole authenticator, so it is
a CSPRNG value (24 random bytes, hex-encoded — `randomBytes` in the VS Code
host, the OS CSPRNG via `getrandom` in the standalone host) and the server
compares it in constant time (SHA-256 digests through `timingSafeEqual`),
never a short-circuiting string compare. Source of truth:
[`dor-control-server.js`](../../standalone/sidecar/dor-control-server.js)
(`tokenMatches`).
endpoint credentials, set together or not at all (see
[Control-channel security](#control-channel-security)). The token is the
shared secret both ends of the channel prove knowledge of, so it is a CSPRNG
value (24 random bytes, hex-encoded — `randomBytes` in the VS Code host, the
OS CSPRNG via `getrandom` in the standalone host). It is never sent on the
wire.

`DORMOUSE_CLI_BIN` is host-internal spawn configuration. Terminals should rely
on `PATH`, not on that variable.
Expand Down Expand Up @@ -162,9 +161,14 @@ exports.

`standalone/package.json` runs `pnpm stage:dor-cli` before Tauri dev/build.
Rust resolves the staged/bundled CLI paths, starts the Node sidecar with
`DORMOUSE_HOST`, `DORMOUSE_NODE`, `DORMOUSE_CLI_BIN`, `DORMOUSE_CLI_JS`,
`DORMOUSE_CONTROL_SOCKET`, and `DORMOUSE_CONTROL_TOKEN`, then the shared PTY
core prepends `DORMOUSE_CLI_BIN` and sets `DORMOUSE_SURFACE_ID` per PTY.
`DORMOUSE_HOST`, `DORMOUSE_NODE`, `DORMOUSE_CLI_BIN`, `DORMOUSE_CLI_JS`, and
`DORMOUSE_CONTROL_TOKEN`, then the shared PTY core prepends `DORMOUSE_CLI_BIN`
and sets `DORMOUSE_SURFACE_ID` per PTY. Rust does **not** set
`DORMOUSE_CONTROL_SOCKET`: the sidecar chooses the path itself and puts both
control variables back into its own `process.env` — which is what `pty-core`
merges into every spawned shell — only once the socket is bound. Until then it
holds incoming stdin commands, so no PTY can be spawned into the window where
the channel's fate is undecided.

Control direction:

Expand All @@ -184,7 +188,13 @@ dor process
`vscode-ext/package.json` runs `pnpm stage:dor-cli` before bundling the
extension host and `pty-host.js`. The extension host computes the staged CLI
paths under `context.extensionPath/dor-cli`, starts `pty-host.js`, and sends the
same dor env on each PTY spawn.
same dor env on each PTY spawn. `getDorRuntimeEnv` deliberately omits both
control variables: the token reaches `pty-host.js` through the fork env alone,
and the host pairs it with a bound socket path onto each spawn's env itself. The
`ready` message that releases the extension host's queued messages is held until
the channel has settled, so no spawn can race the bind. Source of truth:
[`pty-manager.ts`](../../vscode-ext/src/pty-manager.ts),
[`pty-host.js`](../../vscode-ext/src/pty-host.js).

`DORMOUSE_NODE` points at VS Code's own runtime (`process.execPath`, re-execed
as Node by VS Code's extension-host environment), not a user-installed Node.
Expand All @@ -207,6 +217,52 @@ Because VS Code can host multiple Dormouse webviews in one extension host, the
request includes `DORMOUSE_SURFACE_ID`; `message-router.ts` routes to the webview
that owns that surface when one is available.

### Control-channel security

The control channel carries the whole surface API — `dor send` types arbitrary
keystrokes into any pane, `dor read` returns its screen and scrollback, `dor
kill` destroys it — so the threat it defends against is another local principal
(a second account on the box, or any process running as the user) getting
between `dor` and its host. All three defences live in
[`dor-control-server.js`](../../standalone/sidecar/dor-control-server.js), which
both hosts load, and its client half in
[`control-client.ts`](../../dor/src/control-client.ts).

**The server picks the path, and picks it unguessably.** On POSIX the socket is
`<tmpdir>/dormouse-dor-<uid>/<8 random bytes>.sock`. The parent directory is
created `0700` and re-checked on every use — a real directory, not a symlink,
owned by this uid, at exactly mode `0700`; a directory of ours that is merely
loose gets tightened, anything else stands the channel down. This mirrors
`peerDirIsSafe()` in
[`peer-link.ts`](../../vscode-ext/src/peer-link.ts). 8 random bytes rather than
16 because macOS caps `sun_path` near 104 bytes and its `os.tmpdir()` spends
~50 of them. On Windows the name is `\\.\pipe\dormouse-dor-<8 random bytes>`:
the pipe namespace is machine-wide and has no directory to harden, so
unpredictability is what is left. Neither spelling derives from the PID, which
is enumerable and recycled.

**The server proves itself first.** The token is a bearer credential, so it
never goes on the wire in either direction. The server speaks first with a
challenge nonce; the client answers with `HMAC-SHA256(token, "dor-control/client
<nonce>")` and a nonce of its own; the server answers that with
`HMAC-SHA256(token, "dor-control/server <nonce>")` before the client sends any
request. A peer that fails its half gets hung up on with no reply — a wrong
answer and a port scan deserve the same nothing. Whoever merely bound the path
learns two nonces. Both sides compare proofs in constant time (SHA-256 digests
through `timingSafeEqual`), never a short-circuiting string compare.

**A lost bind is fatal to the channel, never to the host.** PTY work must
survive a dead control channel, so neither host exits — but a host that keeps
handing `DORMOUSE_CONTROL_TOKEN` to every shell it spawns is feeding clients and
tokens to whoever won the race. So the token stops at the process that owns the
server: `pty-host.js` and the sidecar delete both control variables from their
own environment on startup (`pty-core` merges `process.env` into every shell)
and put them back only when `ready` resolves. When the bind is lost — a squatted
Windows pipe name, an unsafe socket directory, a socket file that cannot be
cleared — the variables stay gone and `dor` reports the endpoint as unavailable
rather than dialling a stranger. Both hosts hold their spawn path until `ready`
settles (2s ceiling) so the first terminal cannot race the bind.

## Handle Model

Dormouse supports multiple Workspaces within one Window (`docs/specs/glossary.md`):
Expand Down Expand Up @@ -515,6 +571,17 @@ in `dor/test/cli-output.test.mjs`.

## Future

- **Surface a dead control channel in the UI.** A lost bind currently leaves one
`[dor-control]` line on the host's stderr, and the only thing a user sees is
`dor` reporting "Dormouse control endpoint is not available in this terminal
yet" — which reads like a startup race rather than a channel that will never
come up (and, on Windows, possibly a name somebody else took). It wants a
visible notice, but the two hosts have no shared place to put one, so the
design question is where: the Baseboard carries the standalone update notice
(`docs/specs/auto-update.md`) and has no VS Code counterpart. The plumbing that
would feed it exists — both hosts already know the outcome at `ready` (see
[Control-channel security](#control-channel-security)).

- **`dor skill` follow-ons** — skill-ecosystem publication (plugin
marketplaces, npm) distributes the bootstrap stub, never a copy of the
content. A user-level `--global` install variant waits until a story needs
Expand Down
121 changes: 99 additions & 22 deletions dor/src/control-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { createConnection } from 'node:net';
import type {
AgentBrowserSurfaceRequest,
Expand Down Expand Up @@ -30,6 +31,30 @@ export interface SocketControlClientOptions {
timeoutMs?: number;
}

// Must match standalone/sidecar/dor-control-server.js, the other half of this
// handshake. The two live in different packages (a bundled ESM CLI and a plain
// CJS module loaded by both hosts) with no shared build, so the constants and
// the proof construction are duplicated rather than imported.
const CLIENT_PROOF_DOMAIN = 'dor-control/client';
const SERVER_PROOF_DOMAIN = 'dor-control/server';

// Deliberately says nothing about which half of the handshake failed: from here
// a squatter, a torn-down host, and a stale socket file are the same event, and
// the user's next move is the same for all three.
const HANDSHAKE_FAILURE =
'the process holding the Dormouse control socket could not prove it is Dormouse';

function proveToken(token: string, domain: string, nonce: string): string {
return createHmac('sha256', token).update(`${domain} ${nonce}`).digest('hex');
}

function proofMatches(provided: unknown, expected: string): boolean {
if (typeof provided !== 'string') return false;
const a = createHash('sha256').update(provided).digest();
const b = createHash('sha256').update(expected).digest();
return timingSafeEqual(a, b);
}

export class SocketControlClient implements ControlClient {
private readonly socketPath: string;
private readonly token: string;
Expand Down Expand Up @@ -85,12 +110,25 @@ export class SocketControlClient implements ControlClient {
return this.request<ResolveOpenTargetResponse>(SURFACE_CONTROL_METHODS.resolveOpen, request);
}

/**
* One request over one socket, preceded by a mutual handshake.
*
* The token is a bearer credential for the whole surface-control API (`send`
* types into any pane, `read` returns its scrollback), so it never goes on the
* wire: the peer must first prove it holds the token over a nonce we did not
* choose, and we answer over a nonce it did not choose. Whoever merely bound
* the socket path learns two nonces and nothing else.
*/
private request<T>(method: SurfaceControlMethod, params: unknown): Promise<T> {
const requestId = `dor-${this.idBase}-${++this.nextRequestId}`;
return new Promise((resolve, reject) => {
const socket = createConnection({ path: this.socketPath });
let responseBuffer = '';
let settled = false;
// 'challenge' → 'welcome' → 'response': the three lines the server sends,
// in order, over one connection.
let phase: 'challenge' | 'welcome' | 'response' = 'challenge';
const nonce = randomBytes(16).toString('hex');

const settle = (callback: () => void) => {
if (settled) return;
Expand All @@ -105,39 +143,78 @@ export class SocketControlClient implements ControlClient {
}, this.timeoutMs);

socket.setEncoding('utf8');
socket.on('connect', () => {
socket.write(`${JSON.stringify({
requestId,
token: this.token,
surfaceId: this.surfaceId,
method,
params,
})}\n`);
});
// Deliberately nothing on 'connect': the server speaks first.
socket.on('data', (chunk) => {
responseBuffer += chunk;
const newlineIndex = responseBuffer.indexOf('\n');
if (newlineIndex === -1) return;
const line = responseBuffer.slice(0, newlineIndex);
settle(() => {
let newlineIndex = responseBuffer.indexOf('\n');
while (newlineIndex !== -1 && !settled) {
const line = responseBuffer.slice(0, newlineIndex);
responseBuffer = responseBuffer.slice(newlineIndex + 1);
let frame: { kind?: unknown; nonce?: unknown; proof?: unknown };
if (phase === 'response') {
settle(() => {
try {
const response = JSON.parse(line) as DorControlResult<T>;
if (response.ok) {
resolve(response.result as T);
} else {
reject(new Error(response.error || `${method} failed`));
}
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
});
return;
}
try {
const response = JSON.parse(line) as DorControlResult<T>;
if (response.ok) {
resolve(response.result as T);
} else {
reject(new Error(response.error || `${method} failed`));
frame = JSON.parse(line);
} catch {
settle(() => reject(new Error(HANDSHAKE_FAILURE)));
return;
}
if (phase === 'challenge') {
if (frame?.kind !== 'challenge' || typeof frame.nonce !== 'string' || !frame.nonce) {
settle(() => reject(new Error(HANDSHAKE_FAILURE)));
return;
}
// Answering a challenge proves nothing about the challenger, which
// is why this is all that is sent until the welcome comes back.
socket.write(`${JSON.stringify({
kind: 'hello',
nonce,
proof: proveToken(this.token, CLIENT_PROOF_DOMAIN, frame.nonce),
})}\n`);
phase = 'welcome';
} else {
if (
frame?.kind !== 'welcome' ||
!proofMatches(frame.proof, proveToken(this.token, SERVER_PROOF_DOMAIN, nonce))
) {
settle(() => reject(new Error(HANDSHAKE_FAILURE)));
return;
}
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
socket.write(`${JSON.stringify({
requestId,
surfaceId: this.surfaceId,
method,
params,
})}\n`);
phase = 'response';
}
});
newlineIndex = responseBuffer.indexOf('\n');
}
});
socket.on('error', (error) => {
settle(() => reject(error));
});
socket.on('end', () => {
if (settled) return;
settle(() => reject(new Error(`connection closed before ${method} response`)));
// A peer that drops us mid-handshake is the same event as one that
// answers it wrongly — the server hangs up on a bad hello rather than
// replying — so report it the same way.
settle(() =>
reject(new Error(phase === 'response' ? `connection closed before ${method} response` : HANDSHAKE_FAILURE)),
);
});
});
}
Expand Down
20 changes: 20 additions & 0 deletions dor/src/node-runtime.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ declare module 'node:net' {
export function createConnection(options: { path: string }): Socket;
}

declare module 'node:crypto' {
// Opaque stand-in for Buffer: this package ships without @types/node (see the
// hand-written shims around it), and nothing here needs more than "the thing
// digest() returns, which timingSafeEqual accepts".
export interface BinaryDigest {
readonly length: number;
}

export interface Hash {
update(data: string): Hash;
digest(): BinaryDigest;
digest(encoding: 'hex'): string;
}

export function createHash(algorithm: string): Hash;
export function createHmac(algorithm: string, key: string): Hash;
export function randomBytes(size: number): { toString(encoding: 'hex'): string };
export function timingSafeEqual(a: BinaryDigest, b: BinaryDigest): boolean;
}

declare module 'node:fs' {
export function existsSync(path: string): boolean;
export function readFileSync(path: string, encoding: 'utf8'): string;
Expand Down
Loading