@henols/vice-mcp 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // The ONLY writer of tools-manifest.json (see vice-proxy.mjs's tools/list
3
+ // handler doc comment -- that handler is a pure, offline READ of the file
4
+ // this CLI produces). An operator runs this by hand against a running host
5
+ // to refresh the snapshot; vice-proxy.mjs never imports this file, so this
6
+ // file's stdout is a normal CLI stream, not the MCP channel.
7
+ //
8
+ // Sibling import: the transport module lives in this skill's own scripts/
9
+ // directory (plan 01.1-04 relocated it from the now-retired `vice-session`
10
+ // skill).
11
+ import { serverInfo, activeInstance, type ServerInfoPayload, type ToolInfo } from "./vice.ts";
12
+ import { writeFileSync, chmodSync, renameSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join, resolve } from "node:path";
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url));
17
+ const DEFAULT_MANIFEST_PATH = join(HERE, "tools-manifest.json");
18
+
19
+ function manifestPath(): string {
20
+ return process.env.VICE_TOOLS_MANIFEST
21
+ ? resolve(process.env.VICE_TOOLS_MANIFEST)
22
+ : DEFAULT_MANIFEST_PATH;
23
+ }
24
+
25
+ export interface ToolsManifest {
26
+ generated_at: string;
27
+ endpoint: string;
28
+ tools: ToolInfo[];
29
+ }
30
+
31
+ /** Write `manifest` to `path` via the tmp sibling -> restrict-mode -> content
32
+ * -> rename sequence (01.6.1-06 Decision 1), copied verbatim in shape from
33
+ * vice-broker.mts's writeBrokerRecord(). The tmp file is created EMPTY and
34
+ * mode-tightened to 0o600 BEFORE any content reaches it, then renamed into
35
+ * place -- so a crash or a full disk mid-write can only ever leave a stray
36
+ * tmp sibling behind, never a partial or empty file at the real path. This
37
+ * is what makes this module's own long-standing promise ("a partial or
38
+ * empty manifest is never written over a good one on failure") true against
39
+ * a crash, not only against a rejected handshake -- the rejected-handshake
40
+ * half of that guarantee already held (the early return below, unchanged
41
+ * from the original), but a crash between "open the file" and "write the
42
+ * content" could previously still truncate a good manifest to nothing. */
43
+ function writeManifestAtomic(path: string, manifest: ToolsManifest): void {
44
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
45
+ writeFileSync(tmpPath, "");
46
+ chmodSync(tmpPath, 0o600);
47
+ writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + "\n");
48
+ renameSync(tmpPath, path);
49
+ }
50
+
51
+ /** Parses no arguments (there are none), performs the host handshake, and
52
+ * writes the manifest -- or leaves the existing one untouched and sets a
53
+ * non-zero exit code if the handshake fails. Exported (01.6.1-06 Decision 2)
54
+ * so this can be driven directly in a test without spawning a process; the
55
+ * bottom-of-module entry-point guard below is what makes that safe -- import
56
+ * alone must never perform a handshake or write anything. */
57
+ export async function main(): Promise<void> {
58
+ const path = manifestPath();
59
+
60
+ // serverInfo() performs the host handshake (initialize + tools/list) and
61
+ // already strips DENY_LIST names before returning (vice.ts's own
62
+ // documented choke point) -- vice-proxy.mjs's tools/list handler applies
63
+ // the SAME filter again at read time, so a snapshot generated by any other
64
+ // means is still safe, but this refresh path inherits the omission for
65
+ // free.
66
+ let info: ServerInfoPayload;
67
+ try {
68
+ info = (await serverInfo()) as ServerInfoPayload;
69
+ } catch (e) {
70
+ const message = e instanceof Error ? e.message : String(e);
71
+ console.error(
72
+ `refresh-manifest: could not reach the host VICE MCP server (${message}) -- ` +
73
+ `manifest at ${path} left UNCHANGED. A partial or empty manifest is never written over a good one on ` +
74
+ `failure -- whether the failure is a rejected handshake (this path) or a crash mid-write, the latter now ` +
75
+ `also covered by the tmp-sibling -> restrict-mode -> content -> rename sequence below.`
76
+ );
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ const tools = Array.isArray(info?.tools) ? info.tools : [];
82
+ const manifest: ToolsManifest = {
83
+ generated_at: new Date().toISOString(),
84
+ endpoint: activeInstance().url,
85
+ tools,
86
+ };
87
+
88
+ writeManifestAtomic(path, manifest);
89
+ console.log(`refresh-manifest: wrote ${tools.length} tool${tools.length === 1 ? "" : "s"} to ${path}`);
90
+ }
91
+
92
+ // -------------------------------------------------------------------- CLI
93
+ // Guarded exactly like vice-broker.mts's own entry point: importing this
94
+ // module (e.g. from a test) must never itself perform a handshake or write
95
+ // anything. process.exitCode, never process.exit(), so any pending I/O
96
+ // flushes first.
97
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
98
+ main().catch((e: unknown) => {
99
+ const detail = e instanceof Error && e.stack ? e.stack : String(e);
100
+ console.error(`refresh-manifest: unexpected error -- ${detail}`);
101
+ process.exitCode = 1;
102
+ });
103
+ }
package/repo-root.ts ADDED
@@ -0,0 +1,198 @@
1
+ // The ONE shared place every module in this directory resolves the repo
2
+ // root through (D-2). Everything else in this module tree -- vice.mjs's
3
+ // EPOCH_FILE, vice-broker-client.mjs's brokerRootDir() (and, before their
4
+ // 2026-08-02 deletion, vice-pool.mjs's poolDir() and vice-session.mjs's
5
+ // sessionFilePath()) -- derives its `.vice-supervisor` path through
6
+ // supervisorDir() below, so there is exactly one definition of both "where
7
+ // is the repo root" and "what is the shared state directory called".
8
+ //
9
+ // WHY THIS FILE EXISTS AT ALL: originally, each of the three modules
10
+ // resolved the repo root with a fixed `resolve(dirname(SELF), "..", ...)` --
11
+ // ONE level up from the module's own file. That was correct while the
12
+ // modules lived in `tools/` (one level up from `tools/` IS the repo root),
13
+ // but a move put them THREE levels deeper, at `.claude/skills/vice-session/`'s
14
+ // `scripts/` directory (the original, now-retired home; plan 01.1-04
15
+ // relocated it again, into the `vice-mcp-selector` skill, at the same
16
+ // depth). A naive move that kept the old fixed `".."` would have silently
17
+ // resolved to `.claude/skills/.vice-supervisor` or
18
+ // `.claude/skills/vice-session/.vice-supervisor` instead of
19
+ // `<repo>/.vice-supervisor` -- a directory the host-side shell launcher
20
+ // (`tools/vice-launcher.sh`, plan 11's surviving script -- the paired
21
+ // implementation this era's now-retired `tools/vice-supervisor.sh` and
22
+ // `tools/vice-pool.sh` used to be) never writes to. NOTHING would have
23
+ // errored: the container would just read a permanently-empty
24
+ // epoch/registry/session directory, and restart detection (and the pool,
25
+ // and sessions) would quietly stop working while every command kept
26
+ // "succeeding". That failure mode -- a broken invariant with no error
27
+ // anywhere -- is exactly the class of bug this codebase keeps rejecting
28
+ // elsewhere (see vice.mjs's MachineRestartedError, vice-session.mjs's
29
+ // epoch-continuity guard). Do not reintroduce a fixed `".."` (or any other
30
+ // relative-to-this-file hop count) in place of this resolver; if the
31
+ // directory depth of this module tree ever changes again, the ladder below
32
+ // still gets the right answer without anyone having to count directories by
33
+ // hand.
34
+ //
35
+ // THIRD MOVE (quick-260731-p8a): the implementation relocated again, out of
36
+ // the `vice-mcp-selector` skill's `scripts/` into a new, flattened,
37
+ // non-skill `.claude/mcp/vice/` directory -- ONE level SHALLOWER than the
38
+ // old `.claude/skills/<skill>/scripts/` shape, since flattening removed the
39
+ // `scripts/` segment. Branch 4's hop count below moved from four levels to
40
+ // three to match. Branches 1-3 are depth-independent (an env var check, then
41
+ // a `.git` ancestor walk) and needed no change.
42
+ import { existsSync } from "node:fs";
43
+ import { fileURLToPath } from "node:url";
44
+ import { dirname, join, resolve, sep } from "node:path";
45
+
46
+ import { ensureResourcesInstalled } from "./install-resources.ts";
47
+
48
+ const HERE = dirname(fileURLToPath(import.meta.url));
49
+
50
+ // Gates the two "last resort" stderr notes below so a long-running process
51
+ // (or a test suite driving this module many times) emits each at most once,
52
+ // rather than spamming stderr on every single call.
53
+ let warnedEnvOutsideFrom = false;
54
+ let warnedNoMarkerFound = false;
55
+
56
+ /** Options accepted by repoRoot()/supervisorDir(): `from` overrides the
57
+ * caller location the ladder resolves relative to (defaults to this file's
58
+ * own location, HERE), and `env` overrides the environment it reads
59
+ * CONTAINER_WORKSPACE_PATH from (defaults to process.env) -- both exist so
60
+ * the ladder is deterministically testable without mutating real process
61
+ * state, per repo-root.test.ts's own injection idiom. */
62
+ export interface RepoRootOptions {
63
+ from?: string;
64
+ env?: NodeJS.ProcessEnv;
65
+ }
66
+
67
+ /**
68
+ * True iff `child` is `parent` itself or lies inside it, compared as plain
69
+ * resolved path strings (no filesystem access) -- deliberately not a symlink-
70
+ * aware realpath comparison, since CONTAINER_WORKSPACE_PATH and this file's
71
+ * own location are both already resolved, non-symlinked container paths in
72
+ * every case this project runs in.
73
+ */
74
+ function isInside(child: string, parent: string): boolean {
75
+ const c = resolve(child);
76
+ const p = resolve(parent);
77
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
78
+ }
79
+
80
+ /**
81
+ * Resolve the repository root. Precedence, in order (D-2):
82
+ *
83
+ * 0. `env.CLAUDE_PROJECT_DIR`, when set -- the authoritative project root
84
+ * Claude Code exports for the workspace it is driving. This is the ONLY
85
+ * branch that is correct when this module is consumed as an installed
86
+ * plugin: the MCP's own files then live under the plugin install dir
87
+ * (e.g. `~/.claude/plugins/<marketplace>/<plugin>/.claude/mcp/vice/`),
88
+ * NOT inside the project the user is working in, so neither the
89
+ * `from`-relative `.git` walk (branch 2, which would find the plugin's
90
+ * OWN checkout) nor a CONTAINER_WORKSPACE_PATH containment check
91
+ * (branch 1, which fails the containment test) can reach the project
92
+ * root. In the in-repo, non-plugin layout this variable is either unset
93
+ * (the test injections below, a bare host) or already equal to the repo
94
+ * root, so honouring it first is a safe no-op there and the branches
95
+ * below are unchanged.
96
+ * 1. `env.CONTAINER_WORKSPACE_PATH`, when set AND `from` resolves inside
97
+ * it -- this devcontainer sets it (`.devcontainer/devcontainer.json`'s
98
+ * `containerEnv`, value `/workspaces/bruce_lee`), and it is the most
99
+ * explicit signal available.
100
+ * 2. Otherwise, walk up from `from` toward the filesystem root, returning
101
+ * the first directory containing a `.git` entry (`existsSync` on the
102
+ * joined path -- matches both a real `.git` directory and a worktree's
103
+ * `.git` file). This is what keeps the skill correct once exported into
104
+ * a project that sets no such variable at all.
105
+ * 3. Otherwise, `env.CONTAINER_WORKSPACE_PATH` if it is set at all (just
106
+ * not containing `from` -- an exported copy of this skill living
107
+ * outside the mounted workspace the variable names). Silence here would
108
+ * be exactly the quiet-wrong-answer failure class this file exists to
109
+ * prevent, so this path emits a one-time stderr note naming both paths.
110
+ * 4. Otherwise, three levels up from `from`, with a one-time stderr note.
111
+ * Last resort only -- three levels is what `<root>/.claude/mcp/<server>/`
112
+ * implies. In this repo branch 4 never actually runs (there is always a
113
+ * `.git` ancestor), which is exactly why the paired synthetic test in
114
+ * repo-root.test.ts is the only thing that would catch a wrong hop
115
+ * count here.
116
+ */
117
+ export function repoRoot({ from = HERE, env = process.env }: RepoRootOptions = {}): string {
118
+ // Branch 0 (plugin-consumption signal): Claude Code sets CLAUDE_PROJECT_DIR
119
+ // to the root of the workspace it is driving. When this module runs as an
120
+ // installed plugin its own files sit outside that workspace, so this is the
121
+ // only signal that points at the user's project rather than the plugin's
122
+ // install dir. Unset / equal-to-root in the in-repo layout, so it is a
123
+ // no-op there.
124
+ const projectDir = env.CLAUDE_PROJECT_DIR;
125
+ if (projectDir) {
126
+ return resolve(projectDir);
127
+ }
128
+
129
+ const cwp = env.CONTAINER_WORKSPACE_PATH;
130
+
131
+ if (cwp && isInside(from, cwp)) {
132
+ return resolve(cwp);
133
+ }
134
+
135
+ let dir = resolve(from);
136
+ while (true) {
137
+ if (existsSync(join(dir, ".git"))) {
138
+ return dir;
139
+ }
140
+ const parent = dirname(dir);
141
+ if (parent === dir) break; // reached the filesystem root -- no .git found anywhere above `from`
142
+ dir = parent;
143
+ }
144
+
145
+ if (cwp) {
146
+ if (!warnedEnvOutsideFrom) {
147
+ warnedEnvOutsideFrom = true;
148
+ console.error(
149
+ `warn: CONTAINER_WORKSPACE_PATH is set (${cwp}) but does not contain ${from}, and no .git ` +
150
+ `ancestor was found either -- falling back to CONTAINER_WORKSPACE_PATH itself as the repo root. ` +
151
+ `This is expected for an exported copy of this skill living outside its mounted workspace; if ` +
152
+ `that is not the situation here, the repo root this resolved to may be wrong.`
153
+ );
154
+ }
155
+ return resolve(cwp);
156
+ }
157
+
158
+ if (!warnedNoMarkerFound) {
159
+ warnedNoMarkerFound = true;
160
+ const fallback = resolve(from, "..", "..", "..");
161
+ console.error(
162
+ `warn: could not find a .git ancestor above ${from} and CONTAINER_WORKSPACE_PATH is not set -- ` +
163
+ `falling back to three levels up (${fallback}), the shape <root>/.claude/mcp/<server>/ implies. ` +
164
+ `This is a last resort; if it's wrong, set CONTAINER_WORKSPACE_PATH or run from inside a git repo.`
165
+ );
166
+ }
167
+ return resolve(from, "..", "..", "..");
168
+ }
169
+
170
+ /** The one shared directory name every module in this skill reads/writes
171
+ * host-synchronised state through -- `join(repoRoot(...), ".vice-supervisor")`,
172
+ * so the literal directory name also has exactly one definition. */
173
+ export function supervisorDir(opts: RepoRootOptions = {}): string {
174
+ return join(repoRoot(opts), ".vice-supervisor");
175
+ }
176
+
177
+ // Fires once per process, on whatever entry point happens to import THIS
178
+ // module -- which is vice.mjs and vice-broker-client.mjs already (both
179
+ // import repoRoot()/supervisorDir()), among other modules in this tree,
180
+ // plus vice-probe.ts's own side-effect-only import (see that file). This
181
+ // one call is what makes the
182
+ // deploy-on-first-use check (quick-260730-q4b, D-3) fire for every skill
183
+ // .mjs entry point without any of them referencing install-resources.ts
184
+ // directly.
185
+ //
186
+ // POSITION IS LOAD-BEARING: this must run at the BOTTOM of this module body,
187
+ // after HERE, repoRoot() and supervisorDir() are all initialised. Moving it
188
+ // above HERE's initialisation reintroduces the exact module-cycle TDZ crash
189
+ // install-resources.ts's own header describes ("Cannot access 'HERE' before
190
+ // initialization") -- install-resources.ts takes the repo root as an
191
+ // argument specifically so it never needs to import this file back.
192
+ try {
193
+ ensureResourcesInstalled({ root: repoRoot() });
194
+ } catch {
195
+ // ensureResourcesInstalled() already never throws (D-3) -- this catch is
196
+ // belt-and-suspenders against a future change to that contract, not a
197
+ // signal that one is expected.
198
+ }
@@ -0,0 +1,343 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from broker-control.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // broker-control.mts
8
+ //
9
+ // N / D-01 (plan 01, tracer): the framing, the token gate, and acquire/
10
+ // release. THIS PLAN (05) completes the message set: recycle, status,
11
+ // host_state, the arrival-ordered pending-acquire structure, and the
12
+ // kernel-enforced singleton guard's low-level bind primitive. The
13
+ // subsystem's FIRST network listener: a TCP control plane replacing the
14
+ // bash broker's requests/grants/denials/leases directory tree entirely. One
15
+ // JSON object per line; the connection open IS the claim, connection close
16
+ // IS the release (T-01.6.2-01 through -09).
17
+ //
18
+ // Wire format confirmed at plan 01's blocking checkpoint:decision
19
+ // (2026-08-03, `as-specified`, no amendments -- see .planning/RE-FINDINGS.md
20
+ // for the full record, including the two accepted residual risks and the
21
+ // unix-domain-socket dead end). Auth: per-boot capability token compared
22
+ // constant-time, checked BEFORE any state read or write. Bind: 0.0.0.0
23
+ // explicitly, never 127.0.0.1 -- host.docker.internal is the bridge
24
+ // address, not loopback, so a loopback-only listener is structurally
25
+ // unreachable from the container. Port: 19510 default via
26
+ // VICE_BROKER_CONTROL_PORT.
27
+ import { createServer } from "node:net";
28
+ import { timingSafeEqual, randomBytes } from "node:crypto";
29
+ /** 32 cryptographically random bytes rendered as hex -- the per-boot
30
+ * capability token. Held in memory only by the caller; written once into
31
+ * broker.json and never logged, never included in an error message
32
+ * (T-01.6.2-02). */
33
+ export function newControlToken() {
34
+ return randomBytes(32).toString("hex");
35
+ }
36
+ const MAX_LINE_BYTES = 65536;
37
+ export function resolveControlPort(override) {
38
+ if (typeof override === "number")
39
+ return override;
40
+ const raw = process.env.VICE_BROKER_CONTROL_PORT;
41
+ if (raw === undefined || raw === "")
42
+ return 19510;
43
+ const n = Number(raw);
44
+ return Number.isFinite(n) ? n : 19510;
45
+ }
46
+ /** Constant-time token comparison over EQUAL-LENGTH buffers -- an
47
+ * unequal-length comparison is refused without ever calling
48
+ * timingSafeEqual (which throws on a length mismatch), so the length check
49
+ * itself leaks nothing beyond what a fixed-length comparison already
50
+ * would not avoid. */
51
+ function tokensMatch(candidate, expected) {
52
+ const a = Buffer.from(candidate, "utf8");
53
+ const b = Buffer.from(expected, "utf8");
54
+ if (a.length !== b.length)
55
+ return false;
56
+ return timingSafeEqual(a, b);
57
+ }
58
+ function writeLine(socket, obj) {
59
+ if (socket.writable) {
60
+ socket.write(`${JSON.stringify(obj)}\n`);
61
+ }
62
+ }
63
+ function defaultRequestId(prefix) {
64
+ return `${prefix}-${process.pid}-${Date.now()}`;
65
+ }
66
+ /** Appends to the BACK of the queue -- the only mutation this structure
67
+ * ever performs on receipt. Nothing here sorts or re-orders; arrival order
68
+ * falls out of the array's own insertion order. */
69
+ export function enqueueAcquire(queue, entry) {
70
+ queue.push(entry);
71
+ }
72
+ /** Drains the queue from the front, strictly in the order this CALL found
73
+ * them: takes a snapshot of everything currently pending (`splice`, never a
74
+ * sort), then attempts each in that order. An entry whose launch is still
75
+ * in flight is pushed back onto the queue for the NEXT drain pass rather
76
+ * than retried immediately in a tight loop -- a later-arriving acquire that
77
+ * queued behind it during THIS pass is not overtaken (it is appended after
78
+ * the requeued entry, never before), so the array never needs re-ordering
79
+ * to stay correct; a genuinely adversarial retry pattern could still starve
80
+ * an entry across MULTIPLE passes, which is exactly the direct fairness
81
+ * proof this module deliberately does not author -- injecting N acquires
82
+ * and asserting grants return in that order is Phase 01.6.2.1's D-08
83
+ * deliverable. The original defect this queue replaces (a lexical iteration
84
+ * over `req-<pid>-<ms>-<hex>` filenames) cannot exist here regardless: there
85
+ * is no file, and no re-ordering call of any kind anywhere in this region. */
86
+ export async function drainPendingAcquires(queue) {
87
+ const snapshot = queue.splice(0, queue.length);
88
+ for (const entry of snapshot) {
89
+ const settled = await entry.attempt();
90
+ if (!settled) {
91
+ queue.push(entry);
92
+ }
93
+ }
94
+ }
95
+ /** Binds a bare TCP listener with NO protocol wired up -- no token check, no
96
+ * request handling, nothing. `startControlListener()` below calls this
97
+ * internally and then attaches the real protocol; a test wanting to occupy
98
+ * a control port with "something that is not a broker" (the loud singleton
99
+ * path's own fixture) can call this directly and never see anything that
100
+ * looks like this broker's wire format. */
101
+ export function bindControlListener(host, port) {
102
+ return new Promise((resolvePromise, reject) => {
103
+ const server = createServer();
104
+ server.on("error", reject);
105
+ server.listen(port, host, () => {
106
+ const addr = server.address();
107
+ const boundPort = typeof addr === "object" && addr !== null ? addr.port : port;
108
+ resolvePromise({ server, port: boundPort, host });
109
+ });
110
+ });
111
+ }
112
+ /** Attaches the newline-delimited-JSON protocol (framing, token gate, all
113
+ * five request kinds) to an ALREADY-BOUND server. Split out of
114
+ * startControlListener() so the bind step and the protocol-wiring step are
115
+ * two separately callable primitives -- the real broker still calls
116
+ * startControlListener() as one step (this function is not part of its own
117
+ * public surface); this module's own tests exercise the two independently. */
118
+ function attachControlProtocol(server, opts, pendingAcquires) {
119
+ server.on("connection", (socket) => {
120
+ let buffer = "";
121
+ let requestIdForThisConnection = null;
122
+ socket.on("data", (chunk) => {
123
+ buffer += chunk.toString("utf8");
124
+ if (Buffer.byteLength(buffer, "utf8") > MAX_LINE_BYTES) {
125
+ socket.destroy();
126
+ return;
127
+ }
128
+ let newlineIdx;
129
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
130
+ const line = buffer.slice(0, newlineIdx);
131
+ buffer = buffer.slice(newlineIdx + 1);
132
+ handleLine(line);
133
+ }
134
+ });
135
+ socket.on("close", () => {
136
+ // Connection close IS the release -- including on the client's own
137
+ // SIGKILL, since "close" always fires either way. Idempotent: an
138
+ // explicit `release` already having cleared
139
+ // requestIdForThisConnection makes this a no-op.
140
+ if (requestIdForThisConnection) {
141
+ const id = requestIdForThisConnection;
142
+ requestIdForThisConnection = null;
143
+ opts.onRelease(id);
144
+ }
145
+ });
146
+ socket.on("error", () => {
147
+ // Per-connection error handling isolates one peer's failure from
148
+ // every other connection and from the server itself (T-01.6.2-06).
149
+ });
150
+ /** Attempts one acquire over THIS connection/socket, writing the
151
+ * terminal response (grant or a non-queueing error) when settled, or
152
+ * enqueueing itself and returning unsettled when a launch is already in
153
+ * flight. Shared by the immediate first attempt and every later retry
154
+ * `drainPendingAcquires()` drives, so the two paths can never answer
155
+ * differently for the same requestId.
156
+ *
157
+ * Gap closure (plan 14, WR-03/T-01.6.2-87/-88): two destroyed-socket
158
+ * checks guard a grant against outliving the connection that owns it,
159
+ * and they bound TWO DIFFERENT failures -- do not conflate them into one
160
+ * claim.
161
+ *
162
+ * Half one -- the pre-check immediately below, BEFORE onAcquire() is
163
+ * ever called -- closes the ALWAYS-REACHABLE leak: a client that
164
+ * disconnects while queued leaves its entry pending (nothing removes it,
165
+ * since it never held a grant id to release), and the next drain pass
166
+ * would otherwise call the launch callback anyway -- which on the real
167
+ * broker allocates a port, spawns a real child, writes an epoch record
168
+ * and records a grant that no connection owns. This half turns that
169
+ * always-reachable leak into a bounded race (half two, below).
170
+ *
171
+ * Half two -- the release-on-late-grant branch on the success path --
172
+ * bounds the NARROW race the pre-check cannot close: a disconnect
173
+ * landing between the pre-check passing and onAcquire()'s own
174
+ * completion. This half does NOT eliminate that race -- it cannot, the
175
+ * pre-check and the callback are separated by a real await -- it turns
176
+ * the race from a leak into a reclaim, by invoking the existing release
177
+ * callback with the same request id instead of silently dropping the
178
+ * grant it produced.
179
+ */
180
+ function attemptAcquire(requestId) {
181
+ // Half one: a queued entry whose owning socket is already gone is
182
+ // settled immediately, WITHOUT ever calling onAcquire() -- this is
183
+ // what keeps a retried drain pass from performing a real, ownerless
184
+ // launch.
185
+ if (socket.destroyed)
186
+ return Promise.resolve(true);
187
+ return opts
188
+ .onAcquire(requestId)
189
+ .then((outcome) => {
190
+ if (outcome.ok) {
191
+ // Half two: the pre-check above ran before this call; a
192
+ // disconnect landing DURING the await is still possible and is
193
+ // bounded, not eliminated, here -- a grant that settles for a
194
+ // socket that is now gone is released through the existing
195
+ // release path rather than dropped.
196
+ if (socket.destroyed) {
197
+ opts.onRelease(requestId);
198
+ return true;
199
+ }
200
+ requestIdForThisConnection = requestId;
201
+ writeLine(socket, {
202
+ kind: "grant",
203
+ id: requestId,
204
+ port: outcome.grant.port,
205
+ url: outcome.grant.url,
206
+ epoch_file: outcome.grant.epochFile,
207
+ supervisor_dir: outcome.grant.supervisorDir,
208
+ });
209
+ return true;
210
+ }
211
+ if (outcome.reason === "launch_in_flight") {
212
+ return false; // still blocked -- caller re-queues
213
+ }
214
+ if (socket.destroyed)
215
+ return true; // no grant was produced -- nothing to release, nothing left to answer
216
+ const code = outcome.reason === "internal" ? "internal" : outcome.reason;
217
+ writeLine(socket, { kind: "error", code, message: `acquire failed: ${outcome.reason}` });
218
+ return true;
219
+ })
220
+ .catch(() => {
221
+ if (!socket.destroyed) {
222
+ writeLine(socket, { kind: "error", code: "internal", message: "acquire threw" });
223
+ }
224
+ return true;
225
+ });
226
+ }
227
+ function handleLine(line) {
228
+ if (line.trim() === "")
229
+ return;
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(line);
233
+ }
234
+ catch {
235
+ writeLine(socket, { kind: "error", code: "bad_request", message: "malformed JSON line" });
236
+ return;
237
+ }
238
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
239
+ writeLine(socket, { kind: "error", code: "bad_request", message: "request must be a JSON object" });
240
+ return;
241
+ }
242
+ const req = parsed;
243
+ // Token check BEFORE any state is read or written -- absence or
244
+ // mismatch is refused, the connection is destroyed, and nothing is
245
+ // allocated, spawned or signalled (T-01.6.2-01, T-01.6.2-03).
246
+ const token = typeof req.token === "string" ? req.token : "";
247
+ if (!tokensMatch(token, opts.token)) {
248
+ writeLine(socket, { kind: "error", code: "unauthorized", message: "missing or invalid control token" });
249
+ socket.destroy();
250
+ return;
251
+ }
252
+ if (req.op === "acquire") {
253
+ const requestId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("req");
254
+ void attemptAcquire(requestId).then((settled) => {
255
+ if (!settled) {
256
+ enqueueAcquire(pendingAcquires, { requestId, attempt: () => attemptAcquire(requestId) });
257
+ }
258
+ });
259
+ }
260
+ else if (req.op === "release") {
261
+ if (requestIdForThisConnection) {
262
+ const id = requestIdForThisConnection;
263
+ requestIdForThisConnection = null;
264
+ opts.onRelease(id);
265
+ }
266
+ writeLine(socket, { kind: "released" });
267
+ }
268
+ else if (req.op === "recycle") {
269
+ const recycleId = typeof req.id === "string" && req.id !== "" ? req.id : defaultRequestId("recycle");
270
+ const targetId = typeof req.target_id === "string" ? req.target_id : "";
271
+ // T-01.6.2-31: a connection may only recycle the grant IT ITSELF
272
+ // holds. This check happens here, before onRecycle() is ever
273
+ // called, so a mismatched target never reaches the kill discipline
274
+ // and never signals anything -- an injected signal recorder stays
275
+ // empty for this case.
276
+ if (requestIdForThisConnection === null || targetId !== requestIdForThisConnection) {
277
+ writeLine(socket, {
278
+ kind: "error",
279
+ code: "denied",
280
+ message: "recycle may only target the grant this connection itself holds",
281
+ });
282
+ return;
283
+ }
284
+ opts
285
+ .onRecycle(targetId)
286
+ .then((result) => {
287
+ writeLine(socket, {
288
+ kind: "recycle_ack",
289
+ id: recycleId,
290
+ target_id: targetId,
291
+ port: result.port,
292
+ x64sc_pid: result.pid,
293
+ vice_bin: result.viceBin,
294
+ kill_stage: result.killStage,
295
+ epoch_before: result.epochBefore,
296
+ outcome: result.outcome,
297
+ reason: result.reason,
298
+ });
299
+ })
300
+ .catch(() => {
301
+ writeLine(socket, { kind: "error", code: "internal", message: "recycle threw" });
302
+ });
303
+ }
304
+ else if (req.op === "status") {
305
+ writeLine(socket, { kind: "status", instances: opts.onStatus() });
306
+ }
307
+ else if (req.op === "host_state") {
308
+ const hs = opts.onHostState();
309
+ writeLine(socket, {
310
+ kind: "host_state",
311
+ pid: hs.pid,
312
+ started_at: hs.startedAt,
313
+ node_version: hs.nodeVersion,
314
+ vice_bin: hs.viceBin,
315
+ warm_floor: hs.warmFloor,
316
+ max_instances: hs.maxInstances,
317
+ base_port: hs.basePort,
318
+ });
319
+ }
320
+ else {
321
+ writeLine(socket, { kind: "error", code: "bad_request", message: `unknown op: ${String(req.op)}` });
322
+ }
323
+ }
324
+ });
325
+ }
326
+ /** Starts the TCP control listener: binds (bindControlListener()), then
327
+ * attaches the full newline-delimited-JSON protocol (attachControlProtocol()
328
+ * above) -- all five request kinds, the token gate, and the arrival-ordered
329
+ * pending-acquire queue this listener instance owns. Frames inbound bytes as
330
+ * newline-delimited JSON: buffers, splits on "\n", parses each line with
331
+ * the never-throw posture this codebase already uses for untrusted input --
332
+ * a malformed line answers `bad_request` and the connection survives. A
333
+ * connection exceeding MAX_LINE_BYTES without a newline is destroyed rather
334
+ * than buffered further (T-01.6.2-04). */
335
+ export function startControlListener(opts) {
336
+ const host = opts.host ?? process.env.VICE_BROKER_CONTROL_HOST ?? "0.0.0.0";
337
+ const port = resolveControlPort(opts.port);
338
+ return bindControlListener(host, port).then((bound) => {
339
+ const pendingAcquires = [];
340
+ attachControlProtocol(bound.server, opts, pendingAcquires);
341
+ return { server: bound.server, port: bound.port, host: bound.host, pendingAcquires };
342
+ });
343
+ }