@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.
- package/README.md +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
|
@@ -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
|
+
}
|