From 0775a1954d33b396ba5af7d8c2b1d7bbfdf830c7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 10:51:08 -0700 Subject: [PATCH 1/5] Publish a fresh peer-link pipe name per bind on Windows On Windows the VS Code peer link no longer binds a pipe name derived from the storage path, which another account could create first. Every bind takes a fresh random name, published by an exclusive create of burrow.peer-endpoint in the extension's storage; windows find the broker only through that file. A published bind is believed only once the file still names it, and a name that twice fails to yield a verified broker is retired, so a dead broker's name taken by someone else holds nobody off. The broker also listens on the old derived name so windows on an older build join it rather than electing a second Burrow. Unix keeps bind-as-lease on its derived path in the per-user directory. Co-Authored-By: Claude Opus 5.5 --- .github/audit/local.md | 4 +- docs/specs/security-local.md | 12 +- docs/specs/security.md | 4 +- docs/specs/vscode.md | 15 +- docs/specs/vscode.rationale.md | 6 + scripts/security-rule-ids.json | 2 +- scripts/spec-word-budgets.json | 4 +- vscode-ext/src/peer-endpoint.ts | 24 ++ vscode-ext/src/peer-link.ts | 247 ++++++++++++++++++-- vscode-ext/test/peer-endpoint.test.ts | 324 ++++++++++++++++++++++++++ vscode-ext/test/peer-link.test.ts | 46 +--- 11 files changed, 616 insertions(+), 72 deletions(-) create mode 100644 vscode-ext/src/peer-endpoint.ts create mode 100644 vscode-ext/test/peer-endpoint.test.ts diff --git a/.github/audit/local.md b/.github/audit/local.md index 886978966..e0ed2052c 100644 --- a/.github/audit/local.md +++ b/.github/audit/local.md @@ -14,13 +14,13 @@ For `## Network policy`, read `docs/specs/remote-network.md` -> "Policy", then ` For `## Terminal context directory actions`, read `docs/specs/terminal-context.md` and `docs/specs/layout.md` -> "Workspace names"; for `## Dor Tool configuration`, `docs/specs/dor-tool.md` -> "Declaring tools", "Trust", and "Serving"; then the code each section's `Source of truth` names. -For the rest of `docs/specs/security-local.md`, read each section's owner first — `docs/specs/terminal-escapes.md` and the owners its rows name, `docs/specs/dor-tool.md` -> "Reaping", "Terminal links", and "Opening local files", `docs/specs/dor-browser.md` -> "Automated Browser", "Iframe Renderer", and "Iframe Host Capability And CSP", `docs/specs/dor-cli.md` -> "Spawning External Binaries" and "Host Plumbing", `docs/specs/vscode.md` -> "Webview message authentication", `docs/specs/standalone.md` -> "Persistence", `docs/compatible-agents.md` -> "Recovery contract (maintainers)" — then the parser, the iframe shim, the control-socket code, the persistence paths they point at, and the files the checklist below names. +For the rest of `docs/specs/security-local.md`, read each section's owner first — `docs/specs/terminal-escapes.md` and the owners its rows name, `docs/specs/dor-tool.md` -> "Reaping", "Terminal links", and "Opening local files", `docs/specs/dor-browser.md` -> "Automated Browser", "Iframe Renderer", and "Iframe Host Capability And CSP", `docs/specs/dor-cli.md` -> "Spawning External Binaries" and "Host Plumbing", `docs/specs/vscode.md` -> "Webview message authentication" and "Burrow: a service in the extension host", `docs/specs/standalone.md` -> "Persistence", `docs/compatible-agents.md` -> "Recovery contract (maintainers)" — then the parser, the iframe shim, the control-socket code, the persistence paths they point at, and the files the checklist below names. ## Work streams Delegate only by these streams (`.github/audit/_preamble.md` -> "Work streams"). Each holds every rule under the headings it names, subheadings included: -- `local-terminal` — `docs/specs/security-local.md`: "Terminal output", "Browser panes", "The dor control socket", "Persisted state", "Terminal context directory actions", "Spawned programs", "Dor Tool configuration". +- `local-terminal` — `docs/specs/security-local.md`: "Terminal output", "Browser panes", "The dor control socket", "The VS Code peer link", "Persisted state", "Terminal context directory actions", "Spawned programs", "Dor Tool configuration". - `local-listeners` — `docs/specs/security-local.md`: "Loopback Listeners", "Local-file viewer", "Network policy". - `qualitative` — the qualitative pass below, once, over the whole of this domain's scope. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index eac365411..f5c7efca2 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -83,6 +83,16 @@ The socket bounds other local accounts, never a process running as the user (`do - **FAIL IF** `S139` `ensureControlDir` in `standalone/sidecar/dor-control-server.js` stops requiring all four of a real directory, not a symlink, owned by this uid, at exactly mode `0700`, or `resolveControlSocketPath` stops refusing to name a socket when that predicate fails or derives a socket or pipe name from anything but fresh random bytes, the PID included. Pinned by `standalone/sidecar/dor-control-server.test.js`. - **FAIL IF** `S140` the raw token reaches a socket, or either side compares a proof with anything but the SHA-256-then-`timingSafeEqual` of `proofMatches`. The construction is hand-mirrored between `standalone/sidecar/dor-control-server.js` and `dor/src/control-client.ts`; only the two proof domains are pinned across the copies, by `lib/src/lib/mirrored-constants.test.ts` (rationale). +## The VS Code peer link + +| Guarantee | Feature | Pinned by | +| --- | --- | --- | +| **Another Windows account cannot keep VS Code's windows from their Burrow by taking a pipe name first.** Each broker binds a name nobody can predict and publishes it only in your own storage, and a name that cannot prove the shared secret is abandoned. | One-time link, Hosted phone, Self-hosted Relay | `vscode-ext/test/peer-endpoint.test.ts` | + +The attacker is another local account; the link carries the Burrow's commands and every window's terminals between VS Code windows (`docs/specs/vscode.md` -> "Burrow: a service in the extension host"). + +- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name that twice failed to yield a verified welcome, or anything but a verified broker on the derived name older builds bind changes a window's role. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`. + ## Loopback Listeners | Guarantee | Feature | Pinned by | @@ -164,7 +174,7 @@ The attacker is another local account reading disk; what the remote stack leaves **VS Code persists pane structure in VS Code's own storage** — `workspaceState` and `vscode.setState()` — so the modes there are VS Code's, not ours, and no transcript reaches either (`docs/specs/vscode.md` -> "Serialization and restore"). Dormouse also writes `recovery.json` in extension storage, mode `0600` on Unix: one rebuilt agent-resume invocation per Surface, no buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record"). -**The VS Code peer-link token is a local credential at rest** — `burrow.peer-token` in the extension's global storage, written mode `0600`, its socket directory re-checked on every contention round. Neither control does anything on Windows, and Dormouse applies no Windows DACL to the peer-link token, `recovery.json`, or the `tool-trust` receipts (`docs/specs/security.md` -> "Known gaps"; rationale). +**The VS Code peer-link token is a local credential at rest** — `burrow.peer-token` in the extension's global storage, written mode `0600`, its socket directory re-checked on every contention round; a Windows broker's `burrow.peer-endpoint` beside it is written the same way. Neither control does anything on Windows, and Dormouse applies no Windows DACL to the peer-link token or endpoint, `recovery.json`, or the `tool-trust` receipts (`docs/specs/security.md` -> "Known gaps"; rationale). No standalone log call (`docs/specs/standalone.md` -> "Logging") carries PTY bytes, but the log records the `dor` socket path (rationale). diff --git a/docs/specs/security.md b/docs/specs/security.md index dfa0daf4f..3d910c10c 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)). - **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)). -- **Another Windows account can take the VS Code peer-link pipe's name first.** Every window must bind the same name, so it is derived, not random, and Windows lists every pipe to every account; whoever creates it first keeps remote control and peer surfaces down, though the token handshake serves them nothing ([Persisted state](./security-local.md#persisted-state)). +- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can take the pipe name older VS Code builds bind.** A window still on such a build then reaches no broker until it updates, and a current broker waits out one handshake on that name before serving ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)). - **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)). - **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)). - **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). @@ -72,7 +72,7 @@ Gaps rather than accepted risks: we intend to close them. - **Browser-pane scripts share loopback cookies across grant ports.** HTTP and WebSocket cookie headers are stripped, but `document.cookie` remains shared; cookie-authenticated iframe pages are unsupported ([Loopback Listeners](./security-local.md#loopback-listeners)). - **Windows screenshots and pasted clipboard images are only as private as the temp directory they land in:** private by default, exposed if it is shared or loosened ([Browser panes](./security-local.md#browser-panes)). -- **Neither VS Code's peer-link token, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)). +- **Neither VS Code's peer-link token and endpoint, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)). - *One-time link, Hosted phone, Self-hosted Relay.* **No independent third party has reviewed remote control's cryptography.** Dormouse's Noise integration, passkey (WebAuthn) channel binding, key storage, and push sealing are checked only by this project's own tests, lints, and nightly audits, which AI agents run ([Noise suite](./remote-security-model.md#noise-suite)). - *Self-hosted Relay.* **Anyone who can reach your Relay can hold off sign-in by flooding it**: unauthenticated requests share the capped pool of sign-in challenges, evicting a real one before its passkey prompt is answered. The default install admits only your tailnet ([Network posture (self-hosted)](./security-remote.md#network-posture-self-hosted)). - *Hosted phone, Self-hosted Relay.* **A user-verification demand raised after enrollment can be lost if this computer cannot save it.** The Burrow keeps the raise in memory and retries the save at each start, but a relaunch while saving keeps failing starts it at presence until the Relay repeats the demand ([Trust boundary](./security-remote.md#trust-boundary)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 1d8e462f2..cc82f5ead 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -135,16 +135,23 @@ The shared `BurrowService` (`docs/specs/relay.md` → "Burrow side", which owns Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `markOneTimeServing` and `contendIfServing` in `vscode-ext/src/burrow.ts`. -**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path — `.sock` in a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. +**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). On unix every contending window binds one fixed path, `.sock` in a per-user `dormouse-peer-` directory in the temp dir; **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. - **Roles never flip downward**: no `onRole(false)` after a `true` (rationale). - **Contend on broker death, not on a timer**: `bind` picks exactly one; no TTL, heartbeat file, or watcher. - **Never unlink on the first refusal; a reclaimed bind is re-checked (`stillOurs`) before it serves** (rationale). - **A bind is not a role until it is believed**: **an unverified bind reads as unsettled**, and a command landing mid-verification is held for the verdict (rationale). -**Trust.** The socket path is derived, not secret, so two layers stand between it and this installation's terminals. +**Windows: a published endpoint**, since another account could create a derived pipe name first (rationale): -*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory and skip this layer (`docs/specs/security.md` → "What is not defended"). +- **Every bind takes a fresh `\\.\pipe\dormouse-peer-<32 random hex>`**, published by an exclusive `wx` create of `burrow.peer-endpoint` beside the token. **Windows find the broker only through that file**; one that loses the create closes its pipe and dials the winner. +- **Every published bind is provisional**, believed only if the file still names it after `RECLAIM_VERIFY_MS`: a window retiring a stale file can remove a fresh one. +- **A published name that twice fails to yield a verified welcome, a jittered re-read apart, is retired** whatever the failure, and only while the file still names it (rationale); a closing broker retracts its own the same way. +- **The broker also listens on the derived `\\.\pipe\dormouse-peer-`**, so an older build's window joins it rather than electing a second Burrow (rationale). If that name is taken, only a verified broker there makes this window retract its file and join it. + +**Trust.** Neither the socket path nor a listed pipe name is secret, so two layers stand between them and this installation's terminals. + +*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory; the published endpoint stands in for it (`docs/specs/security-local.md` → "The VS Code peer link"). *The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out with a bounded retry (rationale); **exhausting that wait latches the same permanent stand-down** as an unsafe directory. The token **never crosses the socket**; three frames prove mutual knowledge of it: @@ -160,7 +167,7 @@ Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, ` **A window with no Burrow at all still answers the read-only commands (`idleAnswer`) exactly as an idle service would**; the policy is read and never saved. The idle `status` reads the installer's offer file, so the one-click card renders on a machine no window has a Burrow for. **Everything else refuses with an error**, so the console hook fails fast. -Source of truth: `vscode-ext/src/burrow.ts`, `ensurePeerNet` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts` and `vscode-ext/test/peer-link.test.ts`. +Source of truth: `vscode-ext/src/burrow.ts`, `ensurePeerNet` and `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts`, `vscode-ext/test/peer-link.test.ts`, and `vscode-ext/test/peer-endpoint.test.ts`. **The webview bridge.** A webview reaches the service over `BurrowLink`, implemented in `vscode-adapter.ts` on three messages, each wrapping the shared client's shape in a `payload`: `burrow:command { payload: BurrowCommand }` out, `burrow:result { payload: BurrowResult }` and `burrow:event { payload }` back. **Everything else is the shared client** in `lib/src/host/remote/link-client.ts` (`docs/specs/transport.md` → Message protocol). Results are **broadcast to every webview in the window** — safe only because the shared client mints globally unique ids — and one correlation id serves both the in-window fan-out and the cross-window forward. diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index 2cc011d45..6532b3450 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -66,6 +66,12 @@ **What squatting the socket path buys.** One HMAC over a nonce the squatter chose — which is not the token — and nothing else. +**Why Windows publishes its endpoint.** With a derived pipe name, another account could create it before any window bound it after a restart, and every window's bind then failed: remote control and peer surfaces stayed down. Keying the name by the token does not help: the token and storage path are permanent, so the keyed name is fixed after its first bind, and Windows lists it to every account. A name minted per bind cannot be created in advance, and once bound, libuv's first-instance flag and the pipe's default DACL keep another account from adding an instance of it. + +**Why a published name is retired on any failure, not only a refusal.** A dead broker's name is free again and was listed while it lived, so another account can bind it as the broker goes. Under unix's refusal-only rule every window would then retry it forever: the same denial, moved from boot to the first broker restart. The cost is that a live broker that cannot finish a handshake within `HANDSHAKE_BUDGET_MS`, twice, is replaced, and the two Burrows displace each other until the hung one recovers. + +**Why the broker holds the derived name too.** VS Code keeps running an extension's old version in each window until that window restarts its extensions, so after an update windows of both builds share one installation. An older-build window that found the derived name free would bind it and run a second Burrow under the same enrollment. A squatter on that name costs a current broker one `HANDSHAKE_BUDGET_MS` dial before it is believed, and keeps older-build windows from any broker (`docs/specs/security.md` -> "What is not defended"). + **Why a mid-contention command waits rather than being refused.** While the contention runs the window is neither broker nor client, and a bind plus a handshake is not instant. Refusing there would tell an enrolled machine's webview it has no Burrow seconds before it gets one, and the gates that arm on that answer (`enrolled-gate.ts`) would stay down. **Where the `WebSocket` boundary falls.** `globalThis.WebSocket` arrived in Node 22, and VS Code 1.92 — the floor `engines.vscode` declares — shipped Node 20.14 (its release notes, July 2024), so an older extension host has no global to use. diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index 05a21a8bc..f0289c602 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 274, + "next": 275, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0fa7257e3..c7b9656f3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/security-audit.md": 4750, "docs/specs/security-ci.md": 3400, "docs/specs/security-hosted.md": 3050, - "docs/specs/security-local.md": 5900, + "docs/specs/security-local.md": 6100, "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, "docs/specs/security.md": 2750, @@ -40,7 +40,7 @@ "docs/specs/tiling-engine.md": 2500, "docs/specs/transport.md": 4450, "docs/specs/tutorial.md": 1750, - "docs/specs/vscode.md": 4300, + "docs/specs/vscode.md": 4500, "docs/specs/webgl-text.md": 750, "docs/specs/website-docs.md": 3100 } diff --git a/vscode-ext/src/peer-endpoint.ts b/vscode-ext/src/peer-endpoint.ts new file mode 100644 index 000000000..2e27120c3 --- /dev/null +++ b/vscode-ext/src/peer-endpoint.ts @@ -0,0 +1,24 @@ +/** + * The two platform facts the peer link's arbitration turns on, in a module of + * their own so `vscode-ext/test/peer-endpoint.test.ts` can run Windows' + * published-endpoint arbitration over unix sockets. The contract lives in + * `docs/specs/vscode.md` → "Burrow: a service in the extension host". + */ + +import { randomBytes } from 'node:crypto'; + +/** + * Whether the broker binds a fresh name per bind and publishes it in the + * extension's storage, rather than binding one derived path. Windows only: its + * named pipes live in one namespace every account can list and create in, so a + * derived name could be created first by another account, while unix keeps its + * derived path in a directory only this user can write. + */ +export function publishesEndpoint(): boolean { + return process.platform === 'win32'; +} + +/** A pipe name nobody can predict: 128 CSPRNG bits, minted per bind. */ +export function freshEndpointName(): string { + return `\\\\.\\pipe\\dormouse-peer-${randomBytes(16).toString('hex')}`; +} diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 62fb7ec79..0a5fccb95 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -1,8 +1,8 @@ /** * Cross-window broker/client transport for the Burrow. The bind-as-lease, - * monotone-role, and mutual-handshake contracts live in `docs/specs/vscode.md` - * → "Burrow: a service in the extension host"; routing in "Peer surfaces across - * windows". + * published-endpoint, monotone-role, and mutual-handshake contracts live in + * `docs/specs/vscode.md` → "Burrow: a service in the extension host"; routing + * in "Peer surfaces across windows". */ import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; @@ -35,6 +35,7 @@ import { type PeerLinkResponse, type PeerLinkWelcome, } from './peer-link-protocol'; +import { freshEndpointName, publishesEndpoint } from './peer-endpoint'; import type { PtySink } from './processed-pty-streams'; import { log } from './log'; @@ -95,6 +96,12 @@ export function configurePeerLink(next: PeerLinkDeps): void { } const TOKEN_FILE = 'burrow.peer-token'; +/** + * Where a Windows broker publishes the pipe name it bound, beside the token and + * under the same protection: only this user can write it, so nobody else can + * point this installation's windows somewhere or force a second election. + */ +const ENDPOINT_FILE = 'burrow.peer-endpoint'; /** * How long a window losing the exclusive create will wait for the winner's @@ -146,8 +153,8 @@ function peerDirPath(): string { * stands the peer link down for good rather than spinning against it. * * Windows named pipes are not filesystem objects, so there is no directory - * here to check; the squat that leaves open is recorded in - * `docs/specs/security.md` → "What is not defended". + * here to check; a fresh name per bind, published only in this user's storage + * ({@link attemptPublished}), stands in for it. * * The same predicate is duplicated as `ensureControlDir()` in * standalone/sidecar/dor-control-server.js (sync fs, returns the directory @@ -179,12 +186,16 @@ async function peerDirIsSafe(): Promise { } /** - * The one path every window of this installation contends for. + * The one path every window of this installation contends for on unix. * * Hashed rather than joined: macOS caps a unix socket path near 104 bytes and * the extension's globalStorage path is most of that on its own. Derived from * that path rather than random precisely because it must be *the same* in every * window — the bind is the arbitration. + * + * On Windows this is only the name older builds bind: a broker there holds it + * as a second listener so their windows join it ({@link olderBuildHoldsLegacy}), + * and never depends on getting it, because another account can create it first. */ function socketPath(): string | null { if (!context) return null; @@ -257,6 +268,66 @@ async function ensureToken(): Promise { } } +function endpointPath(): string | null { + return context ? join(context.globalStorageUri.fsPath, ENDPOINT_FILE) : null; +} + +/** + * The pipe name the broker published: `null` when there is no file, otherwise + * its trimmed contents. An empty file is waited out as *not yet written*, like + * the token, since the exclusive create lands before its bytes; one that stays + * empty, or cannot be read, comes back as `''` — a name nothing answers on, so + * the caller retires it like any dead broker's. + */ +async function readEndpoint(): Promise { + const path = endpointPath(); + if (!path) return null; + for (let attempt = 1; ; attempt++) { + const raw = await readFile(path, 'utf8').then( + (text) => text.trim(), + (error: NodeJS.ErrnoException) => (error.code === 'ENOENT' ? null : ''), + ); + if (raw !== '' || attempt >= TOKEN_WRITE_ATTEMPTS) return raw; + await delay(TOKEN_WRITE_POLL_MS); + } +} + +/** + * Publish this window's freshly bound name, exclusively: `false` when another + * window's file is already there, which makes that window the one to dial. + * 0600 for the same reason as the token; on Windows the extension's storage + * under the user profile is what keeps other accounts out. + */ +async function publishEndpoint(name: string): Promise { + const path = endpointPath(); + if (!path) return false; + try { + await writeFile(path, name, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } +} + +/** + * Remove the endpoint file if it still says `name`, reporting whether it is + * gone. Compared first so a window retiring a dead broker's name never removes + * a live one's that replaced it; the gap between the read and the unlink is + * what {@link attemptPublished}'s verification covers. + */ +async function retractEndpoint(name: string): Promise { + const path = endpointPath(); + if (!path) return false; + if ((await readEndpoint()) !== name) return true; + try { + await rm(path); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; + } +} + // ---------------------------------------------------------------- server side /** @@ -277,17 +348,29 @@ export interface PeerLinkClient { let server: Server | null = null; +/** + * Windows only: the second listener on the name older builds bind, so their + * windows join this broker instead of electing their own + * ({@link olderBuildHoldsLegacy}). Closed with `server`. + */ +let legacyServer: Server | null = null; +/** + * Windows only: the fresh name `server` is bound to, once this window has bound + * one — what it publishes, verifies, and retracts. Cleared with `server`. + */ +let boundEndpoint: string | null = null; /** * Whether the bind in `server` has been *believed*. * * A reclaimed bind is provisional: `stillOurs` spends 250 ms watching for a * competing window that cleared the same corpse and bound after us, and the - * loser stands down through `closeServer(false)`. Between the bind and that - * verdict `server !== null` is true while this window may be about to give the - * socket up, so every *role* answer reads this instead — otherwise an enroll or - * a secrets-change landing inside that window is told it is the broker, starts - * a service, and the stand-down never tears it down: two Burrows under one burrowId, - * displacing each other on the relay forever. + * loser stands down through `closeServer(false)`; on Windows every published + * bind is provisional the same way ({@link attemptPublished}). Between the bind + * and that verdict `server !== null` is true while this window may be about to + * give the socket up, so every *role* answer reads this instead — otherwise an + * enroll or a secrets-change landing inside that window is told it is the + * broker, starts a service, and the stand-down never tears it down: two Burrows + * under one burrowId, displacing each other on the relay forever. */ let brokerConfirmed = false; /** Claimed and cleared with `server`; the two always move together. */ @@ -770,9 +853,9 @@ export function listenServer(nextServer: Server, path: string): Promise { }); } -/** Take the socket path, or report that somebody else holds it. */ -async function tryBind(path: string, token: string): Promise { - const nextServer = createServer((socket) => { +/** A listener that challenges every connection it accepts. */ +function createPeerServer(): Server { + return createServer((socket) => { const client: PeerLinkClient = { socket, decoder: new FrameDecoder(), @@ -796,8 +879,13 @@ async function tryBind(path: string, token: string): Promise { // of the token must not volunteer one into whatever bound this path. send(client, { kind: 'challenge', nonce: client.challenge }); }); +} + +/** Listen on `path`, or close the server and report that somebody else holds it. */ +async function listenOrClose(nextServer: Server, path: string): Promise { try { await listenServer(nextServer, path); + return true; } catch { // Callback form deliberately: closing a server that never listened emits an // `'error'` nobody is listening for, which an EventEmitter rethrows and @@ -805,16 +893,22 @@ async function tryBind(path: string, token: string): Promise { nextServer.close(() => {}); return false; } +} + +/** Take the socket path, or report that somebody else holds it. */ +async function tryBind(path: string, token: string): Promise { + const nextServer = createPeerServer(); + if (!(await listenOrClose(nextServer, path))) return false; server = nextServer; // Synchronous, and before the first `await` past the bind: `listen` binds // inside the call and resolves on a nextTick, so no thread-pool callback of // *this* process can interleave. A competing window is a separate extension // host, so its unlink and rebind can still land here — this narrows the gap // to a few microseconds of straight-line code rather than closing it, and - // reads the file our own bind made ({@link boundSocketFile}). Skipped on - // Windows: a named pipe is not a filesystem object, so there is no anchor - // to take and nothing there can displace us ({@link stillOurs}). - boundSocketFile = process.platform === 'win32' ? null : socketFileIdentitySync(path); + // reads the file our own bind made ({@link boundSocketFile}). Skipped where + // the endpoint is published: that name is ours alone, and what can be + // displaced there is the file naming it ({@link attemptPublished}). + boundSocketFile = publishesEndpoint() ? null : socketFileIdentitySync(path); // Provisional until the caller settles it: a reclaimed bind may still be // displaced (see {@link brokerConfirmed}). brokerConfirmed = false; @@ -1198,6 +1292,8 @@ async function attempt(): Promise { return true; } + if (publishesEndpoint()) return attemptPublished(path, token); + if (await tryBind(path, token)) { // Disposal can land inside any of the awaits above; a socket bound after it // would outlive the window that owns it. @@ -1259,6 +1355,106 @@ async function attempt(): Promise { return false; } +/** + * Dial a published broker; a verified welcome settles this window as its + * client. `''` — a file that stayed empty — names nothing to dial. + */ +async function joinBroker(path: string, token: string): Promise { + if (!path || (await tryConnect(path, token)) !== 'connected') return false; + if (disposed) disconnectClient(); + else settle(false); + return true; +} + +/** + * One round where the endpoint is published rather than derived (Windows): + * dial the name the broker published, or retire a dead broker's file and + * publish a fresh name of our own. + * + * Every name is minted per bind, so nobody can create it before we do; but a + * dead broker's name is free again, and Windows lists every pipe to every + * account. So a published name is retired on any failure to reach a verified + * broker on it — not only a refusal — or another account that bound it after + * the broker went would hold this installation's windows off it forever. + */ +async function attemptPublished(legacyPath: string, token: string): Promise { + const published = await readEndpoint(); + if (published !== null) { + if (await joinBroker(published, token)) return true; + // Never retire on the first failure, for the reason unix never unlinks on + // the first refusal: every client of a dead broker gets here at once, and + // one of them may have published a replacement while we waited. + await delay(Math.floor(Math.random() * RECLAIM_JITTER_MS)); + const now = await readEndpoint(); + if (now !== published) return now !== null && (await joinBroker(now, token)); + if (await joinBroker(published, token)) return true; + if (!(await retractEndpoint(published))) { + log.error(`[peer-link] could not remove the stale peer endpoint ${endpointPath()}`); + return false; + } + } + + const name = freshEndpointName(); + if (!(await tryBind(name, token))) return false; + boundEndpoint = name; + if (disposed) { + await closeServer(true); + return true; + } + if (!(await publishEndpoint(name))) { + // Another window published first, so it is the broker and we are not. + await closeServer(false); + const winner = await readEndpoint(); + return winner !== null && (await joinBroker(winner, token)); + } + + if (await olderBuildHoldsLegacy(legacyPath, token)) { + // Its broker already runs the Burrow, and a second would displace it on + // the relay, so this window has joined that one instead. + await closeServer(true); + if (disposed) disconnectClient(); + else settle(false); + return true; + } + + // Provisional even uncontested: a window retiring the file we replaced reads + // it before it unlinks, and can unlink ours in between. Whoever's name the + // file holds after the wait is the broker; anyone else stands down, and the + // loop's next round dials the winner. + await delay(RECLAIM_VERIFY_MS); + if (disposed) { + await closeServer(true); + return true; + } + if ((await readEndpoint()) !== name) { + await closeServer(false); + return false; + } + log.info('[peer-link] serving peers on a freshly published endpoint'); + brokerConfirmed = true; + settle(true); + return true; +} + +/** + * Hold the derived name older builds bind, as a second listener, so a window + * still running one joins this broker rather than electing its own. Reports + * whether an older build's broker holds it already — the only reason this + * window stands down, and only once that broker proves the token: anything + * else on the name (another account, say) is ignored, because this window + * never depends on it. + */ +async function olderBuildHoldsLegacy(path: string, token: string): Promise { + const nextServer = createPeerServer(); + if (await listenOrClose(nextServer, path)) { + // Disposal may have closed `server` while this bound; nothing would close it. + if (server) legacyServer = nextServer; + else nextServer.close(); + return false; + } + return (await tryConnect(path, token)) === 'connected'; +} + /** How long to let a competing reclaim land before believing we won it. */ const RECLAIM_VERIFY_MS = 250; /** Spread over which a stampede of orphaned clients clears one corpse. */ @@ -1341,18 +1537,29 @@ async function contend(): Promise { /** * Give up this window's server. Unlink only when the path still names our - * socket: removing a winner's would strand every client dialing it. + * socket: removing a winner's would strand every client dialing it. A + * published name is retracted only while the file still says it, so `unlink` + * there can never remove another broker's. */ async function closeServer(unlink: boolean): Promise { const closing = server; + const closingLegacy = legacyServer; + const published = boundEndpoint; server = null; + legacyServer = null; + boundEndpoint = null; brokerConfirmed = false; serverToken = null; boundSocketFile = null; for (const peer of [...clients]) dropClient(peer); + if (closingLegacy?.listening) closingLegacy.close(); if (!closing) return; if (closing.listening) closing.close(); if (!unlink) return; + if (published !== null) { + await retractEndpoint(published); + return; + } const path = socketPath(); if (path) await rm(path, { force: true }).catch(() => {}); } diff --git a/vscode-ext/test/peer-endpoint.test.ts b/vscode-ext/test/peer-endpoint.test.ts new file mode 100644 index 000000000..c69fb8eca --- /dev/null +++ b/vscode-ext/test/peer-endpoint.test.ts @@ -0,0 +1,324 @@ +/** + * The published-endpoint arbitration Windows uses, driven end to end. Windows' + * pipe names are swapped for unix sockets in a private directory (the mock + * below keeps the real name's random part), so this runs on every platform the + * suite does; on Windows itself the real pipe names are used. Bind-as-lease on + * a derived path, unix's arbitration, is `peer-link.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHmac } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { createServer, type Server, type Socket } from 'node:net'; +import { dirname, join } from 'node:path'; + +import { + FrameDecoder, + PEER_SERVER_PROOF_DOMAIN, + encodeFrame, +} from '../src/peer-link-protocol'; +import { + derivedSocketPath, + fakeContext, + fakeWindow, + freshModule, + removeDir, + tempStorageDir, + waitFor, +} from './helpers'; + +type LinkModule = typeof import('../src/peer-link'); + +/** Where the mock below puts its sockets; one directory per test. */ +const endpoint = { dir: '' }; + +/** + * This platform's arbitration as one window sees it: the published endpoint + * with Windows' pipe names swapped for unix sockets (keeping the real name's + * random part), or an older build's derived path. Registered per window, before + * {@link freshModule} evaluates that window's copy of `peer-link`. + */ +function mockEndpoint(publishes: boolean): void { + vi.doMock('../src/peer-endpoint', async (importOriginal) => { + const real = await importOriginal(); + return { + publishesEndpoint: () => publishes, + freshEndpointName: () => { + const name = real.freshEndpointName(); + return process.platform === 'win32' + ? name + : join(endpoint.dir, `${name.slice(name.lastIndexOf('\\') + 1)}.sock`); + }, + }; + }); +} + +vi.mock('../src/log', () => ({ log: { init() {}, info() {}, error() {} } })); + +let dir: string; +let realTmp: string | undefined; +const opened: LinkModule[] = []; +const servers: Server[] = []; + +const endpointFile = (): string => join(dir, 'burrow.peer-endpoint'); +const published = async (): Promise => + readFile(endpointFile(), 'utf8').then((text) => text.trim(), () => null); + +/** A name nothing has bound, on whichever namespace this platform dials. */ +let spare = 0; +const spareName = (): string => + process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-test-${process.pid}-${++spare}` + : join(endpoint.dir, `spare-${++spare}.sock`); + +async function openWindow( + side = fakeWindow(), + { olderBuild = false }: { olderBuild?: boolean } = {}, +): Promise { + mockEndpoint(!olderBuild); + const mod = await freshModule(() => import('../src/peer-link')); + mod.initPeerLink(fakeContext(dir)); + mod.configurePeerLink(side.deps()); + opened.push(mod); + return mod; +} + +/** Whether `mod` is a client that can reach its broker. */ +const reachesBroker = (mod: LinkModule): boolean => + !mod.isPeerBroker() && mod.forwardCommand({ burrowRequestId: 'probe', cmd: 'status' }); + +function listen(server: Server, path: string): Promise { + servers.push(server); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, () => resolve()); + }); +} + +beforeEach(async () => { + dir = await tempStorageDir(); + // Short, for macOS's ~104-byte socket path cap; private, like the real names. + endpoint.dir = process.platform === 'win32' ? '' : await mkdtemp('/tmp/dpe-'); + realTmp = process.env.TMPDIR; + process.env.TMPDIR = dir; +}); + +afterEach(async () => { + for (const mod of [...opened].reverse()) await mod.disposePeerLink(); + opened.length = 0; + for (const server of servers.splice(0)) { + await new Promise((resolve) => server.close(resolve)); + } + if (realTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = realTmp; + await removeDir(dir); + if (endpoint.dir) await removeDir(endpoint.dir); +}); + +describe('published endpoint', () => { + it('mints a fresh, unpredictable pipe name every time', async () => { + const { freshEndpointName } = await vi.importActual( + '../src/peer-endpoint', + ); + const names = new Set(Array.from({ length: 32 }, () => freshEndpointName())); + expect(names.size).toBe(32); + for (const name of names) expect(name).toMatch(/^\\\\\.\\pipe\\dormouse-peer-[0-9a-f]{32}$/); + }); + + it('binds a fresh name for every broker, never the derived one, and retracts it on close', async () => { + const first = await openWindow(); + await first.ensurePeerNet(() => {}); + expect(first.isPeerBroker()).toBe(true); + const firstName = await published(); + expect(firstName).toBeTruthy(); + expect(firstName).not.toBe(derivedSocketPath(dir)); + + // Windows that need the broker find it through the file. + const peer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); + await peer.ensurePeerNet(() => {}); + expect(reachesBroker(peer)).toBe(true); + + await peer.disposePeerLink(); + await first.disposePeerLink(); + expect(await published()).toBeNull(); + + const second = await openWindow(); + await second.ensurePeerNet(() => {}); + expect(second.isPeerBroker()).toBe(true); + const secondName = await published(); + expect(secondName).toBeTruthy(); + expect(secondName).not.toBe(firstName); + expect(secondName).not.toBe(derivedSocketPath(dir)); + }, 15_000); + + it.skipIf(process.platform === 'win32')('publishes the name in a file only this user can read', async () => { + const broker = await openWindow(); + await broker.ensurePeerNet(() => {}); + expect((await stat(endpointFile())).mode & 0o777).toBe(0o600); + }); + + it('is not held off by another account that took the derived name first', async () => { + // The squat this design closes: anything can sit on the derived name, and + // a new build brokers on its own fresh name regardless. + const squatted: Socket[] = []; + const squatter = createServer((socket) => void squatted.push(socket)); + const legacy = derivedSocketPath(dir); + if (process.platform !== 'win32') await mkdir(dirname(legacy), { recursive: true, mode: 0o700 }); + await listen(squatter, legacy); + + const broker = await openWindow(); + const peer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); + await broker.ensurePeerNet(() => {}); + await peer.ensurePeerNet(() => {}); + expect(broker.isPeerBroker()).toBe(true); + expect(reachesBroker(peer)).toBe(true); + expect(await broker.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); + for (const socket of squatted) socket.destroy(); + }, 15_000); + + it('retires a stale file naming a broker that is gone', async () => { + const dead = spareName(); + await writeFile(endpointFile(), dead, { mode: 0o600 }); + + const mod = await openWindow(); + const roles: boolean[] = []; + await mod.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([true]); + const now = await published(); + expect(now).toBeTruthy(); + expect(now).not.toBe(dead); + }, 15_000); + + it('retires a published name that something other than a broker bound after it died', async () => { + // Windows lists every pipe to every account, so a dead broker's name can be + // taken by someone else. Retrying it forever would be the same denial as + // squatting a derived name; it never proves the token, so it is retired. + const taken = spareName(); + const received: unknown[] = []; + const squatter = createServer((socket) => { + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + received.push(frame); + socket.write(encodeFrame({ kind: 'welcome', proof: 'made up' })); + } + }); + socket.write(encodeFrame({ kind: 'challenge', nonce: 'squatter-nonce' })); + }); + await listen(squatter, taken); + await writeFile(endpointFile(), taken, { mode: 0o600 }); + + const side = fakeWindow({ entries: [{ surfaceId: 'secret-1' }] }); + const mod = await openWindow(side); + await mod.ensurePeerNet(() => {}); + + expect(mod.isPeerBroker()).toBe(true); + expect(await published()).not.toBe(taken); + // It was told nothing but hellos, which carry no token. + const token = (await readFile(join(dir, 'burrow.peer-token'), 'utf8')).trim(); + expect(received.every((frame) => (frame as { kind: string }).kind === 'hello')).toBe(true); + expect(JSON.stringify(received)).not.toContain(token); + expect(side.requests).toEqual([]); + }, 15_000); + + it('settles windows starting together into one broker that the others reach', async () => { + // With the derived name squatted, the file alone has to elect one broker: + // nothing can join through that name instead. + const legacy = derivedSocketPath(dir); + if (process.platform !== 'win32') await mkdir(dirname(legacy), { recursive: true, mode: 0o700 }); + await listen(createServer((socket) => socket.destroy()), legacy); + const windows = [await openWindow(), await openWindow(), await openWindow()]; + const roles: boolean[] = []; + await Promise.all(windows.map((mod) => mod.ensurePeerNet((held) => roles.push(held)))); + + await waitFor(() => windows.every((mod) => mod.isPeerBroker() || reachesBroker(mod)), 10_000); + expect(windows.filter((mod) => mod.isPeerBroker())).toHaveLength(1); + expect(roles).toEqual([true]); + }, 20_000); + + it('brings connected peers to a fresh broker when theirs dies and leaves its file behind', async () => { + // A broker whose extension host crashed: its pipe is gone, its file is not. + const token = 'shared-token'; + await writeFile(join(dir, 'burrow.peer-token'), token, { mode: 0o600 }); + const crashed = spareName(); + const accepted: Socket[] = []; + const broker = createServer((socket) => { + accepted.push(socket); + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const hello = frame as { kind: string; nonce: string }; + if (hello.kind !== 'hello') continue; + const proof = createHmac('sha256', token) + .update(PEER_SERVER_PROOF_DOMAIN + hello.nonce) + .digest('base64url'); + socket.write(encodeFrame({ kind: 'welcome', proof })); + } + }); + socket.write(encodeFrame({ kind: 'challenge', nonce: 'broker-nonce' })); + }); + await listen(broker, crashed); + await writeFile(endpointFile(), crashed, { mode: 0o600 }); + + const sides = [ + fakeWindow({ entries: [{ surfaceId: 'b-1' }] }), + fakeWindow({ entries: [{ surfaceId: 'c-1' }] }), + ]; + const peers = [await openWindow(sides[0]), await openWindow(sides[1])]; + await Promise.all(peers.map((mod) => mod.ensurePeerNet(() => {}))); + expect(peers.every(reachesBroker)).toBe(true); + + servers.splice(servers.indexOf(broker), 1); + for (const socket of accepted) socket.destroy(); + await new Promise((resolve) => broker.close(resolve)); + expect(await published()).toBe(crashed); + + await waitFor(() => peers.some((mod) => mod.isPeerBroker()), 10_000); + const [elected] = peers.filter((mod) => mod.isPeerBroker()); + const other = peers.find((mod) => mod !== elected)!; + await waitFor(() => reachesBroker(other), 10_000); + expect(peers.filter((mod) => mod.isPeerBroker())).toHaveLength(1); + expect(await published()).not.toBe(crashed); + + // And a window opened afterwards finds the new broker too. + const late = await openWindow(fakeWindow({ entries: [{ surfaceId: 'd-1' }] })); + await late.ensurePeerNet(() => {}); + expect(reachesBroker(late)).toBe(true); + await waitFor(async () => (await elected.remoteRequest('directory', {})).length === 2, 10_000); + }, 30_000); +}); + +describe('alongside older builds', () => { + it('joins an older build’s broker on the derived name instead of electing a second one', async () => { + const older = await openWindow(fakeWindow(), { olderBuild: true }); + await older.ensurePeerNet(() => {}); + expect(older.isPeerBroker()).toBe(true); + + const roles: boolean[] = []; + const newer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); + await newer.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([]); + expect(reachesBroker(newer)).toBe(true); + expect(await older.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); + // It retracted the name it briefly published, so later windows are not sent there. + expect(await published()).toBeNull(); + }, 15_000); + + it('lets an older build’s window join a newer broker through the derived name', async () => { + const newer = await openWindow(); + await newer.ensurePeerNet(() => {}); + expect(newer.isPeerBroker()).toBe(true); + + const roles: boolean[] = []; + const older = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] }), { olderBuild: true }); + await older.ensurePeerNet((held) => roles.push(held)); + + expect(roles).toEqual([]); + expect(reachesBroker(older)).toBe(true); + expect(await newer.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); + }, 15_000); +}); diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index d4767111b..5e5ccbea9 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -170,44 +170,6 @@ afterEach(async () => { }); describe('bind-as-lease', () => { - it.skipIf(process.platform !== 'win32')('reclaims a dead named pipe and elects exactly one broker for racing windows', async () => { - // Windows removes the pipe when its process dies; there is no Unix corpse - // inode to unlink. Exercise the actual transport, not a fake socket path. - const corpse = spawn(process.execPath, ['-e', - "require('node:net').createServer().listen(process.argv[1], () => process.send('listening'))", - derivedSocketPath()], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); - try { - await new Promise((resolve, reject) => { - corpse.once('message', () => resolve()); - corpse.once('error', reject); - corpse.once('exit', (code) => reject(new Error(`pipe fixture exited ${code}`))); - }); - const exited = new Promise((resolve) => corpse.once('exit', () => resolve())); - corpse.kill('SIGKILL'); - await exited; - const firstSide = fakeWindow({ entries: [{ surfaceId: 'first' }] }); - const secondSide = fakeWindow({ entries: [{ surfaceId: 'second' }] }); - const first = await openWindow(firstSide); - const second = await openWindow(secondSide); - const roles: boolean[] = []; - await Promise.all([ - first.ensurePeerNet((held) => roles.push(held)), - second.ensurePeerNet((held) => roles.push(held)), - ]); - const brokers = [first, second].filter((mod) => mod.isPeerBroker()); - expect(brokers).toHaveLength(1); - expect(roles).toEqual([true]); - const expected = first.isPeerBroker() ? secondSide.entries : firstSide.entries; - await waitFor(async () => JSON.stringify(await brokers[0].remoteRequest('directory', {})) === JSON.stringify(expected)); - } finally { - if (corpse.exitCode === null && corpse.signalCode === null) { - const exited = new Promise((resolve) => corpse.once('exit', () => resolve())); - corpse.kill('SIGKILL'); - await exited; - } - } - }, 15_000); - it('rejects when the peer socket cannot be bound', async () => { const mod = await openWindow(fakeWindow()); const failingServer = createServer(); @@ -1218,7 +1180,9 @@ describe('peer handshake', () => { second.destroy(); }); - it('serves nothing to a squatter that took the path but not the token', async () => { + // Windows never depends on the derived name; `peer-endpoint.test.ts` covers + // what holds a published one. + it.skipIf(process.platform === 'win32')('serves nothing to a squatter that took the path but not the token', async () => { // The co-resident-user attack: bind the path first, then wait to be handed // this installation's terminals. The client must send its hello and nothing // else, and must disconnect on a welcome it cannot verify. @@ -1272,7 +1236,9 @@ describe('peer handshake', () => { } }); - it('rejects a non-object frame from the process holding the socket', async () => { + // Windows never depends on the derived name; `peer-endpoint.test.ts` covers + // what holds a published one. + it.skipIf(process.platform === 'win32')('rejects a non-object frame from the process holding the socket', async () => { let squatterSocket: import('node:net').Socket | null = null; let window: LinkModule | null = null; let closed = false; From fd9280795450514278d5436dbfa4347e49de30c6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 11:03:30 -0700 Subject: [PATCH 2/5] Wait for a stalled Windows broker instead of replacing it A broker stalled behind a busy extension host answers a dial with silence, exactly like a squatter holding a dead broker's name. Replacing it left two Burrows under one enrollment once it caught up, since a broker never stands down. The endpoint file now records the broker's pid, and silence retires a published name only once that process is gone; a refusal or a holder that cannot prove the token still retires it. Co-Authored-By: Claude Opus 5.5 --- docs/specs/security-local.md | 2 +- docs/specs/security.md | 2 +- docs/specs/vscode.md | 4 +- docs/specs/vscode.rationale.md | 4 +- vscode-ext/src/peer-link.ts | 104 +++++++++++++++++++------- vscode-ext/test/peer-endpoint.test.ts | 103 ++++++++++++++++++++----- 6 files changed, 170 insertions(+), 49 deletions(-) diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index f5c7efca2..6d7484b3f 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -91,7 +91,7 @@ The socket bounds other local accounts, never a process running as the user (`do The attacker is another local account; the link carries the Burrow's commands and every window's terminals between VS Code windows (`docs/specs/vscode.md` -> "Burrow: a service in the extension host"). -- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name that twice failed to yield a verified welcome, or anything but a verified broker on the derived name older builds bind changes a window's role. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`. +- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name whose holder refused, failed to prove the token, or stayed silent after its publishing pid died, or replaces on silence alone a broker whose pid is alive, or anything but a verified broker on the derived name older builds bind changes a window's role. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`. ## Loopback Listeners diff --git a/docs/specs/security.md b/docs/specs/security.md index 3d910c10c..0db74acb2 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)). - **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)). -- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can take the pipe name older VS Code builds bind.** A window still on such a build then reaches no broker until it updates, and a current broker waits out one handshake on that name before serving ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)). +- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can take the pipe name older VS Code builds bind**, keeping a window on such a build from any broker until it updates and making a current broker wait out one handshake before serving. It can also hold a dead broker's name silently while one of its processes reuses that broker's pid, which keeps windows waiting for a broker that is gone ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)). - **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)). - **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)). - **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index cc82f5ead..13f649da8 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -144,9 +144,9 @@ Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, ` **Windows: a published endpoint**, since another account could create a derived pipe name first (rationale): -- **Every bind takes a fresh `\\.\pipe\dormouse-peer-<32 random hex>`**, published by an exclusive `wx` create of `burrow.peer-endpoint` beside the token. **Windows find the broker only through that file**; one that loses the create closes its pipe and dials the winner. +- **Every bind takes a fresh `\\.\pipe\dormouse-peer-<32 random hex>`**, published with the broker's pid by an exclusive `wx` create of `burrow.peer-endpoint` beside the token. **Windows find the broker only through that file**; one that loses the create closes its pipe and dials the winner. - **Every published bind is provisional**, believed only if the file still names it after `RECLAIM_VERIFY_MS`: a window retiring a stale file can remove a fresh one. -- **A published name that twice fails to yield a verified welcome, a jittered re-read apart, is retired** whatever the failure, and only while the file still names it (rationale); a closing broker retracts its own the same way. +- **A published name that twice fails to yield a verified welcome, a jittered re-read apart, is retired**, only while the file still names it — unless both dials met only silence and the publishing pid is alive, since a stalled broker is waited for (`docs/specs/security-local.md` → "The VS Code peer link"; rationale). A closing broker retracts its own the same way. - **The broker also listens on the derived `\\.\pipe\dormouse-peer-`**, so an older build's window joins it rather than electing a second Burrow (rationale). If that name is taken, only a verified broker there makes this window retract its file and join it. **Trust.** Neither the socket path nor a listed pipe name is secret, so two layers stand between them and this installation's terminals. diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index 6532b3450..1b103b2c7 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -68,7 +68,9 @@ **Why Windows publishes its endpoint.** With a derived pipe name, another account could create it before any window bound it after a restart, and every window's bind then failed: remote control and peer surfaces stayed down. Keying the name by the token does not help: the token and storage path are permanent, so the keyed name is fixed after its first bind, and Windows lists it to every account. A name minted per bind cannot be created in advance, and once bound, libuv's first-instance flag and the pipe's default DACL keep another account from adding an instance of it. -**Why a published name is retired on any failure, not only a refusal.** A dead broker's name is free again and was listed while it lived, so another account can bind it as the broker goes. Under unix's refusal-only rule every window would then retry it forever: the same denial, moved from boot to the first broker restart. The cost is that a live broker that cannot finish a handshake within `HANDSHAKE_BUDGET_MS`, twice, is replaced, and the two Burrows displace each other until the hung one recovers. +**Why a published name is retired on more than a refusal.** A dead broker's name is free again and was listed while it lived, so another account can bind it as the broker goes. Under unix's refusal-only rule every window would then retry it forever: the same denial, moved from boot to the first broker restart. + +**Why silence needs the pid.** A broker stalled behind a busy extension host (a paused debugger, another extension's synchronous work) accepts at the OS level and then says nothing, exactly like a squatter that holds the name silently. Replacing it would be permanent: a broker never stands down and nothing makes it re-read the file, so once it caught up two Burrows would displace each other on the Relay until a window reloaded. A live pid tells the two apart; the residual is a squatter that also gets a process at the dead broker's pid, which keeps windows waiting (`docs/specs/security.md` -> "What is not defended"). **Why the broker holds the derived name too.** VS Code keeps running an extension's old version in each window until that window restarts its extensions, so after an update windows of both builds share one installation. An older-build window that found the derived name free would bind it and run a second Burrow under the same enrollment. A squatter on that name costs a current broker one `HANDSHAKE_BUDGET_MS` dial before it is believed, and keeps older-build windows from any broker (`docs/specs/security.md` -> "What is not defended"). diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 0a5fccb95..eb0092829 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -273,11 +273,43 @@ function endpointPath(): string | null { } /** - * The pipe name the broker published: `null` when there is no file, otherwise + * What a broker publishes: the pipe name it bound, and its own pid, so a window + * can tell a broker that has stalled from one that is gone. + */ +interface EndpointRecord { + name: string; + pid: number; +} + +/** The record a raw endpoint file holds, or `null` for anything else. */ +function parseEndpoint(raw: string): EndpointRecord | null { + try { + const value = JSON.parse(raw) as Partial; + return typeof value.name === 'string' && value.name && Number.isInteger(value.pid) && value.pid! > 0 + ? { name: value.name, pid: value.pid! } + : null; + } catch { + return null; + } +} + +/** Whether a process with this pid exists; one we may not signal still does. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** + * The raw endpoint file: `null` when there is no file, otherwise * its trimmed contents. An empty file is waited out as *not yet written*, like * the token, since the exclusive create lands before its bytes; one that stays - * empty, or cannot be read, comes back as `''` — a name nothing answers on, so - * the caller retires it like any dead broker's. + * empty, or cannot be read, comes back as `''`, which names no broker, so the + * caller retires it like any dead broker's. The raw text is the file's + * identity for {@link retractEndpoint}; {@link parseEndpoint} reads it. */ async function readEndpoint(): Promise { const path = endpointPath(); @@ -298,11 +330,11 @@ async function readEndpoint(): Promise { * 0600 for the same reason as the token; on Windows the extension's storage * under the user profile is what keeps other accounts out. */ -async function publishEndpoint(name: string): Promise { +async function publishEndpoint(raw: string): Promise { const path = endpointPath(); if (!path) return false; try { - await writeFile(path, name, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + await writeFile(path, raw, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; @@ -311,15 +343,15 @@ async function publishEndpoint(name: string): Promise { } /** - * Remove the endpoint file if it still says `name`, reporting whether it is + * Remove the endpoint file if it still says `raw`, reporting whether it is * gone. Compared first so a window retiring a dead broker's name never removes * a live one's that replaced it; the gap between the read and the unlink is * what {@link attemptPublished}'s verification covers. */ -async function retractEndpoint(name: string): Promise { +async function retractEndpoint(raw: string): Promise { const path = endpointPath(); if (!path) return false; - if ((await readEndpoint()) !== name) return true; + if ((await readEndpoint()) !== raw) return true; try { await rm(path); return true; @@ -355,8 +387,8 @@ let server: Server | null = null; */ let legacyServer: Server | null = null; /** - * Windows only: the fresh name `server` is bound to, once this window has bound - * one — what it publishes, verifies, and retracts. Cleared with `server`. + * Windows only: the endpoint record for the fresh name `server` is bound to, + * as the raw text it publishes, verifies, and retracts. Cleared with `server`. */ let boundEndpoint: string | null = null; /** @@ -1057,6 +1089,9 @@ function stopForwarding(): void { * Connect to whoever holds the socket and finish the mutual handshake. * `'refused'` means the path exists but nothing is listening on it — a broker * that died without unlinking — which is the caller's cue to clear it and bind. + * `'silent'` means nothing failed but no verified welcome came within the + * budget, which a stalled broker and a squatter that says nothing look alike in; + * `'failed'` is everything else, each a holder that is not our broker. * * Between `connect` and a verified `welcome` this window sends exactly one * frame, its `hello`, and answers nothing: no directory, no PTY stream, no @@ -1064,7 +1099,9 @@ function stopForwarding(): void { * that guessed the path, and the whole point of the ordering is that guessing * the path is not enough to be served. */ -function tryConnect(path: string, token: string): Promise<'connected' | 'refused' | 'failed'> { +type ConnectOutcome = 'connected' | 'refused' | 'failed' | 'silent'; + +function tryConnect(path: string, token: string): Promise { return new Promise((resolve) => { const socket = createConnection({ path }); const decoder = new FrameDecoder(); @@ -1073,14 +1110,14 @@ function tryConnect(path: string, token: string): Promise<'connected' | 'refused let helloSent = false; let settled = false; - const finish = (outcome: 'connected' | 'refused' | 'failed'): void => { + const finish = (outcome: ConnectOutcome): void => { if (settled) return; settled = true; clearTimeout(timer); if (outcome !== 'connected') socket.destroy(); resolve(outcome); }; - const timer = setTimeout(() => finish('failed'), HANDSHAKE_BUDGET_MS); + const timer = setTimeout(() => finish('silent'), HANDSHAKE_BUDGET_MS); const drop = () => { if (client !== socket) return; @@ -1357,13 +1394,16 @@ async function attempt(): Promise { /** * Dial a published broker; a verified welcome settles this window as its - * client. `''` — a file that stayed empty — names nothing to dial. + * client. Anything else is reported as {@link tryConnect}'s outcome, with a + * record that names no broker reading as `'failed'`. */ -async function joinBroker(path: string, token: string): Promise { - if (!path || (await tryConnect(path, token)) !== 'connected') return false; +async function joinBroker(record: EndpointRecord | null, token: string): Promise { + if (!record) return 'failed'; + const outcome = await tryConnect(record.name, token); + if (outcome !== 'connected') return outcome; if (disposed) disconnectClient(); else settle(false); - return true; + return outcome; } /** @@ -1373,21 +1413,30 @@ async function joinBroker(path: string, token: string): Promise { * * Every name is minted per bind, so nobody can create it before we do; but a * dead broker's name is free again, and Windows lists every pipe to every - * account. So a published name is retired on any failure to reach a verified - * broker on it — not only a refusal — or another account that bound it after - * the broker went would hold this installation's windows off it forever. + * account. So a published name is retired when its holder cannot prove the + * token — not only on a refusal — or another account that bound it after the + * broker went would hold this installation's windows off it forever. Silence + * is the one failure a live broker can also give, stalled behind a busy + * extension host, so silence retires the name only once the process that + * published it is gone: replacing a stalled broker would leave two Burrows + * under one enrollment, since a broker never stands down. */ async function attemptPublished(legacyPath: string, token: string): Promise { const published = await readEndpoint(); if (published !== null) { - if (await joinBroker(published, token)) return true; + const record = parseEndpoint(published); + if ((await joinBroker(record, token)) === 'connected') return true; // Never retire on the first failure, for the reason unix never unlinks on // the first refusal: every client of a dead broker gets here at once, and // one of them may have published a replacement while we waited. await delay(Math.floor(Math.random() * RECLAIM_JITTER_MS)); const now = await readEndpoint(); - if (now !== published) return now !== null && (await joinBroker(now, token)); - if (await joinBroker(published, token)) return true; + if (now !== published) { + return now !== null && (await joinBroker(parseEndpoint(now), token)) === 'connected'; + } + const outcome = await joinBroker(record, token); + if (outcome === 'connected') return true; + if (outcome === 'silent' && record && processAlive(record.pid)) return false; if (!(await retractEndpoint(published))) { log.error(`[peer-link] could not remove the stale peer endpoint ${endpointPath()}`); return false; @@ -1396,16 +1445,17 @@ async function attemptPublished(legacyPath: string, token: string): Promise join(dir, 'burrow.peer-endpoint'); +/** The pipe name the endpoint file publishes, or `null` when there is none. */ const published = async (): Promise => - readFile(endpointFile(), 'utf8').then((text) => text.trim(), () => null); + readFile(endpointFile(), 'utf8').then( + (text) => (JSON.parse(text) as { name: string }).name, + () => null, + ); +/** Stand in for a broker publishing `name` from process `pid`. */ +const publish = (name: string, pid: number): Promise => + writeFile(endpointFile(), JSON.stringify({ name, pid }), { mode: 0o600 }); + +/** The pid of a process that has exited, as a crashed broker's would be. */ +async function deadPid(): Promise { + const child = spawn(process.execPath, ['-e', '']); + await new Promise((resolve) => child.once('exit', resolve)); + return child.pid!; +} + +/** Hand a connecting window the welcome only `token`'s holder can give. */ +function answerHandshake(socket: Socket, token: string): void { + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const hello = frame as { kind: string; nonce: string }; + if (hello.kind !== 'hello') continue; + const proof = createHmac('sha256', token) + .update(PEER_SERVER_PROOF_DOMAIN + hello.nonce) + .digest('base64url'); + socket.write(encodeFrame({ kind: 'welcome', proof })); + } + }); + socket.write(encodeFrame({ kind: 'challenge', nonce: 'broker-nonce' })); +} /** A name nothing has bound, on whichever namespace this platform dials. */ let spare = 0; @@ -178,7 +210,7 @@ describe('published endpoint', () => { it('retires a stale file naming a broker that is gone', async () => { const dead = spareName(); - await writeFile(endpointFile(), dead, { mode: 0o600 }); + await publish(dead, await deadPid()); const mod = await openWindow(); const roles: boolean[] = []; @@ -208,7 +240,7 @@ describe('published endpoint', () => { socket.write(encodeFrame({ kind: 'challenge', nonce: 'squatter-nonce' })); }); await listen(squatter, taken); - await writeFile(endpointFile(), taken, { mode: 0o600 }); + await publish(taken, await deadPid()); const side = fakeWindow({ entries: [{ surfaceId: 'secret-1' }] }); const mod = await openWindow(side); @@ -238,6 +270,55 @@ describe('published endpoint', () => { expect(roles).toEqual([true]); }, 20_000); + it('retires a dead broker’s name held by something that never answers', async () => { + // Silence alone is ambiguous, so what retires it is that the process which + // published the name is gone. + const taken = spareName(); + const held: Socket[] = []; + await listen(createServer((socket) => void held.push(socket)), taken); + await publish(taken, await deadPid()); + + const mod = await openWindow(); + await mod.ensurePeerNet(() => {}); + + expect(mod.isPeerBroker()).toBe(true); + expect(await published()).not.toBe(taken); + for (const socket of held) socket.destroy(); + }, 30_000); + + it('waits for a live broker that stopped answering rather than replacing it', async () => { + // A broker never stands down, so replacing one stalled behind a busy + // extension host would leave two Burrows once it caught up. + const token = 'shared-token'; + await writeFile(join(dir, 'burrow.peer-token'), token, { mode: 0o600 }); + const stalledName = spareName(); + let stalled = true; + const dialed: Socket[] = []; + await listen( + createServer((socket) => { + dialed.push(socket); + if (!stalled) answerHandshake(socket, token); + }), + stalledName, + ); + await publish(stalledName, process.pid); + + const mod = await openWindow(); + const roles: boolean[] = []; + const settled = mod.ensurePeerNet((held) => roles.push(held)); + // Two silent dials make a full round; a third means it went round again + // instead of retiring the name. + await waitFor(() => dialed.length >= 3, 30_000); + expect(mod.isPeerBroker()).toBe(false); + expect(await published()).toBe(stalledName); + + stalled = false; + await settled; + expect(roles).toEqual([]); + expect(reachesBroker(mod)).toBe(true); + for (const socket of dialed) socket.destroy(); + }, 60_000); + it('brings connected peers to a fresh broker when theirs dies and leaves its file behind', async () => { // A broker whose extension host crashed: its pipe is gone, its file is not. const token = 'shared-token'; @@ -246,22 +327,10 @@ describe('published endpoint', () => { const accepted: Socket[] = []; const broker = createServer((socket) => { accepted.push(socket); - const decoder = new FrameDecoder(); - socket.setEncoding('utf8'); - socket.on('data', (chunk: string) => { - for (const frame of decoder.push(chunk)) { - const hello = frame as { kind: string; nonce: string }; - if (hello.kind !== 'hello') continue; - const proof = createHmac('sha256', token) - .update(PEER_SERVER_PROOF_DOMAIN + hello.nonce) - .digest('base64url'); - socket.write(encodeFrame({ kind: 'welcome', proof })); - } - }); - socket.write(encodeFrame({ kind: 'challenge', nonce: 'broker-nonce' })); + answerHandshake(socket, token); }); await listen(broker, crashed); - await writeFile(endpointFile(), crashed, { mode: 0o600 }); + await publish(crashed, await deadPid()); const sides = [ fakeWindow({ entries: [{ surfaceId: 'b-1' }] }), From 317d8029ad14c78f50a4ac72181326907b118713 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 11:08:52 -0700 Subject: [PATCH 3/5] Hold a stalled broker's name only when both dials met silence Co-Authored-By: Claude Opus 5.5 --- vscode-ext/src/peer-link.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index eb0092829..c911cecb6 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -1425,7 +1425,8 @@ async function attemptPublished(legacyPath: string, token: string): Promise Date: Tue, 20 Oct 2026 14:19:53 -0700 Subject: [PATCH 4/5] Drop the older-build listener from the Windows peer link There are no existing installations to stay compatible with, so a Windows broker no longer listens on the old derived pipe name and no window dials it. Tests and spec text that existed only for that path go with it, and the derived name is now unix-only. Co-Authored-By: Claude Opus 5.5 --- docs/specs/security-local.md | 2 +- docs/specs/security.md | 2 +- docs/specs/vscode.md | 1 - docs/specs/vscode.rationale.md | 1 - vscode-ext/src/peer-link.ts | 71 ++++-------------------- vscode-ext/test/helpers.ts | 6 +-- vscode-ext/test/peer-endpoint.test.ts | 77 ++++++--------------------- vscode-ext/test/peer-link.test.ts | 21 +++++--- 8 files changed, 43 insertions(+), 138 deletions(-) diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 6d7484b3f..ba4d191bc 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -91,7 +91,7 @@ The socket bounds other local accounts, never a process running as the user (`do The attacker is another local account; the link carries the Burrow's commands and every window's terminals between VS Code windows (`docs/specs/vscode.md` -> "Burrow: a service in the extension host"). -- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name whose holder refused, failed to prove the token, or stayed silent after its publishing pid died, or replaces on silence alone a broker whose pid is alive, or anything but a verified broker on the derived name older builds bind changes a window's role. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`. +- **FAIL IF** `S274` on Windows a broker binds a pipe name not minted per bind from fresh CSPRNG bytes (`freshEndpointName` in `vscode-ext/src/peer-endpoint.ts`), a window learns the broker's name other than from `burrow.peer-endpoint` in the extension's global storage, a window keeps dialing a published name whose holder refused, failed to prove the token, or stayed silent after its publishing pid died, or replaces on silence alone a broker whose pid is alive. `attemptPublished` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/peer-endpoint.test.ts`. ## Loopback Listeners diff --git a/docs/specs/security.md b/docs/specs/security.md index 0db74acb2..8217bf867 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)). - **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)). -- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can take the pipe name older VS Code builds bind**, keeping a window on such a build from any broker until it updates and making a current broker wait out one handshake before serving. It can also hold a dead broker's name silently while one of its processes reuses that broker's pid, which keeps windows waiting for a broker that is gone ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)). +- *One-time link, Hosted phone, Self-hosted Relay.* **Another Windows account can hold a dead VS Code broker's pipe name silently while one of its processes reuses that broker's pid**, which keeps windows waiting for a broker that is gone ([The VS Code peer link](./security-local.md#the-vs-code-peer-link)). - **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)). - **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)). - **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 13f649da8..9ebd09f95 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -147,7 +147,6 @@ Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, ` - **Every bind takes a fresh `\\.\pipe\dormouse-peer-<32 random hex>`**, published with the broker's pid by an exclusive `wx` create of `burrow.peer-endpoint` beside the token. **Windows find the broker only through that file**; one that loses the create closes its pipe and dials the winner. - **Every published bind is provisional**, believed only if the file still names it after `RECLAIM_VERIFY_MS`: a window retiring a stale file can remove a fresh one. - **A published name that twice fails to yield a verified welcome, a jittered re-read apart, is retired**, only while the file still names it — unless both dials met only silence and the publishing pid is alive, since a stalled broker is waited for (`docs/specs/security-local.md` → "The VS Code peer link"; rationale). A closing broker retracts its own the same way. -- **The broker also listens on the derived `\\.\pipe\dormouse-peer-`**, so an older build's window joins it rather than electing a second Burrow (rationale). If that name is taken, only a verified broker there makes this window retract its file and join it. **Trust.** Neither the socket path nor a listed pipe name is secret, so two layers stand between them and this installation's terminals. diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index 1b103b2c7..960446fd5 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -72,7 +72,6 @@ **Why silence needs the pid.** A broker stalled behind a busy extension host (a paused debugger, another extension's synchronous work) accepts at the OS level and then says nothing, exactly like a squatter that holds the name silently. Replacing it would be permanent: a broker never stands down and nothing makes it re-read the file, so once it caught up two Burrows would displace each other on the Relay until a window reloaded. A live pid tells the two apart; the residual is a squatter that also gets a process at the dead broker's pid, which keeps windows waiting (`docs/specs/security.md` -> "What is not defended"). -**Why the broker holds the derived name too.** VS Code keeps running an extension's old version in each window until that window restarts its extensions, so after an update windows of both builds share one installation. An older-build window that found the derived name free would bind it and run a second Burrow under the same enrollment. A squatter on that name costs a current broker one `HANDSHAKE_BUDGET_MS` dial before it is believed, and keeps older-build windows from any broker (`docs/specs/security.md` -> "What is not defended"). **Why a mid-contention command waits rather than being refused.** While the contention runs the window is neither broker nor client, and a bind plus a handshake is not instant. Refusing there would tell an enrolled machine's webview it has no Burrow seconds before it gets one, and the gates that arm on that answer (`enrolled-gate.ts`) would stay down. diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index c911cecb6..1aab23653 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -192,10 +192,6 @@ async function peerDirIsSafe(): Promise { * the extension's globalStorage path is most of that on its own. Derived from * that path rather than random precisely because it must be *the same* in every * window — the bind is the arbitration. - * - * On Windows this is only the name older builds bind: a broker there holds it - * as a second listener so their windows join it ({@link olderBuildHoldsLegacy}), - * and never depends on getting it, because another account can create it first. */ function socketPath(): string | null { if (!context) return null; @@ -203,9 +199,7 @@ function socketPath(): string | null { .update(context.globalStorageUri.fsPath) .digest('hex') .slice(0, 12); - return process.platform === 'win32' - ? `\\\\.\\pipe\\dormouse-peer-${id}` - : join(peerDirPath(), `${id}.sock`); + return join(peerDirPath(), `${id}.sock`); } /** @@ -380,12 +374,6 @@ export interface PeerLinkClient { let server: Server | null = null; -/** - * Windows only: the second listener on the name older builds bind, so their - * windows join this broker instead of electing their own - * ({@link olderBuildHoldsLegacy}). Closed with `server`. - */ -let legacyServer: Server | null = null; /** * Windows only: the endpoint record for the fresh name `server` is bound to, * as the raw text it publishes, verifies, and retracts. Cleared with `server`. @@ -885,9 +873,9 @@ export function listenServer(nextServer: Server, path: string): Promise { }); } -/** A listener that challenges every connection it accepts. */ -function createPeerServer(): Server { - return createServer((socket) => { +/** Take the socket path, or report that somebody else holds it. */ +async function tryBind(path: string, token: string): Promise { + const nextServer = createServer((socket) => { const client: PeerLinkClient = { socket, decoder: new FrameDecoder(), @@ -911,13 +899,8 @@ function createPeerServer(): Server { // of the token must not volunteer one into whatever bound this path. send(client, { kind: 'challenge', nonce: client.challenge }); }); -} - -/** Listen on `path`, or close the server and report that somebody else holds it. */ -async function listenOrClose(nextServer: Server, path: string): Promise { try { await listenServer(nextServer, path); - return true; } catch { // Callback form deliberately: closing a server that never listened emits an // `'error'` nobody is listening for, which an EventEmitter rethrows and @@ -925,12 +908,6 @@ async function listenOrClose(nextServer: Server, path: string): Promise nextServer.close(() => {}); return false; } -} - -/** Take the socket path, or report that somebody else holds it. */ -async function tryBind(path: string, token: string): Promise { - const nextServer = createPeerServer(); - if (!(await listenOrClose(nextServer, path))) return false; server = nextServer; // Synchronous, and before the first `await` past the bind: `listen` binds // inside the call and resolves on a nextTick, so no thread-pool callback of @@ -1300,8 +1277,7 @@ function settle(broker: boolean): void { * mid-startup) and the loop retries. */ async function attempt(): Promise { - const path = socketPath(); - if (!path) return false; + if (!context) return false; if (!(await peerDirIsSafe())) { log.error( `[peer-link] ${peerDirPath()} is not a private directory of this user; the peer link is off`, @@ -1329,8 +1305,10 @@ async function attempt(): Promise { return true; } - if (publishesEndpoint()) return attemptPublished(path, token); + if (publishesEndpoint()) return attemptPublished(token); + const path = socketPath(); + if (!path) return false; if (await tryBind(path, token)) { // Disposal can land inside any of the awaits above; a socket bound after it // would outlive the window that owns it. @@ -1421,7 +1399,7 @@ async function joinBroker(record: EndpointRecord | null, token: string): Promise * published it is gone: replacing a stalled broker would leave two Burrows * under one enrollment, since a broker never stands down. */ -async function attemptPublished(legacyPath: string, token: string): Promise { +async function attemptPublished(token: string): Promise { const published = await readEndpoint(); if (published !== null) { const record = parseEndpoint(published); @@ -1459,15 +1437,6 @@ async function attemptPublished(legacyPath: string, token: string): Promise { - const nextServer = createPeerServer(); - if (await listenOrClose(nextServer, path)) { - // Disposal may have closed `server` while this bound; nothing would close it. - if (server) legacyServer = nextServer; - else nextServer.close(); - return false; - } - return (await tryConnect(path, token)) === 'connected'; -} - /** How long to let a competing reclaim land before believing we won it. */ const RECLAIM_VERIFY_MS = 250; /** Spread over which a stampede of orphaned clients clears one corpse. */ @@ -1594,16 +1544,13 @@ async function contend(): Promise { */ async function closeServer(unlink: boolean): Promise { const closing = server; - const closingLegacy = legacyServer; const published = boundEndpoint; server = null; - legacyServer = null; boundEndpoint = null; brokerConfirmed = false; serverToken = null; boundSocketFile = null; for (const peer of [...clients]) dropClient(peer); - if (closingLegacy?.listening) closingLegacy.close(); if (!closing) return; if (closing.listening) closing.close(); if (!unlink) return; diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index b50711b99..f18b50ca0 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -25,7 +25,7 @@ export async function tempStorageDir(): Promise { } /** - * The one path every window of an installation contends for, mirroring + * The one path every window of an installation contends for on unix, mirroring * `socketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation that * drifted would silently give each window its own lease and its own Burrow, and * the per-user directory is the layer that keeps another OS user from creating @@ -33,9 +33,7 @@ export async function tempStorageDir(): Promise { */ export function derivedSocketPath(storageDir: string): string { const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); - return process.platform === 'win32' - ? `\\\\.\\pipe\\dormouse-peer-${id}` - : join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); + return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); } export async function removeDir(dir: string): Promise { diff --git a/vscode-ext/test/peer-endpoint.test.ts b/vscode-ext/test/peer-endpoint.test.ts index 8c845f69e..295eddbcd 100644 --- a/vscode-ext/test/peer-endpoint.test.ts +++ b/vscode-ext/test/peer-endpoint.test.ts @@ -31,28 +31,24 @@ import { type LinkModule = typeof import('../src/peer-link'); /** Where the mock below puts its sockets; one directory per test. */ -const endpoint = { dir: '' }; +const endpoint = vi.hoisted(() => ({ dir: '' })); /** - * This platform's arbitration as one window sees it: the published endpoint - * with Windows' pipe names swapped for unix sockets (keeping the real name's - * random part), or an older build's derived path. Registered per window, before - * {@link freshModule} evaluates that window's copy of `peer-link`. + * Windows' arbitration, its pipe names swapped for unix sockets that keep the + * real name's random part. */ -function mockEndpoint(publishes: boolean): void { - vi.doMock('../src/peer-endpoint', async (importOriginal) => { - const real = await importOriginal(); - return { - publishesEndpoint: () => publishes, - freshEndpointName: () => { - const name = real.freshEndpointName(); - return process.platform === 'win32' - ? name - : join(endpoint.dir, `${name.slice(name.lastIndexOf('\\') + 1)}.sock`); - }, - }; - }); -} +vi.mock('../src/peer-endpoint', async (importOriginal) => { + const real = await importOriginal(); + return { + publishesEndpoint: () => true, + freshEndpointName: () => { + const name = real.freshEndpointName(); + return process.platform === 'win32' + ? name + : join(endpoint.dir, `${name.slice(name.lastIndexOf('\\') + 1)}.sock`); + }, + }; +}); vi.mock('../src/log', () => ({ log: { init() {}, info() {}, error() {} } })); @@ -103,11 +99,7 @@ const spareName = (): string => ? `\\\\.\\pipe\\dormouse-peer-test-${process.pid}-${++spare}` : join(endpoint.dir, `spare-${++spare}.sock`); -async function openWindow( - side = fakeWindow(), - { olderBuild = false }: { olderBuild?: boolean } = {}, -): Promise { - mockEndpoint(!olderBuild); +async function openWindow(side = fakeWindow()): Promise { const mod = await freshModule(() => import('../src/peer-link')); mod.initPeerLink(fakeContext(dir)); mod.configurePeerLink(side.deps()); @@ -256,11 +248,6 @@ describe('published endpoint', () => { }, 15_000); it('settles windows starting together into one broker that the others reach', async () => { - // With the derived name squatted, the file alone has to elect one broker: - // nothing can join through that name instead. - const legacy = derivedSocketPath(dir); - if (process.platform !== 'win32') await mkdir(dirname(legacy), { recursive: true, mode: 0o700 }); - await listen(createServer((socket) => socket.destroy()), legacy); const windows = [await openWindow(), await openWindow(), await openWindow()]; const roles: boolean[] = []; await Promise.all(windows.map((mod) => mod.ensurePeerNet((held) => roles.push(held)))); @@ -359,35 +346,3 @@ describe('published endpoint', () => { await waitFor(async () => (await elected.remoteRequest('directory', {})).length === 2, 10_000); }, 30_000); }); - -describe('alongside older builds', () => { - it('joins an older build’s broker on the derived name instead of electing a second one', async () => { - const older = await openWindow(fakeWindow(), { olderBuild: true }); - await older.ensurePeerNet(() => {}); - expect(older.isPeerBroker()).toBe(true); - - const roles: boolean[] = []; - const newer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); - await newer.ensurePeerNet((held) => roles.push(held)); - - expect(roles).toEqual([]); - expect(reachesBroker(newer)).toBe(true); - expect(await older.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); - // It retracted the name it briefly published, so later windows are not sent there. - expect(await published()).toBeNull(); - }, 15_000); - - it('lets an older build’s window join a newer broker through the derived name', async () => { - const newer = await openWindow(); - await newer.ensurePeerNet(() => {}); - expect(newer.isPeerBroker()).toBe(true); - - const roles: boolean[] = []; - const older = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] }), { olderBuild: true }); - await older.ensurePeerNet((held) => roles.push(held)); - - expect(roles).toEqual([]); - expect(reachesBroker(older)).toBe(true); - expect(await newer.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); - }, 15_000); -}); diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 5e5ccbea9..f82e89eac 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -74,6 +74,11 @@ let realTmp: string | undefined; const opened: LinkModule[] = []; const derivedSocketPath = (): string => socketPathFor(dir); +/** Where a window dials the broker: the derived path, or on Windows the published name. */ +const brokerPath = async (): Promise => + process.platform === 'win32' + ? (JSON.parse(await readFile(join(dir, 'burrow.peer-endpoint'), 'utf8')) as { name: string }).name + : derivedSocketPath(); interface SocketFileIdentity { dev: bigint; @@ -184,7 +189,9 @@ describe('bind-as-lease', () => { // libuv callback with nothing to catch it. const mod = await openWindow(fakeWindow()); const server = createServer(); - const path = derivedSocketPath(); + const path = process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-test-${process.pid}` + : derivedSocketPath(); if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await mod.listenServer(server, path); try { @@ -843,7 +850,7 @@ describe('bind-as-lease', () => { expect(peer.isPeerLinkSettled()).toBe(false); }); - it('does not send an old broker’s delayed answer to its replacement', async () => { + it.skipIf(process.platform === 'win32')('does not send an old broker’s delayed answer to its replacement', async () => { const token = 'test-peer-token'; await writeFile(join(dir, 'burrow.peer-token'), token, { mode: 0o600 }); @@ -1079,7 +1086,7 @@ describe('peer handshake', () => { const token = await readToken(); expect(token).toBeTruthy(); - const socket = createConnection({ path: derivedSocketPath() }); + const socket = createConnection({ path: await brokerPath() }); const reader = frameReader(socket); await new Promise((resolve) => socket.on('connect', resolve)); @@ -1116,7 +1123,7 @@ describe('peer handshake', () => { const broker = await openWindow(brokerSide); await broker.ensurePeerNet(() => {}); - const socket = createConnection({ path: derivedSocketPath() }); + const socket = createConnection({ path: await brokerPath() }); const reader = frameReader(socket); await new Promise((resolve) => socket.on('connect', resolve)); const challenge = await reader.next(); @@ -1141,7 +1148,7 @@ describe('peer handshake', () => { const broker = await openWindow(brokerSide); await broker.ensurePeerNet(() => {}); - const socket = createConnection({ path: derivedSocketPath() }); + const socket = createConnection({ path: await brokerPath() }); const reader = frameReader(socket); await new Promise((resolve) => socket.on('connect', resolve)); expect((await reader.next()).kind).toBe('challenge'); @@ -1159,7 +1166,7 @@ describe('peer handshake', () => { await broker.ensurePeerNet(() => {}); const token = await readToken(); - const first = createConnection({ path: derivedSocketPath() }); + const first = createConnection({ path: await brokerPath() }); const firstReader = frameReader(first); await new Promise((resolve) => first.on('connect', resolve)); const captured = await firstReader.next(); @@ -1168,7 +1175,7 @@ describe('peer handshake', () => { // A second connection gets a fresh challenge, so the captured proof is // worth nothing — which is the whole point of the nonce. - const second = createConnection({ path: derivedSocketPath() }); + const second = createConnection({ path: await brokerPath() }); const secondReader = frameReader(second); await new Promise((resolve) => second.on('connect', resolve)); const fresh = await secondReader.next(); From cea1a5b188ae6b1bf64c9f485bd22c0e6aaea5f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 14:27:58 -0700 Subject: [PATCH 5/5] Drop the endpoint test the derived name no longer reaches Co-Authored-By: Claude Opus 5.5 --- vscode-ext/test/peer-endpoint.test.ts | 28 +++------------------------ 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/vscode-ext/test/peer-endpoint.test.ts b/vscode-ext/test/peer-endpoint.test.ts index 295eddbcd..4bd653f39 100644 --- a/vscode-ext/test/peer-endpoint.test.ts +++ b/vscode-ext/test/peer-endpoint.test.ts @@ -9,9 +9,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { spawn } from 'node:child_process'; import { createHmac } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; import { createServer, type Server, type Socket } from 'node:net'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { FrameDecoder, @@ -19,7 +19,6 @@ import { encodeFrame, } from '../src/peer-link-protocol'; import { - derivedSocketPath, fakeContext, fakeWindow, freshModule, @@ -149,13 +148,12 @@ describe('published endpoint', () => { for (const name of names) expect(name).toMatch(/^\\\\\.\\pipe\\dormouse-peer-[0-9a-f]{32}$/); }); - it('binds a fresh name for every broker, never the derived one, and retracts it on close', async () => { + it('binds a fresh name for every broker and retracts it on close', async () => { const first = await openWindow(); await first.ensurePeerNet(() => {}); expect(first.isPeerBroker()).toBe(true); const firstName = await published(); expect(firstName).toBeTruthy(); - expect(firstName).not.toBe(derivedSocketPath(dir)); // Windows that need the broker find it through the file. const peer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); @@ -172,7 +170,6 @@ describe('published endpoint', () => { const secondName = await published(); expect(secondName).toBeTruthy(); expect(secondName).not.toBe(firstName); - expect(secondName).not.toBe(derivedSocketPath(dir)); }, 15_000); it.skipIf(process.platform === 'win32')('publishes the name in a file only this user can read', async () => { @@ -181,25 +178,6 @@ describe('published endpoint', () => { expect((await stat(endpointFile())).mode & 0o777).toBe(0o600); }); - it('is not held off by another account that took the derived name first', async () => { - // The squat this design closes: anything can sit on the derived name, and - // a new build brokers on its own fresh name regardless. - const squatted: Socket[] = []; - const squatter = createServer((socket) => void squatted.push(socket)); - const legacy = derivedSocketPath(dir); - if (process.platform !== 'win32') await mkdir(dirname(legacy), { recursive: true, mode: 0o700 }); - await listen(squatter, legacy); - - const broker = await openWindow(); - const peer = await openWindow(fakeWindow({ entries: [{ surfaceId: 'far-1' }] })); - await broker.ensurePeerNet(() => {}); - await peer.ensurePeerNet(() => {}); - expect(broker.isPeerBroker()).toBe(true); - expect(reachesBroker(peer)).toBe(true); - expect(await broker.remoteRequest('directory', {})).toEqual([{ surfaceId: 'far-1' }]); - for (const socket of squatted) socket.destroy(); - }, 15_000); - it('retires a stale file naming a broker that is gone', async () => { const dead = spareName(); await publish(dead, await deadPid());