@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
package/vice-proxy.ts
ADDED
|
@@ -0,0 +1,3093 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A stdio MCP server that forwards to the host VICE MCP server over HTTP.
|
|
3
|
+
// Claude Code spawns exactly one copy of this per session (per its own
|
|
4
|
+
// `.mcp.json` `vice` entry) and speaks newline-delimited JSON-RPC 2.0 to it
|
|
5
|
+
// over stdin/stdout. This file owns ONLY that stdio-server-facing half --
|
|
6
|
+
// the HTTP-client half (retry ladder, SSE-body parsing, the vice_disk_list
|
|
7
|
+
// deny-list, epoch-based restart detection) is `call()` and its siblings,
|
|
8
|
+
// imported unchanged from the transport module, now a sibling in this
|
|
9
|
+
// skill's own `scripts/` directory (plan 01.1-04 relocated it from
|
|
10
|
+
// `vice-session`). Re-implementing that half here would duplicate code that
|
|
11
|
+
// has already survived six real host outages; see 01.1-RESEARCH.md's
|
|
12
|
+
// "Don't Hand-Roll" table.
|
|
13
|
+
//
|
|
14
|
+
// Sibling import, no longer cross-skill: `vice-session` has been retired
|
|
15
|
+
// (plan 01.1-04) and its transport module tree lives here now.
|
|
16
|
+
//
|
|
17
|
+
// D-01 / ROLLBACK PATH (Phase 01.6.3, plans 01.6.3-01..04): this file's
|
|
18
|
+
// stdio-server-facing wire layer is `@mastra/mcp`'s `MCPServer` +
|
|
19
|
+
// `startStdio()`, adopted per a developer decision AGAINST 01.6-RESEARCH.md
|
|
20
|
+
// §B6's HIGH-confidence recommendation to stay fully hand-rolled. The
|
|
21
|
+
// ROADMAP rates this adoption "reversible but costly -- not one-way": the
|
|
22
|
+
// ~88-94% of hand-rolled logic below (broker leasing, epoch/liveness,
|
|
23
|
+
// recycle/diagnose, deny-list enforcement, path rewriting, incident
|
|
24
|
+
// capture, the ten broker-state message builders -- enumerated exhaustively
|
|
25
|
+
// in 01.6-PATTERNS.md's "Pattern: The D-01 Seam") never moved and is not
|
|
26
|
+
// part of what a rollback touches.
|
|
27
|
+
//
|
|
28
|
+
// (a) What @mastra/mcp now owns, deleted from this file by the swap:
|
|
29
|
+
// writeMessage() / respond() / errorResponse() / handleInitialize() /
|
|
30
|
+
// handleToolsList() / handleMessage() / handleLine() / the stdin
|
|
31
|
+
// read loop / ProtocolError -- the entire hand-rolled JSON-RPC 2.0
|
|
32
|
+
// framing and protocol-version-negotiation layer (~140-300 lines,
|
|
33
|
+
// 6-12% of the pre-swap file, per RESEARCH.md §B4's structural
|
|
34
|
+
// measurement). Superseded by `new MCPServer({name, version, tools})`
|
|
35
|
+
// + `await server.startStdio()`, plus a `CallToolRequestSchema`
|
|
36
|
+
// override installed via `server.getServer().setRequestHandler(...)`
|
|
37
|
+
// immediately after `startStdio()` resolves (tools/call is NOT
|
|
38
|
+
// answered by MCPServer's own dispatch -- see COVERAGE.md's
|
|
39
|
+
// `tools/call routing skeleton` row for why: MCPServer's dispatch
|
|
40
|
+
// forces `isError:false` on success and prepends "Error: " on
|
|
41
|
+
// failure, neither of which matches this proxy's `{content,isError}`
|
|
42
|
+
// contract or the deny-list's pinned refusal text).
|
|
43
|
+
// (b) Rollback steps, concretely, for a future session that needs to
|
|
44
|
+
// execute this rather than re-derive it:
|
|
45
|
+
// 1. Re-author writeMessage()/respond()/errorResponse()/
|
|
46
|
+
// handleInitialize()/handleToolsList()/handleMessage()/
|
|
47
|
+
// handleLine()/the stdin loop/ProtocolError from
|
|
48
|
+
// 01.6-PATTERNS.md's "Pattern: The D-01 Seam" section, which
|
|
49
|
+
// quotes their pre-swap bodies verbatim, cross-checked against
|
|
50
|
+
// this file's own git history at commits a27628b (the swap that
|
|
51
|
+
// deleted them) and its parent (the last commit where they still
|
|
52
|
+
// existed).
|
|
53
|
+
// 2. Re-point every tool's dispatch: each tool's `execute` body
|
|
54
|
+
// (`forwardToVice(name, args)`, UNCHANGED by the rollback -- it
|
|
55
|
+
// predates and outlives the swap) currently runs inside the
|
|
56
|
+
// `CallToolRequestSchema` override's per-tool lookup; re-wire that
|
|
57
|
+
// same lookup into a single hand-rolled `handleToolsCall()`
|
|
58
|
+
// dispatcher called from the resurrected `handleMessage()`.
|
|
59
|
+
// 3. Remove `@mastra/mcp`/`@mastra/core` from package.json's
|
|
60
|
+
// `dependencies` (added Phase 01.6.3 plan 01) and revert
|
|
61
|
+
// tsconfig.json's `skipLibCheck: true` (added plan 01.6.3-02 --
|
|
62
|
+
// see note below; safe to revert once nothing imports either
|
|
63
|
+
// package, since this project's own files typecheck clean with
|
|
64
|
+
// or without the flag).
|
|
65
|
+
// 4. Re-run the full vice-proxy.test.ts suite; the ~5,300-line suite
|
|
66
|
+
// exercises the wire layer directly (initialize/tools-list/
|
|
67
|
+
// tools-call shapes, the deny-list, malformed-input handling) so
|
|
68
|
+
// a clean rollback shows as 0 new failures against this same
|
|
69
|
+
// suite, not merely "it builds".
|
|
70
|
+
// (c) Recorded, permanent cost of D-01 that a rollback would UNDO:
|
|
71
|
+
// tsconfig.json's `skipLibCheck: true` (plan 01.6.3-02) is a genuine
|
|
72
|
+
// reduction in this directory's own type-checking strictness --
|
|
73
|
+
// @mastra/core@1.55.0 bundles internal ai-sdk-provider/zod-v4
|
|
74
|
+
// declaration files with real cross-version inconsistencies, visible
|
|
75
|
+
// to `tsc` only once anything imports from the package. This
|
|
76
|
+
// project's own source typechecks clean with or without the flag,
|
|
77
|
+
// but the flag means a future third-party dependency's OWN bundled
|
|
78
|
+
// `.d.ts` errors would no longer surface here either -- a protection
|
|
79
|
+
// every other file in this repo had by default before this phase.
|
|
80
|
+
// A rollback restores that protection as a side effect of removing
|
|
81
|
+
// the only import that ever needed the flag.
|
|
82
|
+
import {
|
|
83
|
+
call,
|
|
84
|
+
activeInstance,
|
|
85
|
+
useInstance,
|
|
86
|
+
DENY_LIST,
|
|
87
|
+
denyListRefusalMessage,
|
|
88
|
+
readEpoch,
|
|
89
|
+
beginSession,
|
|
90
|
+
MachineRestartedError,
|
|
91
|
+
mcpHost,
|
|
92
|
+
type ActiveInstance,
|
|
93
|
+
type EpochResult,
|
|
94
|
+
type SessionInfo,
|
|
95
|
+
type ToolInfo,
|
|
96
|
+
} from "./vice.ts";
|
|
97
|
+
// Sibling import, same relocation as above. probeInstance() is the
|
|
98
|
+
// deliberately-fragile liveness check (see that file's own header): one
|
|
99
|
+
// 1500ms-budget round trip, no retry, no dependency on vice.ts's resilient
|
|
100
|
+
// reconnect ladder.
|
|
101
|
+
import { probeInstance, type ProbeResult } from "./vice-probe.ts";
|
|
102
|
+
import { repoRoot } from "./repo-root.ts";
|
|
103
|
+
import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
|
|
104
|
+
// The INVERSE direction (host -> container), for inverting a broker grant's
|
|
105
|
+
// own host-local coordinates before useInstance() ever adopts them (this
|
|
106
|
+
// task, quick-260801-ccn). Consuming this from the proxy -- rather than
|
|
107
|
+
// hand-translating a host path here -- is what keeps the host-path consumer
|
|
108
|
+
// set closed to a fixed, traced list (vice-mcp-selector-docs.test.mjs's
|
|
109
|
+
// assertion 4, amended by this task to include containerpath.ts itself as
|
|
110
|
+
// a fifth, sibling consumer of hostpath.mjs's own knowledge).
|
|
111
|
+
import { containerizeRecord } from "./containerpath.ts";
|
|
112
|
+
// The container-side half of the on-demand broker protocol (Phase 01.2).
|
|
113
|
+
// This module deliberately does NOT import hostpath.mjs itself -- the
|
|
114
|
+
// host-path consumer set stays closed to four production modules
|
|
115
|
+
// (vice-mcp-selector-docs.test.mjs's assertion 4), and this file is already
|
|
116
|
+
// on that list, so any broker-related host path text is built HERE.
|
|
117
|
+
// Tasks 1+2 (this plan) swap acquisition, release AND recycle onto the TCP
|
|
118
|
+
// control session (openBrokerControl()/BrokerControlSession, plan 06's
|
|
119
|
+
// completed client) -- writeRequest/createLease/touchLease/releaseLease/
|
|
120
|
+
// pollGrant/startHeartbeat/requestsDir/newRequestId/writeRecycleRequest/
|
|
121
|
+
// pollRecycleAck are no longer imported: their whole job (write a request,
|
|
122
|
+
// create a lease file, heartbeat its mtime, poll for a grant or an
|
|
123
|
+
// acknowledgement, unlink on release) is now "send one request over the
|
|
124
|
+
// connection already held". RECYCLE_TIMEOUT_MS (the client's own recycle
|
|
125
|
+
// deadline, task 3's renamed successor to the now-deleted
|
|
126
|
+
// RECYCLE_ACK_TIMEOUT_MS) is reused below as the bound the post-kill
|
|
127
|
+
// epoch-and-readiness poll uses -- a concern this swap does not touch.
|
|
128
|
+
import {
|
|
129
|
+
readBrokerLiveness,
|
|
130
|
+
brokerRootDir,
|
|
131
|
+
RECYCLE_TIMEOUT_MS,
|
|
132
|
+
openBrokerControl,
|
|
133
|
+
type BrokerLivenessResult,
|
|
134
|
+
type BrokerControlSession,
|
|
135
|
+
type ControlFailureKind,
|
|
136
|
+
} from "./vice-broker-client.ts";
|
|
137
|
+
// The recycle path's own incident record (plan 01.3-01) -- written BEFORE
|
|
138
|
+
// anything is killed (D-17), never through any network call of its own.
|
|
139
|
+
// incidentAssetPath()/incidentAssetStem() (plan 01.3-03) are the SAME stem-
|
|
140
|
+
// building logic incidentRecordPath() itself uses -- imported here so the
|
|
141
|
+
// evidence gatherer's screenshot and the pre-kill snapshot's name can never
|
|
142
|
+
// drift onto a second, independent naming rule.
|
|
143
|
+
import {
|
|
144
|
+
writeIncidentRecord,
|
|
145
|
+
finaliseIncidentRecord,
|
|
146
|
+
incidentAssetPath,
|
|
147
|
+
incidentAssetStem,
|
|
148
|
+
type IncidentEvidence,
|
|
149
|
+
type IncidentAssetStemOptions,
|
|
150
|
+
} from "./incident-record.ts";
|
|
151
|
+
import { readFileSync } from "node:fs";
|
|
152
|
+
import { fileURLToPath } from "node:url";
|
|
153
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
154
|
+
// The wire-layer replacement (this plan, D-01): MCPServer owns tools/list's
|
|
155
|
+
// schema-conversion dispatch; the CallToolRequestSchema override installed
|
|
156
|
+
// below (immediately after startStdio(), see that call site's own comment)
|
|
157
|
+
// owns tools/call instead, so this file's own hand-rolled envelope survives
|
|
158
|
+
// unchanged even though the transport underneath it is now the SDK's own
|
|
159
|
+
// StdioServerTransport/Protocol. createTool()/noopObserve are the documented,
|
|
160
|
+
// public @mastra/core/tools API -- see this plan's "Ground truth" section for
|
|
161
|
+
// why a raw JSON Schema needs the rawJsonSchemaAsStandardSchema() adapter
|
|
162
|
+
// below rather than being passed to createTool() directly.
|
|
163
|
+
import { MCPServer } from "@mastra/mcp";
|
|
164
|
+
import { createTool, noopObserve } from "@mastra/core/tools";
|
|
165
|
+
import type { StandardSchemaWithJSON } from "@mastra/core/schema";
|
|
166
|
+
// A real, already-resolved transitive dependency of @mastra/mcp (Plan 01's
|
|
167
|
+
// Task 2 note) -- deliberately NOT added to package.json directly.
|
|
168
|
+
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
169
|
+
|
|
170
|
+
const HERE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
171
|
+
|
|
172
|
+
// -------------------------------------------------------------- JSON-RPC
|
|
173
|
+
//
|
|
174
|
+
// The boundary types every handler below reads or produces. `params` and
|
|
175
|
+
// `result` are typed `unknown` at this boundary deliberately -- MCP methods
|
|
176
|
+
// each carry their own shape, narrowed at the point each handler actually
|
|
177
|
+
// reads a field (never cast straight to an interface without a runtime
|
|
178
|
+
// check first, matching vice-broker.mts's own isPlainObject() discipline).
|
|
179
|
+
/** A single MCP tool descriptor, as this file's own three synthetic tools
|
|
180
|
+
* and every manifest-sourced tool share the shape (name/description/
|
|
181
|
+
* inputSchema, plus whatever `_meta` handleToolsList() stamps on afterward).
|
|
182
|
+
* Deliberately the same shape as vice.ts's own `ToolInfo` (imported above for
|
|
183
|
+
* `readManifestTools()`'s return), so a manifest tool and a synthetic tool
|
|
184
|
+
* are interchangeable wherever this file combines them. */
|
|
185
|
+
type ToolDefinition = ToolInfo;
|
|
186
|
+
|
|
187
|
+
/** Narrows an `unknown` value to a plain, non-array, non-null object --
|
|
188
|
+
* copied verbatim in shape from vice-broker.mts's own isPlainObject(), the
|
|
189
|
+
* one narrowing idiom this whole conversion phase uses at every JSON
|
|
190
|
+
* boundary rather than casting. */
|
|
191
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
192
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// -------------------------------------------------------------- never-throw
|
|
196
|
+
//
|
|
197
|
+
// Per RESEARCH.md Pitfall 3: a stdio MCP server is NEVER auto-reconnected by
|
|
198
|
+
// Claude Code once it dies (finding 7), so any uncaught throw here strands
|
|
199
|
+
// the session's emulator access for the rest of the session, silently. This
|
|
200
|
+
// is registered FIRST, before anything else in the module runs, so it is in
|
|
201
|
+
// effect for every line below it -- including the ES-module import above,
|
|
202
|
+
// which already executed by the time this file's own body starts, but every
|
|
203
|
+
// subsequent async operation this file performs is covered.
|
|
204
|
+
//
|
|
205
|
+
// There are TWO correct exit paths, not one (spike-findings-bruce-lee
|
|
206
|
+
// skill, shutdown-and-lease-release.md): a graceful client shutdown
|
|
207
|
+
// delivers SIGINT first (then SIGTERM ~100ms later, then SIGKILL at
|
|
208
|
+
// ~490ms) and never closes stdin; an abrupt client death closes stdin
|
|
209
|
+
// (`end` then `close`) and never signals. Both are handled separately
|
|
210
|
+
// below, by the teardown handler near the bottom of this file. Never
|
|
211
|
+
// `process.exit()` from any handler here or there -- see that handler's own
|
|
212
|
+
// comment for why nothing needs it.
|
|
213
|
+
process.on("uncaughtException", (err) => {
|
|
214
|
+
console.error(`vice-proxy: uncaughtException (ignored, staying alive): ${err && err.stack ? err.stack : err}`);
|
|
215
|
+
});
|
|
216
|
+
process.on("unhandledRejection", (reason) => {
|
|
217
|
+
const stack = reason && (reason as Error).stack ? (reason as Error).stack : reason;
|
|
218
|
+
console.error(`vice-proxy: unhandledRejection (ignored, staying alive): ${stack}`);
|
|
219
|
+
});
|
|
220
|
+
// An EPIPE on `stdout.write()` (Claude Code closing the pipe abruptly) throws
|
|
221
|
+
// SYNCHRONOUSLY with no listener attached -- this is the exact class of the
|
|
222
|
+
// filed, confirmed defect in the official MCP TypeScript SDK,
|
|
223
|
+
// modelcontextprotocol/typescript-sdk#1564. Attaching a listener here turns
|
|
224
|
+
// that into a benign, logged event instead of a crash.
|
|
225
|
+
process.stdout.on("error", (err) => {
|
|
226
|
+
console.error(`vice-proxy: stdout write error (ignored): ${err && err.message ? err.message : err}`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ------------------------------------------------------------- @mastra/mcp
|
|
230
|
+
//
|
|
231
|
+
// D-01 (this plan): the entire hand-rolled wire layer that used to live here
|
|
232
|
+
// (writeMessage/respond/errorResponse/ProtocolError/handleInitialize/
|
|
233
|
+
// handleToolsList/handleMessage/handleLine/the stdin loop) is gone, replaced
|
|
234
|
+
// by `@mastra/mcp`'s `MCPServer` + `startStdio()` -- see the construction
|
|
235
|
+
// site near the bottom of this file (right after the teardown region) for
|
|
236
|
+
// the tool registry, the MCPServer instance, and the CallToolRequestSchema
|
|
237
|
+
// override that preserves this file's own `{content, isError}` wire
|
|
238
|
+
// contract exactly (see this plan's "Ground truth" section for why
|
|
239
|
+
// MCPServer's OWN tools/call dispatch cannot be used as-is). PROXY_VERSION
|
|
240
|
+
// survives unchanged, reused as MCPServer's own `version` field.
|
|
241
|
+
const PROXY_VERSION = "0.1.0";
|
|
242
|
+
|
|
243
|
+
// --------------------------------------------------------------- tools/list
|
|
244
|
+
//
|
|
245
|
+
// A pure, offline read of the committed schema snapshot (decision D-C).
|
|
246
|
+
// `refresh-manifest.mjs` is the ONLY writer of that file -- this handler
|
|
247
|
+
// never fetches, never awaits a network call, and never throws. Any problem
|
|
248
|
+
// with the snapshot (absent, unparseable, wrong shape) degrades to a
|
|
249
|
+
// well-formed empty `tools` array plus one stderr line naming the path and
|
|
250
|
+
// the reason, never a fetch and never a hang.
|
|
251
|
+
//
|
|
252
|
+
// The output-size ceiling this proxy enforces (task 3's continuation logic)
|
|
253
|
+
// is declared here too, on every tool entry via `_meta`, so the ceiling a
|
|
254
|
+
// caller is TOLD about and the ceiling actually enforced are the same single
|
|
255
|
+
// number -- see OUTPUT_CHAR_CAP below, the one definition both sites read.
|
|
256
|
+
const OUTPUT_CHAR_CAP: number = (() => {
|
|
257
|
+
const n = Number(process.env.VICE_MAX_RESULT_CHARS);
|
|
258
|
+
return Number.isFinite(n) && n > 0 ? n : 500000;
|
|
259
|
+
})();
|
|
260
|
+
|
|
261
|
+
// -------------------------------------------------- output-limit warning
|
|
262
|
+
//
|
|
263
|
+
// D-1.2-H (plan 01.2-03 task 2). MAX_MCP_OUTPUT_TOKENS genuinely governs
|
|
264
|
+
// the CLIENT's own inline-response ceiling (measured at 40-60KB --
|
|
265
|
+
// spike-findings-bruce-lee skill, large-response-chunking.md -- about half
|
|
266
|
+
// the design's original ~100KB assumption; a 64K RAM read is ~192KB as
|
|
267
|
+
// hex, far above either figure). It is read from the client's own process
|
|
268
|
+
// environment, set via `.claude/settings.json`'s `env` block, which this
|
|
269
|
+
// repo's `.gitignore` makes untrackable (`.claude/*`, `.gitignore` lines
|
|
270
|
+
// 62-67) -- the same structural wall plan 01.1-04 hit with
|
|
271
|
+
// `.claude/CLAUDE.md`. It genuinely cannot be committed, so this proxy
|
|
272
|
+
// documents the required value in a tracked file (`tools/README.md`'s
|
|
273
|
+
// "Per-machine setup" section) and makes its OWN inherited environment's
|
|
274
|
+
// view of the setting OBSERVABLE on stderr, rather than silently assuming
|
|
275
|
+
// it is set. This is a WARNING, never a refusal: nothing throws, no call is
|
|
276
|
+
// rejected, and stdout carries only MCP messages (see the stdin-loop
|
|
277
|
+
// comment below) -- exactly one stderr line, at most once per process.
|
|
278
|
+
//
|
|
279
|
+
// Deliberately NOT resolved here, per this task's own instruction: the
|
|
280
|
+
// standing 32KB chunking non-negotiable and this proxy's own 500,000-char
|
|
281
|
+
// `_meta` ceiling (OUTPUT_CHAR_CAP above) are only compatible if a per-tool
|
|
282
|
+
// override is genuinely honoured, which was never measured -- the spike
|
|
283
|
+
// bracketed the inline ceiling at 40-60KB with no override set. Recorded as
|
|
284
|
+
// a deferred item in this plan's SUMMARY (both numbers, the one open
|
|
285
|
+
// question), not fixed by this warning or by changing OUTPUT_CHAR_CAP.
|
|
286
|
+
const REQUIRED_MAX_MCP_OUTPUT_TOKENS = 25000;
|
|
287
|
+
let outputLimitWarned = false;
|
|
288
|
+
|
|
289
|
+
function warnOnceAboutOutputLimit(): void {
|
|
290
|
+
if (outputLimitWarned) return;
|
|
291
|
+
outputLimitWarned = true;
|
|
292
|
+
const raw = process.env.MAX_MCP_OUTPUT_TOKENS;
|
|
293
|
+
const n = Number(raw);
|
|
294
|
+
const sufficient = raw !== undefined && Number.isFinite(n) && n >= REQUIRED_MAX_MCP_OUTPUT_TOKENS;
|
|
295
|
+
if (sufficient) return;
|
|
296
|
+
console.error(
|
|
297
|
+
`vice-proxy: MAX_MCP_OUTPUT_TOKENS is ${raw === undefined ? "not set" : `set to ${raw}`} in this ` +
|
|
298
|
+
`process's environment -- this project requires at least ${REQUIRED_MAX_MCP_OUTPUT_TOKENS}. Set it in ` +
|
|
299
|
+
`.claude/settings.json's "env" block (untracked -- see tools/README.md's "Per-machine setup" ` +
|
|
300
|
+
`section for why and the exact value).`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Two client behaviours this proxy deliberately does NOT rely on, recorded
|
|
305
|
+
// here so a later reader does not reach for either as a solution:
|
|
306
|
+
//
|
|
307
|
+
// 1. MCP_TIMEOUT does NOT extend the startup handshake. The measurement
|
|
308
|
+
// behind that claim tested only a 60s cap against a 10s delay, so it
|
|
309
|
+
// cannot distinguish "honoured but never reached" from "does nothing",
|
|
310
|
+
// and current official documentation describes it as a startup timeout
|
|
311
|
+
// -- genuinely OPEN, not settled. Moot for this proxy either way:
|
|
312
|
+
// handleInitialize() (above) answers with zero host I/O, so there is no
|
|
313
|
+
// slow handshake here that would need extending.
|
|
314
|
+
// 2. Automatic backgrounding of long tool calls does NOT apply to this
|
|
315
|
+
// project's dominant call pattern. It covers only main-conversation
|
|
316
|
+
// calls and explicitly excludes calls originating from subagents, and
|
|
317
|
+
// this project's emulator work runs overwhelmingly through executor
|
|
318
|
+
// waves, which are subagent-driven and share their parent session's
|
|
319
|
+
// single proxy connection. brokerWarmingMessage() (below) is therefore
|
|
320
|
+
// the PRIMARY cold-path mechanism, not a fallback for something the
|
|
321
|
+
// client will handle on this project's behalf.
|
|
322
|
+
|
|
323
|
+
// The synthetic continuation tool (task 3, decision D-E): served entirely
|
|
324
|
+
// inside this proxy, NEVER forwarded to the host, and advertised in every
|
|
325
|
+
// tools/list response exactly like a real tool so an agent can discover it
|
|
326
|
+
// the same way it discovers everything else.
|
|
327
|
+
const RESULT_CONTINUE_TOOL: ToolDefinition = {
|
|
328
|
+
name: "vice_result_continue",
|
|
329
|
+
description:
|
|
330
|
+
"Retrieve the next chunk of an oversized tools/call result. Call with the token named in the " +
|
|
331
|
+
"previous chunk's trailing marker.",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: {
|
|
335
|
+
token: {
|
|
336
|
+
type: "string",
|
|
337
|
+
description: "the continuation token named in the previous chunk's trailing marker",
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
required: ["token"],
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// The recycle tool (plan 01.3-01, task 1): the only new HOST-SIDE ACTION
|
|
345
|
+
// this phase adds. Served entirely proxy-local -- like RESULT_CONTINUE_TOOL
|
|
346
|
+
// above, it is never in tools-manifest.json (RESEARCH Key Finding 3), so a
|
|
347
|
+
// manifest regenerate can never drop it. Deliberately split from
|
|
348
|
+
// vice_diagnose (D-03): this tool NEVER gates on a verdict, so there is no
|
|
349
|
+
// "confirm"/"mode" argument and no shared state between the two tools to
|
|
350
|
+
// keep in sync -- the separation itself is the safety.
|
|
351
|
+
const RECYCLE_TOOL: ToolDefinition = {
|
|
352
|
+
name: "vice_recycle",
|
|
353
|
+
description:
|
|
354
|
+
"DESTRUCTIVE. Kills and respawns THIS session's own emulator in place, on the same port, via " +
|
|
355
|
+
"the host supervisor's existing respawn loop -- the same instance, not a different one. The " +
|
|
356
|
+
"restart epoch changes, so any run in flight is void and must be resumed from the last recorded " +
|
|
357
|
+
'milestone snapshot. A self-inflicted checkpoint stop (the emulator merely paused at an armed ' +
|
|
358
|
+
"checkpoint) is NOT a wedge and must not be recycled. Requires a non-empty \"reason\" naming why " +
|
|
359
|
+
"this recycle is happening; that reason is written to a permanent, repo-tracked incident record " +
|
|
360
|
+
"BEFORE anything is killed.",
|
|
361
|
+
inputSchema: {
|
|
362
|
+
type: "object",
|
|
363
|
+
properties: {
|
|
364
|
+
reason: {
|
|
365
|
+
type: "string",
|
|
366
|
+
description: "Why this recycle is happening -- written verbatim into the incident record.",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
required: ["reason"],
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// The diagnose tool (plan 01.3-02): the read-mostly companion to
|
|
374
|
+
// RECYCLE_TOOL above, served in the same proxy-local synthetic slot. D-03
|
|
375
|
+
// keeps the two structurally unlinked -- no shared verdict/confirm state,
|
|
376
|
+
// and recycle never reads a diagnose verdict.
|
|
377
|
+
const DIAGNOSE_TOOL: ToolDefinition = {
|
|
378
|
+
name: "vice_diagnose",
|
|
379
|
+
description:
|
|
380
|
+
"Read-mostly. Answers which of five states this session's emulator is in -- restarted, " +
|
|
381
|
+
"checkpoint_trap, wedged, stale_read_path, or live -- with the evidence that produced the " +
|
|
382
|
+
"verdict. It may resume the machine once or twice to measure a cycle bracket, so it is never " +
|
|
383
|
+
"something to call reflexively; when it runs a bracket it leaves the machine PAUSED afterward -- " +
|
|
384
|
+
'resuming is your own next call. A "checkpoint_trap" verdict means the machine stopped ITSELF at ' +
|
|
385
|
+
"an armed checkpoint and must NOT be recycled -- recycling a self-inflicted stop destroys a " +
|
|
386
|
+
"healthy instance.",
|
|
387
|
+
inputSchema: {
|
|
388
|
+
type: "object",
|
|
389
|
+
properties: {},
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
function manifestPath(): string {
|
|
394
|
+
return process.env.VICE_TOOLS_MANIFEST
|
|
395
|
+
? resolve(process.env.VICE_TOOLS_MANIFEST)
|
|
396
|
+
: join(HERE_DIR, "tools-manifest.json");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readManifestTools(): ToolInfo[] {
|
|
400
|
+
const path = manifestPath();
|
|
401
|
+
let raw: string;
|
|
402
|
+
try {
|
|
403
|
+
raw = readFileSync(path, "utf8");
|
|
404
|
+
} catch (e) {
|
|
405
|
+
console.error(
|
|
406
|
+
`vice-proxy: tools-manifest not readable at ${path} (${(e as Error).message}) -- answering tools/list with an empty tools array`
|
|
407
|
+
);
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
let parsed: unknown;
|
|
411
|
+
try {
|
|
412
|
+
parsed = JSON.parse(raw);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
console.error(
|
|
415
|
+
`vice-proxy: tools-manifest at ${path} is not valid JSON (${(e as Error).message}) -- answering tools/list with an empty tools array`
|
|
416
|
+
);
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
const shapeOk =
|
|
420
|
+
isPlainObject(parsed) &&
|
|
421
|
+
Array.isArray(parsed.tools) &&
|
|
422
|
+
parsed.tools.every((t: unknown) => isPlainObject(t) && typeof t.name === "string");
|
|
423
|
+
if (!shapeOk) {
|
|
424
|
+
console.error(
|
|
425
|
+
`vice-proxy: tools-manifest at ${path} has an unexpected shape ("tools" must be an array of objects ` +
|
|
426
|
+
`each carrying a string "name") -- answering tools/list with an empty tools array`
|
|
427
|
+
);
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
return (parsed as { tools: ToolInfo[] }).tools;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --------------------------------------------------------------- tools/call
|
|
434
|
+
//
|
|
435
|
+
// Delegates every real call to the reused `call()` -- the retry ladder
|
|
436
|
+
// already lives there (Pattern 1). Per Pattern 2, EVERY outcome of a tool
|
|
437
|
+
// invocation attempt -- success or failure -- becomes a well-formed
|
|
438
|
+
// `{content, isError}` result, never a JSON-RPC `error` object. Malformed
|
|
439
|
+
// `tools/call` params (a missing/non-string `name`) are now rejected one
|
|
440
|
+
// layer further out, by the SDK's own `CallToolRequestSchema` zod validation
|
|
441
|
+
// (installed at the construction site near the bottom of this file) --
|
|
442
|
+
// there is no `ProtocolError`/`handleMessage()` pair left in this file to
|
|
443
|
+
// catch that case.
|
|
444
|
+
//
|
|
445
|
+
// Two hazards are enforced HERE, at the proxy seam, as independent layers on
|
|
446
|
+
// top of what `call()` already does internally:
|
|
447
|
+
//
|
|
448
|
+
// 1. vice_disk_list refusal. `call()` already refuses it (throwing a
|
|
449
|
+
// ViceError), but this proxy refuses it FIRST, before any forwarding
|
|
450
|
+
// logic runs and before any network attempt, so the refusal is
|
|
451
|
+
// observable with zero HTTP traffic and a well-formed MCP frame rather
|
|
452
|
+
// than one more layer of catch between the hazard and the answer.
|
|
453
|
+
//
|
|
454
|
+
// 2. Per-call epoch re-check (decision D-D). The proxy does NOT call
|
|
455
|
+
// assertSameMachine() and does NOT probe vice_checkpoint_list -- a
|
|
456
|
+
// state-reading call that pauses the emulated CPU and never resumes it,
|
|
457
|
+
// and the proxy arms no checkpoints of its own to probe with anyway.
|
|
458
|
+
// The narrowed contract is a plain readEpoch() comparison, before AND
|
|
459
|
+
// after every forwarded call: a changed epoch refuses the call (or
|
|
460
|
+
// discards its result, if the change happened mid-call) with a loud,
|
|
461
|
+
// evidence-carrying error naming both epoch values, then adopts the new
|
|
462
|
+
// value as the baseline so the SESSION stays usable -- a restart report
|
|
463
|
+
// is never cached, per criterion 6.
|
|
464
|
+
// NEVER-CACHE-A-NEGATIVE-RESULT INVARIANT (plan 01.1-03 task 1, criterion 6;
|
|
465
|
+
// extended to the broker path by plan 01.2-03 task 1, C11): nothing below
|
|
466
|
+
// this line may memoise "the host is down" -- or, as of this extension,
|
|
467
|
+
// "the broker is absent" -- as a fact that outlives a single tools/call.
|
|
468
|
+
// There is no cached probe verdict, no sticky "last known unreachable" flag,
|
|
469
|
+
// and no early-return short-circuit keyed off a PREVIOUS failure -- every
|
|
470
|
+
// forwarded tools/call re-evaluates reachability from scratch (the epoch
|
|
471
|
+
// check below reads the file fresh every time; the liveness probe added in
|
|
472
|
+
// task 2 does its own fresh network round trip every time; task 3's
|
|
473
|
+
// translation runs fresh every time; ensureBrokerLease()'s
|
|
474
|
+
// readBrokerLiveness() call reads broker.json fresh every time it is
|
|
475
|
+
// reached, never memoised at module scope). This is deliberate and easy to
|
|
476
|
+
// break by a later, performance-minded edit ("let's skip the probe if we
|
|
477
|
+
// just failed one 200ms ago", or "let's remember the broker was absent last
|
|
478
|
+
// call so we don't bother checking again") -- don't, for either path. A
|
|
479
|
+
// cached negative here is exactly the "quiet wrong answer" failure class
|
|
480
|
+
// this codebase rejects elsewhere (MachineRestartedError, the epoch
|
|
481
|
+
// re-check itself): the call after a human starts the broker must just
|
|
482
|
+
// work, with no session restart required.
|
|
483
|
+
let viceSession: SessionInfo | null = null; // beginSession()'s return value, set lazily on the first forwarded call
|
|
484
|
+
let epochBaseline: EpochResult | null = null; // the rolling comparison point; updated on every re-baseline
|
|
485
|
+
|
|
486
|
+
function ensureViceSession(): void {
|
|
487
|
+
if (!viceSession) {
|
|
488
|
+
viceSession = beginSession();
|
|
489
|
+
epochBaseline = viceSession.baseline;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function currentEpoch(): EpochResult {
|
|
494
|
+
return readEpoch((viceSession as SessionInfo).epochPath);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function epochChanged(baseline: EpochResult | null, current: EpochResult | null): boolean {
|
|
498
|
+
return Boolean(baseline?.present) && Boolean(current?.present) && baseline!.epoch !== current!.epoch;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function epochDriftMessage(when: string, baseline: EpochResult, current: EpochResult): string {
|
|
502
|
+
const pidNote = current && current.pid != null ? `, pid ${current.pid}` : "";
|
|
503
|
+
const spawnedNote = current && current.spawned_at ? `, spawned_at ${current.spawned_at}` : "";
|
|
504
|
+
return (
|
|
505
|
+
`vice: treat every result since the previous call as void and redo that work -- epoch drift was ` +
|
|
506
|
+
`detected ${when} (epoch changed from ${baseline.epoch} to ${current.epoch}${pidNote}${spawnedNote}).`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Compare the rolling baseline against a fresh epoch read. Returns an error
|
|
512
|
+
* MESSAGE string if the comparison proves a restart (and re-baselines to the
|
|
513
|
+
* new value so the next call is not refused again), or `null` if the call
|
|
514
|
+
* may proceed (including the "absent baseline, now present" case, which is
|
|
515
|
+
* adopted silently -- a supervisor merely started, not a restart, mirroring
|
|
516
|
+
* vice.ts's own "only compare when both are present" rule).
|
|
517
|
+
*/
|
|
518
|
+
function checkEpochAndRebaseline(when: string): string | null {
|
|
519
|
+
const current = currentEpoch();
|
|
520
|
+
if (epochChanged(epochBaseline, current)) {
|
|
521
|
+
const msg = epochDriftMessage(when, epochBaseline as EpochResult, current);
|
|
522
|
+
epochBaseline = current; // never cache a negative result (criterion 6)
|
|
523
|
+
return msg;
|
|
524
|
+
}
|
|
525
|
+
if (!(epochBaseline as EpochResult).present && current.present) {
|
|
526
|
+
epochBaseline = current;
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
interface ErrorTextResult {
|
|
532
|
+
content: { type: "text"; text: string }[];
|
|
533
|
+
isError: true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isErrorText(text: string): ErrorTextResult {
|
|
537
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The shape every tools/call outcome takes (Pattern 2): success or failure,
|
|
541
|
+
* never a JSON-RPC `error` object. Shared by handleRecycle(), handleDiagnose(),
|
|
542
|
+
* handleResultContinue(), wrapPossiblyChunked() and handleToolsCall() itself. */
|
|
543
|
+
interface OkTextResult {
|
|
544
|
+
content: { type: "text"; text: string }[];
|
|
545
|
+
isError: false;
|
|
546
|
+
}
|
|
547
|
+
type ToolCallResult = ErrorTextResult | OkTextResult;
|
|
548
|
+
|
|
549
|
+
// ------------------------------------------------------------ vice_recycle
|
|
550
|
+
//
|
|
551
|
+
// Re-baselines the proxy's own epoch tracking after a CONFIRMED recycle.
|
|
552
|
+
// Mirrors ensureBrokerLease()'s own `viceSession = null` re-baseline
|
|
553
|
+
// (further down this file) for the identical reason: a recycle is a
|
|
554
|
+
// DELIBERATE identity change, and without this the very next forwarded
|
|
555
|
+
// call would fail its own epoch drift guard against a baseline that is now
|
|
556
|
+
// stale by construction. Clearing epochBaseline too (not just viceSession)
|
|
557
|
+
// means nothing in between reads the stale value before the next
|
|
558
|
+
// ensureViceSession() call re-populates both from a fresh read.
|
|
559
|
+
function rebaselineEpochAfterRecycle(): void {
|
|
560
|
+
viceSession = null;
|
|
561
|
+
epochBaseline = null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Renders a human-facing message for a recycle ack whose kill stage was
|
|
565
|
+
* NOT a successful kill -- named per outcome so an operator reading the
|
|
566
|
+
* result can tell "no grant record" from "unreadable epoch file" from "no
|
|
567
|
+
* pid recorded" from "identity mismatch" without opening the broker log
|
|
568
|
+
* (matches resources/vice-broker.sh's own per-outcome ack strings). */
|
|
569
|
+
function recycleAckOutcomeMessage(ack: Record<string, unknown>): string {
|
|
570
|
+
const outcome = ack && typeof ack.outcome === "string" ? ack.outcome : "unknown";
|
|
571
|
+
const stage = ack && typeof ack.kill_stage === "string" ? ack.kill_stage : "unknown";
|
|
572
|
+
const reason = ack && typeof ack.reason === "string" && ack.reason ? ` (${ack.reason})` : "";
|
|
573
|
+
switch (outcome) {
|
|
574
|
+
case "identity_refused":
|
|
575
|
+
return (
|
|
576
|
+
`vice_recycle: the host refused to signal the target -- its process identity did not match ` +
|
|
577
|
+
`the binary recorded in its own epoch file (kill stage: ${stage}). The instance was NOT ` +
|
|
578
|
+
`killed and is still running.`
|
|
579
|
+
);
|
|
580
|
+
case "target_lookup_failed":
|
|
581
|
+
return `vice_recycle: the host could not resolve this session's own recycle target (kill stage: ${stage})${reason}.`;
|
|
582
|
+
case "grant_lookup_failed":
|
|
583
|
+
return `vice_recycle: the host found no grant record for this session's target (kill stage: ${stage})${reason}.`;
|
|
584
|
+
case "epoch_lookup_failed":
|
|
585
|
+
return `vice_recycle: the host could not read the target's epoch file (kill stage: ${stage})${reason}.`;
|
|
586
|
+
case "pid_lookup_failed":
|
|
587
|
+
return `vice_recycle: the target's own epoch file carries no pid to signal (kill stage: ${stage})${reason}.`;
|
|
588
|
+
default:
|
|
589
|
+
return `vice_recycle: the host reported outcome "${outcome}" (kill stage: ${stage})${reason}.`;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Handles the destructive vice_recycle tool. Fixed order, and the order is
|
|
595
|
+
* the point (plan 01.3-01 task 1): read the current epoch first; refuse
|
|
596
|
+
* (no incident record, no request) when no broker lease is held yet or an
|
|
597
|
+
* explicit VICE_MCP_URL override is in effect -- there is no broker to ask
|
|
598
|
+
* and no supervisor to respawn either way; write the incident record BEFORE
|
|
599
|
+
* anything else touches the host (D-17); only then write the recycle
|
|
600
|
+
* request; await the ack; on anything other than a successful kill,
|
|
601
|
+
* finalise the record with that outcome and return a well-formed error
|
|
602
|
+
* naming the stage verbatim; on a successful kill, poll for the epoch to
|
|
603
|
+
* move and probe readiness as two SEPARATE facts (T-01.3-03), finalise the
|
|
604
|
+
* record, re-baseline, and return success. Never throws past this point --
|
|
605
|
+
* every branch is a well-formed isError result (a dead stdio proxy is
|
|
606
|
+
* unrecoverable for the session).
|
|
607
|
+
*/
|
|
608
|
+
// Declared as `const ... = async function handleRecycle(args) { ... }` (a
|
|
609
|
+
// contextually-typed function EXPRESSION), not `async function
|
|
610
|
+
// handleRecycle(args: ...) { ... }` (a typed declaration): the latter's
|
|
611
|
+
// exact param-list text would drift from vice-proxy.test.mjs's own
|
|
612
|
+
// structural oracle (`indexOf("async function handleRecycle(args)")`),
|
|
613
|
+
// which is off-limits to edit in this plan. The variable's own type
|
|
614
|
+
// annotation gives `args` a real, checked type via TS's ordinary contextual
|
|
615
|
+
// typing for a function expression assigned to a typed const -- verified
|
|
616
|
+
// live this session against a scratch file (see RE-FINDINGS.md) -- so this
|
|
617
|
+
// is real typing, not a suppression: every field read below still narrows
|
|
618
|
+
// `args` the same way every other handler in this file does.
|
|
619
|
+
const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
|
|
620
|
+
const rawReason = args && typeof args.reason === "string" ? args.reason : "";
|
|
621
|
+
const reason = rawReason.trim();
|
|
622
|
+
if (!reason) {
|
|
623
|
+
return isErrorText(
|
|
624
|
+
'vice_recycle requires a non-empty "reason" string naming why this recycle is happening -- it ' +
|
|
625
|
+
"becomes the incident record's own explanation, written before anything is killed. No record " +
|
|
626
|
+
"and no request were written."
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const preKillEpoch = readEpoch();
|
|
631
|
+
|
|
632
|
+
if (process.env.VICE_MCP_URL) {
|
|
633
|
+
return isErrorText(
|
|
634
|
+
"vice_recycle: VICE_MCP_URL is set, so this session talks to an explicitly overridden endpoint " +
|
|
635
|
+
"with no broker to ask and no supervisor to respawn it. Recycle only applies to a broker-" +
|
|
636
|
+
"granted instance. No record and no request were written."
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (!controlSession) {
|
|
640
|
+
return isErrorText(
|
|
641
|
+
"vice_recycle: no broker lease is held yet for this session -- recycle only applies to an " +
|
|
642
|
+
"instance already granted to this session. Make at least one other forwarded call first. " +
|
|
643
|
+
"No record and no request were written."
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
648
|
+
const { port } = activeInstance();
|
|
649
|
+
const epochBefore = preKillEpoch.present ? preKillEpoch.epoch : null;
|
|
650
|
+
const at = new Date().toISOString();
|
|
651
|
+
|
|
652
|
+
// Plan 01.3-03 (D-17, extended): gather the FULL criterion-4 evidence set
|
|
653
|
+
// -- including the best-effort pre-kill snapshot -- BEFORE the record is
|
|
654
|
+
// written. There is no argument, environment variable or branch between
|
|
655
|
+
// here and the record write that can reach the request write with any of
|
|
656
|
+
// this still ungathered; every step above degrades to unavailable rather
|
|
657
|
+
// than aborting, so this line always completes.
|
|
658
|
+
const evidence = await gatherWedgeEvidence({ at, port, epoch: epochBefore });
|
|
659
|
+
evidence.snapshot = await captureSnapshotAttempt({ at, port, epoch: epochBefore });
|
|
660
|
+
|
|
661
|
+
// D-17: the record is written BEFORE the request -- capturing is
|
|
662
|
+
// structurally impossible to skip, not a discipline to remember.
|
|
663
|
+
const recordPath = writeIncidentRecord({
|
|
664
|
+
at,
|
|
665
|
+
port,
|
|
666
|
+
epoch_before: epochBefore,
|
|
667
|
+
reason,
|
|
668
|
+
session_id: sessionId,
|
|
669
|
+
evidence,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Plan 01.6.2-07 task 2: the request write + ack poll are replaced by one
|
|
673
|
+
// recycle request over the connection this session already holds -- the
|
|
674
|
+
// client_pid this session used to send with a recycle request has no
|
|
675
|
+
// successor field on the wire, since the connection itself already
|
|
676
|
+
// identifies which grant this is (broker-control.mts's own T-01.6.2-31
|
|
677
|
+
// discipline: a connection may only recycle the grant it itself holds).
|
|
678
|
+
const recycled = await controlSession.recycle(grantId as string);
|
|
679
|
+
if (!recycled.ok) {
|
|
680
|
+
if (recycled.kind === "broker_gone") {
|
|
681
|
+
// D-14 (plan 08): distinct from an acknowledgement carrying a refusal
|
|
682
|
+
// (T-01.6.2-46) -- the instance's state is unknown in both cases, but
|
|
683
|
+
// the operator's next action differs, a refusal means the target is
|
|
684
|
+
// alive and uncooperative, broker_gone means there is no longer
|
|
685
|
+
// anyone to ask. Reuses sessionMustRestartMessage() -- the SAME
|
|
686
|
+
// fresh-machine vocabulary a forwarded call's own broker-gone path
|
|
687
|
+
// (handleGrantedInstanceUnreachable() above) produces -- rather than
|
|
688
|
+
// a bare transport error string. Deliberately does NOT attempt to
|
|
689
|
+
// open a fresh session and acquire a replacement the way a forwarded
|
|
690
|
+
// call does: the instance THIS recycle was trying to kill is now of
|
|
691
|
+
// genuinely unknown state (the kill request may or may not have
|
|
692
|
+
// reached the broker before the connection dropped), and silently
|
|
693
|
+
// handing back a different "replacement" instance under the name of
|
|
694
|
+
// a recycle result would claim more certainty about that kill than
|
|
695
|
+
// this proxy actually has.
|
|
696
|
+
finaliseIncidentRecord(recordPath, { outcome: "broker_gone" });
|
|
697
|
+
return isErrorText(
|
|
698
|
+
`${sessionMustRestartMessage(recycled)} Incident record: ${recordPath}. This recycle's own kill ` +
|
|
699
|
+
`request may or may not have reached the broker before the connection dropped -- the instance's ` +
|
|
700
|
+
`state is now unknown.`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (recycled.kind === "deadline") {
|
|
704
|
+
finaliseIncidentRecord(recordPath, { outcome: "timeout" });
|
|
705
|
+
return isErrorText(
|
|
706
|
+
`vice_recycle: no ack arrived from the host within the timeout (${recycled.message}). Incident ` +
|
|
707
|
+
`record: ${recordPath}. The instance's state is now unknown -- treat it as neither confirmed ` +
|
|
708
|
+
`killed nor confirmed alive.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
// Any other control-plane failure (protocol/unauthorized/bad_request/
|
|
712
|
+
// denied/internal) -- an unexpected shape from the broker's own
|
|
713
|
+
// response, not exhaustively enumerated here (D-14's full vocabulary is
|
|
714
|
+
// plan 08's); still a well-formed, non-throwing result either way.
|
|
715
|
+
finaliseIncidentRecord(recordPath, { outcome: "internal" });
|
|
716
|
+
return isErrorText(
|
|
717
|
+
`vice_recycle: the recycle request failed (${recycled.kind}: ${recycled.message}). Incident record: ${recordPath}.`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const ack = recycled.ack;
|
|
722
|
+
const killStage: string | null = ack.kill_stage;
|
|
723
|
+
const successfulKill = killStage === "already_exited" || killStage === "sigterm" || killStage === "sigkill";
|
|
724
|
+
|
|
725
|
+
if (!successfulKill) {
|
|
726
|
+
finaliseIncidentRecord(recordPath, { outcome: ack.outcome || "refused", kill_stage: killStage });
|
|
727
|
+
return isErrorText(`${recycleAckOutcomeMessage({ ...ack })} Incident record: ${recordPath}.`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// The kill succeeded -- confirm the machine actually came back. The epoch
|
|
731
|
+
// bump and the readiness probe are reported as two SEPARATE facts
|
|
732
|
+
// (T-01.3-03): "the epoch moved" is bookkeeping, "the instance answers"
|
|
733
|
+
// is evidence, and neither substitutes for the other.
|
|
734
|
+
const epochDeadline = Date.now() + RECYCLE_TIMEOUT_MS;
|
|
735
|
+
let afterEpoch = readEpoch();
|
|
736
|
+
const epochMoved = () =>
|
|
737
|
+
afterEpoch.present && (!preKillEpoch.present || (afterEpoch.epoch as number) > (preKillEpoch.epoch as number));
|
|
738
|
+
while (Date.now() < epochDeadline && !epochMoved()) {
|
|
739
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
740
|
+
afterEpoch = readEpoch();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { url, port: instancePort } = activeInstance();
|
|
744
|
+
const probe = await probeInstance({ url, port: instancePort });
|
|
745
|
+
|
|
746
|
+
// 2026-08-05 defect fix: the persisted record's own epoch_after must never
|
|
747
|
+
// carry a stale value equal to epoch_before -- that pair reads as
|
|
748
|
+
// "confirmed unchanged" to a future reader, which is a false claim for a
|
|
749
|
+
// kill that just genuinely succeeded (killStage is one of the three
|
|
750
|
+
// successful-kill stages here, by construction of the guard above). Only
|
|
751
|
+
// the poll loop's own epochMoved() -- not merely afterEpoch.present -- may
|
|
752
|
+
// promote the read into the record; anything else stays the honest `null`
|
|
753
|
+
// ("not yet known", per renderIncidentRecord()'s existing rendering) so a
|
|
754
|
+
// future investigation is never handed a pair that looks complete but
|
|
755
|
+
// isn't.
|
|
756
|
+
finaliseIncidentRecord(recordPath, {
|
|
757
|
+
outcome: "ok",
|
|
758
|
+
kill_stage: killStage,
|
|
759
|
+
epoch_after: epochMoved() ? afterEpoch.epoch : null,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Immediately before returning success -- the deliberate identity change
|
|
763
|
+
// this tool exists to cause would otherwise make every subsequent
|
|
764
|
+
// forwarded call fail the drift guard.
|
|
765
|
+
rebaselineEpochAfterRecycle();
|
|
766
|
+
|
|
767
|
+
const snapshotNote =
|
|
768
|
+
evidence.snapshot && evidence.snapshot.available
|
|
769
|
+
? `accepted (name: ${(evidence.snapshot.value as { name: string }).name})`
|
|
770
|
+
: `unavailable (${evidence.snapshot && evidence.snapshot.reason ? evidence.snapshot.reason : "no reason recorded"})`;
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
content: [
|
|
774
|
+
{
|
|
775
|
+
type: "text",
|
|
776
|
+
text:
|
|
777
|
+
`vice_recycle: kill stage "${killStage}". Epoch before: ${preKillEpoch.present ? preKillEpoch.epoch : "unknown"}, ` +
|
|
778
|
+
`epoch after: ${afterEpoch.present ? afterEpoch.epoch : "unknown"} (${epochMoved() ? "moved" : "did not move within the timeout"}). ` +
|
|
779
|
+
`Readiness probe: ${probe.alive ? "the respawned instance answered" : `not yet answering (${probe.reason})`}. ` +
|
|
780
|
+
`Snapshot: ${snapshotNote}. ` +
|
|
781
|
+
`Incident record: ${recordPath}. This run is VOID -- resume from the last recorded milestone snapshot.`,
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
isError: false,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ----------------------------------------------------------- vice_diagnose
|
|
789
|
+
//
|
|
790
|
+
// Plan 01.3-02 task 1: the read-mostly half of this phase, up to but not
|
|
791
|
+
// including the cycle bracket (task 2 wires that in). Every read below goes
|
|
792
|
+
// through the proxy's existing forwarded call() path -- no new host
|
|
793
|
+
// capability, no new protocol, no second route.
|
|
794
|
+
|
|
795
|
+
// The closed, five-member verdict vocabulary, in the order the checks run.
|
|
796
|
+
// Frozen so a future edit cannot quietly widen it -- must_have C1's whole
|
|
797
|
+
// point.
|
|
798
|
+
const DIAGNOSE_VERDICTS = Object.freeze(["restarted", "checkpoint_trap", "wedged", "stale_read_path", "live"]);
|
|
799
|
+
|
|
800
|
+
/** Normalise a checkpoint/register address to a plain number, accepting
|
|
801
|
+
* either a JS number or a hex string ("$1103"/"1103"/"0x1103"). An unprefixed
|
|
802
|
+
* digit string is read as HEX, matching this project's own address
|
|
803
|
+
* convention (every C64 address in this project's docs and RE-FINDINGS.md is
|
|
804
|
+
* hex), never decimal. Returns null, never throws, on anything unresolvable
|
|
805
|
+
* (T-01.3-06: an untrusted payload degrades to "unknown", never a thrown
|
|
806
|
+
* exception). */
|
|
807
|
+
function toAddressNumber(value: unknown): number | null {
|
|
808
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
809
|
+
if (typeof value === "string") {
|
|
810
|
+
const s = value.trim().replace(/^\$/, "").replace(/^0x/i, "");
|
|
811
|
+
const n = parseInt(s, 16);
|
|
812
|
+
if (Number.isFinite(n)) return n;
|
|
813
|
+
}
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function formatAddress(n: number | null | undefined): string {
|
|
818
|
+
return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function formatByte(n: number | null | undefined): string {
|
|
822
|
+
return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Decode a vice_memory_read result into a plain byte array, accepting
|
|
826
|
+
* either the compact "hex" string encoding (requested below) or the legacy
|
|
827
|
+
* per-byte "bytes" array shape -- an untrusted payload degrades to an empty
|
|
828
|
+
* array, never a thrown exception (T-01.3-06). */
|
|
829
|
+
function bytesFromMemoryReadResult(result: unknown): number[] {
|
|
830
|
+
if (isPlainObject(result) && typeof result.hex === "string") {
|
|
831
|
+
const clean = result.hex.replace(/[^0-9a-fA-F]/g, "");
|
|
832
|
+
const bytes: number[] = [];
|
|
833
|
+
for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.slice(i, i + 2), 16));
|
|
834
|
+
return bytes;
|
|
835
|
+
}
|
|
836
|
+
if (isPlainObject(result) && Array.isArray(result.bytes)) {
|
|
837
|
+
return (result.bytes as unknown[])
|
|
838
|
+
.map((b) => (typeof b === "string" ? parseInt(b.replace(/^\$/, ""), 16) : Number(b)))
|
|
839
|
+
.filter((n) => Number.isFinite(n));
|
|
840
|
+
}
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function wordFromBytes(bytes: number[]): number | null {
|
|
845
|
+
return bytes.length >= 2 ? bytes[0] | (bytes[1] << 8) : null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Bit 1 (HIRAM) of the 6510 processor port at $01. SET -- the KERNAL ROM is
|
|
849
|
+
// banked in, and the RAM IRQ vector pair ($0314/$0315) is what the KERNAL's
|
|
850
|
+
// own dispatch actually reads (RE-FINDINGS.md's own vector-table entry).
|
|
851
|
+
// CLEAR -- the KERNAL is replaced by RAM and the CPU reads the hardware
|
|
852
|
+
// IRQ/BRK vector pair ($FFFE/$FFFF) directly, with no ROM indirection.
|
|
853
|
+
const HIRAM_MASK = 0x02;
|
|
854
|
+
|
|
855
|
+
/** The live-IRQ-handler lookup's own return shape -- shared by
|
|
856
|
+
* gatherCheckpointTrapEvidence() below and by plan 01.3-03's evidence
|
|
857
|
+
* gatherer (gatherWedgeEvidence()). */
|
|
858
|
+
interface IrqHandlerResolution {
|
|
859
|
+
target: number | null;
|
|
860
|
+
pairLabel: string;
|
|
861
|
+
explanation: string;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The single definition of the live-IRQ-handler lookup (Key Finding 6):
|
|
866
|
+
* three forwarded reads through the normal call() path -- $01, the RAM
|
|
867
|
+
* vector pair, and (only when $01 says the ROMs are banked out) the hardware
|
|
868
|
+
* vector pair. Consumed by the checkpoint-trap check below and, per this
|
|
869
|
+
* plan's own key_links, by plan 01.3-03's evidence gatherer. Memoises
|
|
870
|
+
* NOTHING: a disk swap, a reset or a different game retargets the handler,
|
|
871
|
+
* so a cached address would silently resolve the wrong pair.
|
|
872
|
+
*/
|
|
873
|
+
async function resolveLiveIrqHandler(): Promise<IrqHandlerResolution> {
|
|
874
|
+
const portResult = await call("vice_memory_read", { address: "$01", size: 1, encoding: "hex" });
|
|
875
|
+
const portBytes = bytesFromMemoryReadResult(portResult);
|
|
876
|
+
const port01 = portBytes.length > 0 ? portBytes[0] : null;
|
|
877
|
+
const bankedOut = port01 !== null && (port01 & HIRAM_MASK) === 0;
|
|
878
|
+
|
|
879
|
+
const ramResult = await call("vice_memory_read", { address: "$0314", size: 2, encoding: "hex" });
|
|
880
|
+
const ramTarget = wordFromBytes(bytesFromMemoryReadResult(ramResult));
|
|
881
|
+
|
|
882
|
+
if (!bankedOut) {
|
|
883
|
+
return {
|
|
884
|
+
target: ramTarget,
|
|
885
|
+
pairLabel: "the RAM KERNAL IRQ vector pair ($0314/$0315)",
|
|
886
|
+
explanation:
|
|
887
|
+
`$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked in, so the RAM IRQ vector pair ` +
|
|
888
|
+
`($0314/$0315) is the pair this session's IRQ dispatch actually reads; it resolves to ${formatAddress(ramTarget)}.`,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const hwResult = await call("vice_memory_read", { address: "$FFFE", size: 2, encoding: "hex" });
|
|
893
|
+
const hwTarget = wordFromBytes(bytesFromMemoryReadResult(hwResult));
|
|
894
|
+
return {
|
|
895
|
+
target: hwTarget,
|
|
896
|
+
pairLabel: "the hardware IRQ/BRK vector pair ($FFFE/$FFFF)",
|
|
897
|
+
explanation:
|
|
898
|
+
`$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked OUT, so the CPU dispatches ` +
|
|
899
|
+
`directly through the hardware IRQ/BRK vector pair ($FFFE/$FFFF) with no ROM indirection; it ` +
|
|
900
|
+
`resolves to ${formatAddress(hwTarget)}.`,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Enumerate armed checkpoints, read the current PC, resolve the live IRQ
|
|
906
|
+
* handler, and decide the checkpoint-trap verdict on two named shapes
|
|
907
|
+
* (D-14): an enabled, stopping, exec checkpoint sitting exactly at the
|
|
908
|
+
* current PC; or one sitting at the resolved handler entry with a hit count
|
|
909
|
+
* of exactly zero (the corroborating tell that it has never actually
|
|
910
|
+
* fired). Makes NO resume and NO stopwatch call -- the whole point of
|
|
911
|
+
* checking this before any cycle bracket (D-14, T-01.3-08).
|
|
912
|
+
*/
|
|
913
|
+
/** A single vice_checkpoint_list entry, typed loosely (matching this
|
|
914
|
+
* codebase's own precedent for a host-written record this proxy never
|
|
915
|
+
* asserts a closed shape on) -- every field is read defensively below,
|
|
916
|
+
* never assumed present. */
|
|
917
|
+
interface CheckpointInfo {
|
|
918
|
+
checkpoint_num?: unknown;
|
|
919
|
+
start?: unknown;
|
|
920
|
+
stop?: unknown;
|
|
921
|
+
exec?: unknown;
|
|
922
|
+
enabled?: unknown;
|
|
923
|
+
hit_count?: unknown;
|
|
924
|
+
[key: string]: unknown;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
interface CheckpointTrapEvidence {
|
|
928
|
+
isTrap: boolean;
|
|
929
|
+
checkpoints: CheckpointInfo[];
|
|
930
|
+
pc: number | null;
|
|
931
|
+
handler: IrqHandlerResolution;
|
|
932
|
+
trapCheckpoint: CheckpointInfo | null;
|
|
933
|
+
trapReason: "pc" | "handler" | null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function gatherCheckpointTrapEvidence(): Promise<CheckpointTrapEvidence> {
|
|
937
|
+
const checkpointsResult = await call("vice_checkpoint_list", {});
|
|
938
|
+
const checkpoints: CheckpointInfo[] =
|
|
939
|
+
isPlainObject(checkpointsResult) && Array.isArray(checkpointsResult.checkpoints)
|
|
940
|
+
? (checkpointsResult.checkpoints as CheckpointInfo[])
|
|
941
|
+
: [];
|
|
942
|
+
|
|
943
|
+
const regs = await call("vice_registers_get", {});
|
|
944
|
+
const pc = isPlainObject(regs) && typeof regs.PC === "number" ? regs.PC : null;
|
|
945
|
+
|
|
946
|
+
const handler = await resolveLiveIrqHandler();
|
|
947
|
+
|
|
948
|
+
const armedStopping = checkpoints.filter((c) => c && c.enabled !== false && c.stop === true && c.exec === true);
|
|
949
|
+
|
|
950
|
+
const atPc = pc !== null ? armedStopping.find((c) => toAddressNumber(c.start) === pc) : undefined;
|
|
951
|
+
const atHandler =
|
|
952
|
+
!atPc && handler.target !== null && handler.target !== undefined
|
|
953
|
+
? armedStopping.find((c) => toAddressNumber(c.start) === handler.target && c.hit_count === 0)
|
|
954
|
+
: undefined;
|
|
955
|
+
|
|
956
|
+
const trapCheckpoint = atPc || atHandler || null;
|
|
957
|
+
return {
|
|
958
|
+
isTrap: Boolean(trapCheckpoint),
|
|
959
|
+
checkpoints,
|
|
960
|
+
pc,
|
|
961
|
+
handler,
|
|
962
|
+
trapCheckpoint,
|
|
963
|
+
trapReason: atPc ? "pc" : atHandler ? "handler" : null,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// The recorded incident this report's own "not guaranteed" paragraph cites --
|
|
968
|
+
// D-15's own caveat, load-bearing per this plan's planning notes: delete,
|
|
969
|
+
// soft reset, hard reset and an explicit single step ALL left the machine
|
|
970
|
+
// frozen in this recorded case.
|
|
971
|
+
const CHECKPOINT_TRAP_INCIDENT_REF =
|
|
972
|
+
".planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md";
|
|
973
|
+
|
|
974
|
+
/** Renders the checkpoint_trap verdict's report -- an explanation, never a
|
|
975
|
+
* remedy (D-15): it names the armed checkpoints, the resolved handler, the
|
|
976
|
+
* PC's relation to the trap, states plainly this is self-inflicted and not a
|
|
977
|
+
* wedge, names the agent's own next moves without performing any of them,
|
|
978
|
+
* and closes with the not-guaranteed paragraph. */
|
|
979
|
+
function renderCheckpointTrapReport(evidence: CheckpointTrapEvidence): string {
|
|
980
|
+
const { checkpoints, pc, handler, trapCheckpoint, trapReason } = evidence;
|
|
981
|
+
const checkpointList =
|
|
982
|
+
checkpoints.length === 0
|
|
983
|
+
? "none armed"
|
|
984
|
+
: checkpoints
|
|
985
|
+
.map((c) => {
|
|
986
|
+
const addr = formatAddress(toAddressNumber(c && c.start));
|
|
987
|
+
const flag = c && c.stop ? "stop" : "continue";
|
|
988
|
+
const enabled = c && c.enabled === false ? "disabled" : "enabled";
|
|
989
|
+
const hitCount = c && typeof c.hit_count === "number" ? c.hit_count : "unknown";
|
|
990
|
+
return `#${c && c.checkpoint_num} ${addr} (${flag}, ${enabled}, hit_count ${hitCount})`;
|
|
991
|
+
})
|
|
992
|
+
.join("; ");
|
|
993
|
+
|
|
994
|
+
const pcRelation =
|
|
995
|
+
trapReason === "pc"
|
|
996
|
+
? `exactly at armed checkpoint #${trapCheckpoint!.checkpoint_num} -- that is why the machine is stopped here`
|
|
997
|
+
: trapReason === "handler"
|
|
998
|
+
? `not at the armed checkpoint's own address, but checkpoint #${trapCheckpoint!.checkpoint_num} sits at ` +
|
|
999
|
+
"the resolved live IRQ handler entry with hit_count 0 -- the corroborating tell that this checkpoint " +
|
|
1000
|
+
"has never actually fired, not merely that it fired between reads"
|
|
1001
|
+
: "no relation established";
|
|
1002
|
+
|
|
1003
|
+
return [
|
|
1004
|
+
"vice_diagnose verdict: checkpoint_trap",
|
|
1005
|
+
"",
|
|
1006
|
+
`Armed checkpoints: ${checkpointList}.`,
|
|
1007
|
+
`Resolved live IRQ handler: ${handler.explanation}`,
|
|
1008
|
+
`Current PC: ${formatAddress(pc)} -- ${pcRelation}.`,
|
|
1009
|
+
"",
|
|
1010
|
+
"This is a self-inflicted stop, not a wedge: the machine paused because an armed checkpoint " +
|
|
1011
|
+
"fired or sits exactly here, not because it stopped retiring cycles on its own. Recycling now " +
|
|
1012
|
+
"would destroy a healthy instance -- no cycle bracket was run to reach this verdict.",
|
|
1013
|
+
"",
|
|
1014
|
+
"Next moves available to you (this report does not perform any of them): vice_checkpoint_delete " +
|
|
1015
|
+
"the offending checkpoint, or vice_checkpoint_toggle it disabled; vice_execution_step past it; " +
|
|
1016
|
+
"then re-run vice_diagnose.",
|
|
1017
|
+
"",
|
|
1018
|
+
"Not guaranteed: deleting the checkpoint is not guaranteed to unfreeze the machine. The recorded " +
|
|
1019
|
+
`incident (${CHECKPOINT_TRAP_INCIDENT_REF}) shows checkpoint delete, then a soft reset, then a hard ` +
|
|
1020
|
+
"reset, then an explicit single step ALL leaving the machine frozen in sequence -- a checkpoint " +
|
|
1021
|
+
"trap may be the onset without being the whole story. If a cycle bracket still measures zero " +
|
|
1022
|
+
"after the checkpoint is gone, the verdict becomes wedged and recycle is the fallback after all.",
|
|
1023
|
+
].join("\n");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Renders the restarted verdict's report -- reached from a plain epoch-file
|
|
1027
|
+
* comparison alone, at zero emulator calls (D-14's ordering: this check
|
|
1028
|
+
* costs nothing and runs first). */
|
|
1029
|
+
function renderRestartedReport(beforeEpoch: number | null | undefined, afterEpoch: number | null | undefined): string {
|
|
1030
|
+
return (
|
|
1031
|
+
"vice_diagnose verdict: restarted\n\n" +
|
|
1032
|
+
`The host VICE MCP server's epoch changed from ${beforeEpoch} to ${afterEpoch} -- the emulator ` +
|
|
1033
|
+
"behind this session restarted. This is answered from a plain epoch comparison alone, at zero " +
|
|
1034
|
+
"emulator calls; no checkpoint enumeration was attempted, because a restart is this project's own " +
|
|
1035
|
+
"already-handled case (criterion 1) and re-deriving it here would be a second mechanism. Any run " +
|
|
1036
|
+
"in flight before this point is void."
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Plan 01.3-02 task 2: the cycle bracket, the definitive liveness test, and
|
|
1041
|
+
// the three verdicts that depend on it (wedged, stale_read_path, live).
|
|
1042
|
+
|
|
1043
|
+
// Three polls: the bracket needs the machine to be given real forwarded
|
|
1044
|
+
// round trips to retire cycles across, and three is enough for the counter
|
|
1045
|
+
// to move at any rate worth calling alive.
|
|
1046
|
+
const CYCLE_BRACKET_PINGS = 3;
|
|
1047
|
+
// Two brackets: criterion 2's minimum for a wedged verdict is two
|
|
1048
|
+
// consecutive zeros, and D-04 makes every additional bracket another call to
|
|
1049
|
+
// the tool most correlated with host death. Two is the minimum and the
|
|
1050
|
+
// maximum.
|
|
1051
|
+
const CYCLE_BRACKET_MAX = 2;
|
|
1052
|
+
|
|
1053
|
+
// ~991,000 cycles/s is the measured PAL C64 full-speed rate (RE-FINDINGS.md,
|
|
1054
|
+
// "the only trustworthy VICE liveness test is a cycle bracket"). Printed
|
|
1055
|
+
// only, as an observation beside a measured rate -- D-08 refuses a
|
|
1056
|
+
// degradation threshold, and a constant that is only ever printed cannot
|
|
1057
|
+
// become one by accident.
|
|
1058
|
+
const BASELINE_CYCLES_PER_SECOND = 991000;
|
|
1059
|
+
|
|
1060
|
+
function cyclesFromStopwatchResult(result: unknown): number {
|
|
1061
|
+
if (isPlainObject(result) && typeof result.cycles === "number") return result.cycles;
|
|
1062
|
+
if (isPlainObject(result) && typeof result.previous_cycles === "number") return result.previous_cycles;
|
|
1063
|
+
return 0;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
interface CycleBracketResult {
|
|
1067
|
+
cycles: number;
|
|
1068
|
+
elapsedMs: number;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The single definition of the cycle bracket criterion 2 requires: reset the
|
|
1073
|
+
* stopwatch, resume execution exactly once, poll with ping
|
|
1074
|
+
* CYCLE_BRACKET_PINGS times, pause, read the stopwatch back. Pacing comes
|
|
1075
|
+
* from the forwarded round trips alone -- there is no timer, no delay and no
|
|
1076
|
+
* wall-clock quantity anywhere in it (the standing project rule). Every
|
|
1077
|
+
* stopwatch call in this file lives inside this function's body; the
|
|
1078
|
+
* structural test enforces it. `elapsedMs` is measured only to print an
|
|
1079
|
+
* observational rate afterward -- it decides nothing and paces nothing.
|
|
1080
|
+
*/
|
|
1081
|
+
async function runCycleBracket() {
|
|
1082
|
+
await call("vice_cycles_stopwatch", { action: "reset" });
|
|
1083
|
+
const startedAt = Date.now();
|
|
1084
|
+
await call("vice_execution_run", {});
|
|
1085
|
+
for (let i = 0; i < CYCLE_BRACKET_PINGS; i += 1) {
|
|
1086
|
+
await call("vice_ping", {}); // the ping EXECUTION field is never inspected here -- it decides nothing (C1, D-07)
|
|
1087
|
+
}
|
|
1088
|
+
await call("vice_execution_pause", {});
|
|
1089
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1090
|
+
const readResult = await call("vice_cycles_stopwatch", { action: "read" });
|
|
1091
|
+
const cycles = cyclesFromStopwatchResult(readResult);
|
|
1092
|
+
return { cycles, elapsedMs };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function registersByteIdentical(a: unknown, b: unknown): boolean {
|
|
1096
|
+
try {
|
|
1097
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
interface BracketEvidence {
|
|
1104
|
+
regsBefore: unknown;
|
|
1105
|
+
regsAfter: unknown;
|
|
1106
|
+
bracket1: CycleBracketResult;
|
|
1107
|
+
bracket2: CycleBracketResult | null;
|
|
1108
|
+
finalBracket: CycleBracketResult;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Gathers the bracket evidence: a register snapshot at each end, bracket
|
|
1113
|
+
* one, and -- only when bracket one retired exactly zero cycles -- bracket
|
|
1114
|
+
* two. A non-zero first bracket short-circuits (D-04): the answer is already
|
|
1115
|
+
* not wedged, and a second resume buys nothing.
|
|
1116
|
+
*/
|
|
1117
|
+
async function gatherBracketEvidence(): Promise<BracketEvidence> {
|
|
1118
|
+
const regsBefore = await call("vice_registers_get", {});
|
|
1119
|
+
const bracket1 = await runCycleBracket();
|
|
1120
|
+
let bracket2: CycleBracketResult | null = null;
|
|
1121
|
+
let finalBracket = bracket1;
|
|
1122
|
+
if (bracket1.cycles === 0) {
|
|
1123
|
+
bracket2 = await runCycleBracket();
|
|
1124
|
+
finalBracket = bracket2;
|
|
1125
|
+
}
|
|
1126
|
+
const regsAfter = await call("vice_registers_get", {});
|
|
1127
|
+
return { regsBefore, regsAfter, bracket1, bracket2, finalBracket };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
type LivenessVerdict = "wedged" | "stale_read_path" | "live";
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Produces the post-bracket verdict (criterion 2/3). Two consecutive zeros
|
|
1134
|
+
* is wedged and nothing else is. On any non-zero result (whichever bracket
|
|
1135
|
+
* produced it), a byte-identical register snapshot across an advancing
|
|
1136
|
+
* bracket is stale_read_path -- one read path is stale while the machine is
|
|
1137
|
+
* demonstrably not frozen; anything else is live.
|
|
1138
|
+
*/
|
|
1139
|
+
function classifyLiveness(evidence: BracketEvidence): LivenessVerdict {
|
|
1140
|
+
const { bracket1, bracket2, regsBefore, regsAfter } = evidence;
|
|
1141
|
+
if (bracket1.cycles === 0 && (!bracket2 || bracket2.cycles === 0)) {
|
|
1142
|
+
return "wedged";
|
|
1143
|
+
}
|
|
1144
|
+
return registersByteIdentical(regsBefore, regsAfter) ? "stale_read_path" : "live";
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Renders the post-bracket report (wedged/stale_read_path/live). Separates
|
|
1149
|
+
* load-bearing evidence (the restart epoch, already checked; the stopwatch
|
|
1150
|
+
* delta across the bracket) from corroborating evidence (the program
|
|
1151
|
+
* counter, VIC-II state, checkpoint hit counts, a screenshot) explicitly --
|
|
1152
|
+
* criterion 3's own requirement. A status of ok with an execution state of
|
|
1153
|
+
* running is compatible with every one of these verdicts and is therefore
|
|
1154
|
+
* evidence for none of them.
|
|
1155
|
+
*/
|
|
1156
|
+
function renderDiagnoseReport(evidence: BracketEvidence, verdict: LivenessVerdict): string {
|
|
1157
|
+
const { bracket1, bracket2, finalBracket } = evidence;
|
|
1158
|
+
const bracketsRun = bracket2 ? 2 : 1;
|
|
1159
|
+
const ratePerSecond =
|
|
1160
|
+
finalBracket.cycles > 0 ? Math.round((finalBracket.cycles / Math.max(finalBracket.elapsedMs, 1)) * 1000) : 0;
|
|
1161
|
+
|
|
1162
|
+
const lines = [
|
|
1163
|
+
`vice_diagnose verdict: ${verdict}`,
|
|
1164
|
+
"",
|
|
1165
|
+
"Load-bearing evidence: the restart epoch (already checked, at zero emulator cost) and the " +
|
|
1166
|
+
`stopwatch cycle delta across the bracket -- bracket 1 retired ${bracket1.cycles} cycles` +
|
|
1167
|
+
(bracket2 ? `, bracket 2 retired ${bracket2.cycles} cycles` : "") +
|
|
1168
|
+
` (${bracketsRun} bracket${bracketsRun > 1 ? "s" : ""} run, ${bracketsRun} resume call${bracketsRun > 1 ? "s" : ""}).`,
|
|
1169
|
+
"Corroborating evidence only, never load-bearing on its own: the program counter, VIC-II state, " +
|
|
1170
|
+
"checkpoint hit counts, and a screenshot. A status of ok with an execution state of running is " +
|
|
1171
|
+
"compatible with every one of these verdicts and is therefore evidence for none of them.",
|
|
1172
|
+
];
|
|
1173
|
+
|
|
1174
|
+
if (verdict !== "wedged") {
|
|
1175
|
+
lines.push(
|
|
1176
|
+
`Measured rate this call: ~${finalBracket.cycles} cycles in ~${finalBracket.elapsedMs}ms ` +
|
|
1177
|
+
`(~${ratePerSecond} cycles/s), beside the baseline ~${BASELINE_CYCLES_PER_SECOND} cycles/s ` +
|
|
1178
|
+
"(PAL C64 full speed) -- an observation, never a threshold, and never a verdict of its own."
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (verdict === "stale_read_path") {
|
|
1183
|
+
lines.push(
|
|
1184
|
+
"The register-read path returned a byte-identical snapshot across both ends of an advancing " +
|
|
1185
|
+
"bracket -- that read path is stale, but the machine is demonstrably not frozen."
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
lines.push(
|
|
1190
|
+
verdict === "wedged"
|
|
1191
|
+
? "Machine state left: paused, after two zero-cycle brackets. Resuming is your own deliberate next call."
|
|
1192
|
+
: "Machine state left: paused, after the bracket that reached this verdict. Resuming is your own deliberate next call."
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
return lines.join("\n");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Handles vice_diagnose. Fixed check order, and the order is the point
|
|
1200
|
+
* (D-14): first the epoch comparison (zero emulator calls), then the
|
|
1201
|
+
* checkpoint-trap check (no resume at all). Never throws past this point --
|
|
1202
|
+
* every branch is a well-formed isError:false or isError:true result.
|
|
1203
|
+
*/
|
|
1204
|
+
async function handleDiagnose(_args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
1205
|
+
try {
|
|
1206
|
+
const leaseResult = await ensureBrokerLease();
|
|
1207
|
+
if (!leaseResult.ok) {
|
|
1208
|
+
return isErrorText(leaseResult.message);
|
|
1209
|
+
}
|
|
1210
|
+
ensureViceSession();
|
|
1211
|
+
|
|
1212
|
+
const epochNow = currentEpoch();
|
|
1213
|
+
if (epochChanged(epochBaseline, epochNow)) {
|
|
1214
|
+
const before = (epochBaseline as EpochResult).epoch;
|
|
1215
|
+
epochBaseline = epochNow; // never cache a negative result (criterion 6)
|
|
1216
|
+
return { content: [{ type: "text", text: renderRestartedReport(before, epochNow.epoch) }], isError: false };
|
|
1217
|
+
}
|
|
1218
|
+
if (!(epochBaseline as EpochResult).present && epochNow.present) {
|
|
1219
|
+
epochBaseline = epochNow;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const trapEvidence = await gatherCheckpointTrapEvidence();
|
|
1223
|
+
if (trapEvidence.isTrap) {
|
|
1224
|
+
return { content: [{ type: "text", text: renderCheckpointTrapReport(trapEvidence) }], isError: false };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Third and last: the cycle bracket, the definitive liveness test, drives
|
|
1228
|
+
// the three remaining verdicts (D-14's full order: epoch, trap, bracket).
|
|
1229
|
+
const bracketEvidence = await gatherBracketEvidence();
|
|
1230
|
+
const verdict = classifyLiveness(bracketEvidence);
|
|
1231
|
+
return { content: [{ type: "text", text: renderDiagnoseReport(bracketEvidence, verdict) }], isError: false };
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
if (e instanceof MachineRestartedError) {
|
|
1234
|
+
const current = currentEpoch();
|
|
1235
|
+
epochBaseline = current;
|
|
1236
|
+
return { content: [{ type: "text", text: renderRestartedReport(e.baselineEpoch, e.currentEpoch) }], isError: false };
|
|
1237
|
+
}
|
|
1238
|
+
return isErrorText(
|
|
1239
|
+
`vice_diagnose: an unexpected error occurred while gathering evidence: ${e && (e as Error).message ? (e as Error).message : e}`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// -------------------------------------------- vice_recycle: evidence gather
|
|
1245
|
+
//
|
|
1246
|
+
// Plan 01.3-03 (criterion 4): the destructive path's own evidence set,
|
|
1247
|
+
// composed ENTIRELY from reads already forwardable through call() -- no new
|
|
1248
|
+
// host capability, no second route. runCycleBracket() and
|
|
1249
|
+
// resolveLiveIrqHandler() are plan 01.3-02's own single definitions, reused
|
|
1250
|
+
// here rather than re-derived (this plan's own key_links) -- criterion 2's
|
|
1251
|
+
// single-bracket-definition guard is a PHASE property, not a plan one.
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Capture-step deadline (T-01.3-10): a TRANSPORT deadline bounding how long
|
|
1255
|
+
* ANY single evidence-gathering step (including the pre-kill snapshot
|
|
1256
|
+
* attempt, task 2) may wait for its own forwarded call(s) before this
|
|
1257
|
+
* wrapper gives up and records an explicit unavailable-with-reason entry.
|
|
1258
|
+
*
|
|
1259
|
+
* This is deliberately DIFFERENT from the project's standing prohibition on
|
|
1260
|
+
* WALL-CLOCK PACING (never sleep to wait for the emulated machine to reach
|
|
1261
|
+
* some state -- synchronise on checkpoint hits and cycle counts instead):
|
|
1262
|
+
* that rule governs synchronising INPUT/WAITS against the emulated game's
|
|
1263
|
+
* own state. This deadline governs a capture step's patience with the
|
|
1264
|
+
* TRANSPORT alone -- exactly the kind of deadline call()'s own
|
|
1265
|
+
* AbortSignal.timeout already applies per forwarded call, just bounding the
|
|
1266
|
+
* WHOLE step (which may issue several forwarded calls, e.g. the bracket) so
|
|
1267
|
+
* one non-answering read can never stall the whole gather, and the
|
|
1268
|
+
* snapshot attempt can never stall the recycle itself (D-19). Overridable
|
|
1269
|
+
* purely so this file's own test suite can exercise a "never answers"
|
|
1270
|
+
* fixture in milliseconds rather than minutes -- production always uses the
|
|
1271
|
+
* generous default.
|
|
1272
|
+
*/
|
|
1273
|
+
const CAPTURE_STEP_TIMEOUT_MS = Number(process.env.VICE_RECYCLE_CAPTURE_TIMEOUT_MS || 8000);
|
|
1274
|
+
|
|
1275
|
+
/** captureStep()'s own result shape -- structurally an EvidenceItem
|
|
1276
|
+
* (incident-record.ts), just narrowed to a discriminated union here so a
|
|
1277
|
+
* caller can branch on `available` without an optional-field guess. */
|
|
1278
|
+
type CaptureStepResult<T> = { available: true; value: T } | { available: false; reason: string };
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Runs one evidence-gathering step, turning any rejection, transport
|
|
1282
|
+
* failure or capture-step deadline into an explicit `{ available: false,
|
|
1283
|
+
* reason }` entry rather than letting it abort the whole gather -- the
|
|
1284
|
+
* whole point (D-17, D-19) is that a wedged machine will fail SOME of these
|
|
1285
|
+
* and the record must still exist. Never throws.
|
|
1286
|
+
*/
|
|
1287
|
+
async function captureStep<T>(fn: () => Promise<T>): Promise<CaptureStepResult<T>> {
|
|
1288
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1289
|
+
try {
|
|
1290
|
+
const value = await Promise.race([
|
|
1291
|
+
fn(),
|
|
1292
|
+
new Promise<never>((_, reject) => {
|
|
1293
|
+
timer = setTimeout(
|
|
1294
|
+
() => reject(new Error(`capture step deadline of ${CAPTURE_STEP_TIMEOUT_MS}ms exceeded`)),
|
|
1295
|
+
CAPTURE_STEP_TIMEOUT_MS
|
|
1296
|
+
);
|
|
1297
|
+
}),
|
|
1298
|
+
]);
|
|
1299
|
+
return { available: true, value };
|
|
1300
|
+
} catch (e) {
|
|
1301
|
+
return { available: false, reason: e && (e as Error).message ? (e as Error).message : String(e) };
|
|
1302
|
+
} finally {
|
|
1303
|
+
clearTimeout(timer);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Assembles criterion-4's evidence set for an incident record: one cycle
|
|
1309
|
+
* bracket (runCycleBracket(), plan 01.3-02 -- NEVER a second bracket
|
|
1310
|
+
* definition), the program counter and full register snapshot, the full
|
|
1311
|
+
* checkpoint enumeration (address, enabled flag, stop-or-continue), the
|
|
1312
|
+
* resolved live IRQ handler (resolveLiveIrqHandler(), plan 01.3-02), and a
|
|
1313
|
+
* screenshot written to a path in the incidents directory sharing the
|
|
1314
|
+
* record's own stem. Every step goes through captureStep() above, so no
|
|
1315
|
+
* step can abort the gather.
|
|
1316
|
+
*
|
|
1317
|
+
* `at`/`port`/`epoch` name the SAME triple the caller passes to
|
|
1318
|
+
* writeIncidentRecord(), so the screenshot's path shares that record's stem
|
|
1319
|
+
* (best-effort: the very rare case of a same-millisecond/port/epoch
|
|
1320
|
+
* collision forcing writeIncidentRecord() to append a numeric suffix onto
|
|
1321
|
+
* the actual .md file is not reflected here, since this path is computed
|
|
1322
|
+
* BEFORE that write happens).
|
|
1323
|
+
*/
|
|
1324
|
+
async function gatherWedgeEvidence({ at, port, epoch }: IncidentAssetStemOptions): Promise<IncidentEvidence> {
|
|
1325
|
+
const bracket = await captureStep(() => runCycleBracket());
|
|
1326
|
+
const registers = await captureStep(() => call("vice_registers_get", {}));
|
|
1327
|
+
const checkpoints = await captureStep(async () => {
|
|
1328
|
+
const result = await call("vice_checkpoint_list", {});
|
|
1329
|
+
const list: CheckpointInfo[] =
|
|
1330
|
+
isPlainObject(result) && Array.isArray(result.checkpoints) ? (result.checkpoints as CheckpointInfo[]) : [];
|
|
1331
|
+
return list.map((c) => ({
|
|
1332
|
+
checkpoint_num: c && c.checkpoint_num,
|
|
1333
|
+
address: formatAddress(toAddressNumber(c && c.start)),
|
|
1334
|
+
enabled: Boolean(c && c.enabled !== false),
|
|
1335
|
+
flag: c && c.stop ? "stop" : "continue",
|
|
1336
|
+
}));
|
|
1337
|
+
});
|
|
1338
|
+
const irqHandler = await captureStep(() => resolveLiveIrqHandler());
|
|
1339
|
+
|
|
1340
|
+
// The screenshot's path argument must be translated (T-01.3-11's sibling
|
|
1341
|
+
// concern): handleToolsCall() applies rewriteArguments() before
|
|
1342
|
+
// forwarding, and this proxy-local caller does NOT pass through that seam
|
|
1343
|
+
// -- so it is called explicitly here. Skipping this would write the file
|
|
1344
|
+
// to a host path that does not exist and return a success the record
|
|
1345
|
+
// would then be lying about.
|
|
1346
|
+
const screenshotContainerPath = incidentAssetPath({ at, port, epoch, ext: "png" });
|
|
1347
|
+
const screenshot = await captureStep(async () => {
|
|
1348
|
+
const { args: translated } = rewriteArguments({ path: screenshotContainerPath }, "vice_display_screenshot");
|
|
1349
|
+
await call("vice_display_screenshot", translated);
|
|
1350
|
+
return relative(repoRoot(), screenshotContainerPath);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
return { bracket, registers, checkpoints, irqHandler, screenshot };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* The best-effort pre-kill snapshot (plan 01.3-03 task 2, D-19): the LAST
|
|
1358
|
+
* capture step, run immediately before the incident record is written. It
|
|
1359
|
+
* takes a NAME, not a path -- vice_snapshot_save's own contract -- so the
|
|
1360
|
+
* file lands in the host emulator's own snapshot directory and nothing
|
|
1361
|
+
* container-side can confirm it landed there. The record therefore says
|
|
1362
|
+
* the ATTEMPT was accepted, never that a file was verified (T-01.3-11): the
|
|
1363
|
+
* wording must not overstate what was established. A rejection, a
|
|
1364
|
+
* transport failure or a capture-step deadline records unavailable with
|
|
1365
|
+
* the reason verbatim and moves on -- it cannot fail or stall the recycle.
|
|
1366
|
+
* The name is built from the SAME timestamp/port/epoch triple the incident
|
|
1367
|
+
* record's own stem uses, so the two artifacts are trivially correlated
|
|
1368
|
+
* later.
|
|
1369
|
+
*/
|
|
1370
|
+
async function captureSnapshotAttempt({
|
|
1371
|
+
at,
|
|
1372
|
+
port,
|
|
1373
|
+
epoch,
|
|
1374
|
+
}: IncidentAssetStemOptions): Promise<CaptureStepResult<{ name: string }>> {
|
|
1375
|
+
const name = incidentAssetStem({ at, port, epoch });
|
|
1376
|
+
return captureStep(async () => {
|
|
1377
|
+
await call("vice_snapshot_save", { name, description: "vice_recycle pre-kill evidence capture" });
|
|
1378
|
+
return { name };
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// --------------------------------------------------- unreachable diagnostics
|
|
1383
|
+
//
|
|
1384
|
+
// Plan 01.1-03 task 2 / ROADMAP criterion 7. Blocking on withReconnect()'s
|
|
1385
|
+
// ~50s ladder turns a clear diagnosis into an opaque tool timeout, so every
|
|
1386
|
+
// forwarded tools/call gets a pre-flight `probeInstance()` check FIRST (one
|
|
1387
|
+
// 1500ms-budget round trip, no retry -- see vice-probe.ts's own header for
|
|
1388
|
+
// why reusing the resilient ladder here would be wrong). When the probe
|
|
1389
|
+
// reports the emulator unreachable, this classifies the failure into exactly
|
|
1390
|
+
// one of three states, each with its own message and its own fix, each
|
|
1391
|
+
// quoting an absolute host path, each closing off the "just run the
|
|
1392
|
+
// transport module from a shell instead" workaround explicitly.
|
|
1393
|
+
//
|
|
1394
|
+
// This MCP tool surface is the only route to the emulator -- never named
|
|
1395
|
+
// together with a CLI verb here, since plan 01.1-04 installs a durable gate
|
|
1396
|
+
// matching exactly that pattern in documentation.
|
|
1397
|
+
const ONLY_ROUTE_NOTE =
|
|
1398
|
+
"This MCP tool surface is the only route to the emulator. The correct action is to stop and ask " +
|
|
1399
|
+
"the human to start it on the host -- falling back to a direct shell invocation of the underlying " +
|
|
1400
|
+
"transport is not an available workaround.";
|
|
1401
|
+
|
|
1402
|
+
// supervisorHostPath() (the per-instance-supervisor host-path helper) is
|
|
1403
|
+
// GONE, not merely unused (01.6.2-09, T-01.6.2-54/T-01.6.2-59): its three
|
|
1404
|
+
// former consumers below -- neverStartedMessage(), deadOrHungMessage() and
|
|
1405
|
+
// aliveButFailedMessage() -- now resolve brokerHostPath() instead, the SAME
|
|
1406
|
+
// single helper the broker-absent triple already used. There is exactly one
|
|
1407
|
+
// host-path helper left in this file (a structural test in
|
|
1408
|
+
// vice-proxy.test.ts asserts that directly: the resolved path's basename
|
|
1409
|
+
// equals the surviving launcher's filename).
|
|
1410
|
+
|
|
1411
|
+
/** The absolute path of the command a human should run on the HOST to
|
|
1412
|
+
* start/restart access to the emulator -- computed via hostPath() over the
|
|
1413
|
+
* deployed launcher's container path, degrading to the container path plus
|
|
1414
|
+
* SET_ENV_HINT exactly as install-resources.ts's hostLaunchInstructions()
|
|
1415
|
+
* does, so a translation failure still yields something to act on rather
|
|
1416
|
+
* than an empty message. Recomputed fresh every call -- never cached (see
|
|
1417
|
+
* the never-cache-a-negative-result invariant above ensureViceSession()).
|
|
1418
|
+
* Points at resources/vice-launcher.sh's deployed copy -- the one surviving
|
|
1419
|
+
* host script (01.6.2-09). Every message in this file that used to name
|
|
1420
|
+
* either the retiring per-instance supervisor (vice-supervisor.sh) or the
|
|
1421
|
+
* retiring bash broker (vice-broker.sh) now names THIS launcher instead: its
|
|
1422
|
+
* own broker performs both the acquire-on-demand job the bash broker did and
|
|
1423
|
+
* the launch/supervise/respawn-with-backoff job the bash supervisor did. */
|
|
1424
|
+
function brokerHostPath(): string {
|
|
1425
|
+
const root = repoRoot();
|
|
1426
|
+
const target = join(root, "tools", "vice-launcher.sh");
|
|
1427
|
+
try {
|
|
1428
|
+
return hostPath(target, { workspaceRoot: root });
|
|
1429
|
+
} catch {
|
|
1430
|
+
return `${target}\n (host path could not be determined -- ${SET_ENV_HINT})`;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ------------------------------------------------- broker-absent diagnostics
|
|
1435
|
+
//
|
|
1436
|
+
// Plan 01.2-03 task 1 / must_have C10. A missing broker answers exactly one
|
|
1437
|
+
// generic message two times out of three sends the reader to the wrong fix
|
|
1438
|
+
// -- mirrors the host-unreachable triple above (never-started /
|
|
1439
|
+
// dead-or-hung / alive-but-failed), but answers a DIFFERENT question ("is
|
|
1440
|
+
// the on-demand broker itself reachable" vs "is the host VICE MCP server
|
|
1441
|
+
// reachable"), so both triples stay in place side by side, not one
|
|
1442
|
+
// replacing the other. Every message here quotes brokerHostPath() (an
|
|
1443
|
+
// absolute HOST path, recomputed fresh -- see that function's own comment)
|
|
1444
|
+
// and the single shared ONLY_ROUTE_NOTE definition; no message below writes
|
|
1445
|
+
// its own second only-route sentence. As of 01.6.2-09, the host-unreachable
|
|
1446
|
+
// triple below quotes the exact same brokerHostPath() helper -- there is
|
|
1447
|
+
// only one surviving launcher left to name, so both triples now resolve
|
|
1448
|
+
// identically rather than two different paths.
|
|
1449
|
+
|
|
1450
|
+
/** State: readBrokerLiveness() found no broker.json at all -- the broker has
|
|
1451
|
+
* never been started on this host. Nothing on the other side would ever
|
|
1452
|
+
* read a request, so ensureBrokerLease() returns this BEFORE writing one. */
|
|
1453
|
+
function brokerNeverStartedMessage(): string {
|
|
1454
|
+
return (
|
|
1455
|
+
`vice: the on-demand VICE broker has never been started on this host -- no broker.json ` +
|
|
1456
|
+
`record exists at all. Start it on the host with:\n` +
|
|
1457
|
+
` ${brokerHostPath()}\n` +
|
|
1458
|
+
ONLY_ROUTE_NOTE
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** State: broker.json exists but its heartbeat is older than the stale
|
|
1463
|
+
* threshold -- the broker process is dead or hung. Quotes the recorded pid
|
|
1464
|
+
* (readBrokerLiveness()'s own field), since checking that pid is the first
|
|
1465
|
+
* thing a human does on the host, mirroring deadOrHungMessage() above. */
|
|
1466
|
+
function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
|
|
1467
|
+
const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
|
|
1468
|
+
return (
|
|
1469
|
+
`vice: the on-demand VICE broker appears to be dead or hung${pidNote} -- its last recorded ` +
|
|
1470
|
+
`heartbeat is older than the stale threshold. Restart it on the host with:\n` +
|
|
1471
|
+
` ${brokerHostPath()}\n` +
|
|
1472
|
+
ONLY_ROUTE_NOTE
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** State: the broker is alive and a request was polled, but it wrote a
|
|
1477
|
+
* denial rather than a grant. Relays the denial's own `reason` field
|
|
1478
|
+
* VERBATIM -- never paraphrased -- and deliberately carries no RESTART
|
|
1479
|
+
* instruction, for the same reason aliveButFailedMessage() above carries
|
|
1480
|
+
* none: restarting something that is answering correctly is the wrong fix.
|
|
1481
|
+
* Still names an absolute path (the running broker's own launcher, purely
|
|
1482
|
+
* as a reference, mirroring aliveButFailedMessage()'s `hostRef` note) and
|
|
1483
|
+
* the only-route sentence, both required of every broker-absent-adjacent
|
|
1484
|
+
* message this proxy emits. */
|
|
1485
|
+
function brokerLaunchFailedMessage(reason: string): string {
|
|
1486
|
+
const hostRef = brokerHostPath().split("\n")[0];
|
|
1487
|
+
return (
|
|
1488
|
+
`vice: the on-demand VICE broker (running via the host-side launcher at ${hostRef}) declined ` +
|
|
1489
|
+
`to grant an instance for this session: ${reason} ${ONLY_ROUTE_NOTE}`
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** State: the broker is alive and a request was written, but neither a
|
|
1494
|
+
* grant nor a denial appeared before pollGrant()'s own deadline -- an
|
|
1495
|
+
* explicit warming-and-retry result, never a silent hang. A cold x64sc
|
|
1496
|
+
* launch plus boot plus readiness is seconds (spike-findings-bruce-lee
|
|
1497
|
+
* skill), well inside the client's own per-server timeout (.mcp.json's
|
|
1498
|
+
* `timeout` field, task 2), so the correct next action is simply to retry
|
|
1499
|
+
* the SAME call, not to treat this as a failure requiring a different fix. */
|
|
1500
|
+
function brokerWarmingMessage(elapsedMs: number): string {
|
|
1501
|
+
return (
|
|
1502
|
+
`vice: the on-demand VICE broker is still warming up an instance for this session -- no ` +
|
|
1503
|
+
`grant or denial appeared within ${elapsedMs}ms. This is expected for a cold start; retry the same ` +
|
|
1504
|
+
`call now, it should succeed once the instance finishes booting.`
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** State: readBrokerLiveness() just classified broker.json as `alive` (a
|
|
1509
|
+
* FRESH heartbeat), yet openBrokerControl() still failed -- a control-plane
|
|
1510
|
+
* CONNECTIVITY failure, never a dead or hung broker. This is the fix for
|
|
1511
|
+
* the exact incident recorded in
|
|
1512
|
+
* .planning/todos/pending/2026-08-04-proxy-reports-a-live-broker-as-stale-blocking-all-emulator-access.md:
|
|
1513
|
+
* `broker.json` is read from the shared filesystem, not over the control
|
|
1514
|
+
* connection, so the freshness computation had a perfectly good timestamp
|
|
1515
|
+
* and would have returned `alive` -- the failure was one layer later, at
|
|
1516
|
+
* the connect (dialing `0.0.0.0`, the broker's own BIND address, from
|
|
1517
|
+
* inside this container). Reporting that connect failure with the
|
|
1518
|
+
* heartbeat/stale-threshold wording sent the reader chasing a threshold
|
|
1519
|
+
* that was never exceeded, costing that session roughly a dozen tool
|
|
1520
|
+
* calls. This message names the address and port instead: from
|
|
1521
|
+
* `opened.target` when the outcome resolved one (every connect-adjacent
|
|
1522
|
+
* failure kind sets it), degrading to the outcome's own `message` for a
|
|
1523
|
+
* kind that never got that far (missing broker.json fields). States
|
|
1524
|
+
* plainly that `broker.json`'s own `control_host` field is the broker's
|
|
1525
|
+
* BIND address -- valid on the host where the broker wrote it, structurally
|
|
1526
|
+
* undialable from inside this container -- so a reader is pointed at the
|
|
1527
|
+
* connectivity problem, never at broker health. Carries NO secret: not
|
|
1528
|
+
* `control_token`, not any other field of the record, only the resolved
|
|
1529
|
+
* target and the fixed prose below. Follows the broker-absent family's own
|
|
1530
|
+
* stated conventions (quotes `brokerHostPath()` purely as a reference, the
|
|
1531
|
+
* shared `ONLY_ROUTE_NOTE`, never a second only-route sentence) -- mirroring
|
|
1532
|
+
* brokerLaunchFailedMessage() above rather than the never-started/
|
|
1533
|
+
* dead-or-hung pair, since (like a launch denial) the broker here is
|
|
1534
|
+
* alive and answering correctly; restarting it would be the wrong fix. */
|
|
1535
|
+
function brokerControlUnreachableMessage(opened: { kind: ControlFailureKind; message: string; target?: string }, liveness: BrokerLivenessResult): string {
|
|
1536
|
+
const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
|
|
1537
|
+
const hostRef = brokerHostPath().split("\n")[0];
|
|
1538
|
+
const targetNote = opened.target ?? opened.message;
|
|
1539
|
+
return (
|
|
1540
|
+
`vice: the on-demand VICE broker${pidNote} (running via the host-side launcher at ${hostRef}) has ` +
|
|
1541
|
+
`a fresh, healthy heartbeat -- this is NOT a dead or hung broker. This MCP tool surface could not ` +
|
|
1542
|
+
`reach the control plane at ${targetNote}. broker.json's own control_host field records the broker's BIND ` +
|
|
1543
|
+
`address, valid on the host where the broker wrote it and structurally undialable from inside this ` +
|
|
1544
|
+
`container -- a control-plane connectivity failure, not a broker health problem. ${ONLY_ROUTE_NOTE}`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// removeRequestFile() (requests/<id>.json cleanup on a denial or a warming
|
|
1549
|
+
// timeout) is GONE, not merely unused -- its subject directory ceases to
|
|
1550
|
+
// exist under the control-plane acquisition below. There is nothing left to
|
|
1551
|
+
// clean up on a denial or a timeout because nothing was ever written: a
|
|
1552
|
+
// failed acquire() over the control connection leaves no file anywhere, so
|
|
1553
|
+
// the "orphan request the sweeper must reap" problem this helper solved
|
|
1554
|
+
// does not exist in this design.
|
|
1555
|
+
|
|
1556
|
+
// A causeCode-shaped reason string (e.g. "ECONNREFUSED", "ECONNRESET") is
|
|
1557
|
+
// exactly what probeInstance() returns for a connection actively refused --
|
|
1558
|
+
// see its own fallback `causeCode || e.message`. A timeout, an HTTP error
|
|
1559
|
+
// status, or "didn't decode to a recognisable ping" all produce prose
|
|
1560
|
+
// instead, never a bare all-caps E-code, which is what keeps this predicate
|
|
1561
|
+
// precise rather than a loose substring guess.
|
|
1562
|
+
function isConnectionRefusedReason(reason: unknown): boolean {
|
|
1563
|
+
return typeof reason === "string" && /^E[A-Z]+$/.test(reason);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function neverStartedMessage(probe: ProbeResult): string {
|
|
1567
|
+
return (
|
|
1568
|
+
`vice: the host VICE MCP server has never been started at this configured path -- no ` +
|
|
1569
|
+
`restart-epoch record exists, and the connection was refused (${probe.reason}). Start it on the host with:\n` +
|
|
1570
|
+
` ${brokerHostPath()}\n` +
|
|
1571
|
+
ONLY_ROUTE_NOTE
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function deadOrHungMessage(probe: ProbeResult, epoch: EpochResult): string {
|
|
1576
|
+
const pidNote =
|
|
1577
|
+
epoch && epoch.present && epoch.pid != null
|
|
1578
|
+
? ` (pid ${epoch.pid}${epoch.spawned_at ? `, spawned_at ${epoch.spawned_at}` : ""})`
|
|
1579
|
+
: "";
|
|
1580
|
+
return (
|
|
1581
|
+
`vice: the host VICE MCP server appears to be dead or hung${pidNote} -- ${probe.reason}. ` +
|
|
1582
|
+
`Restart it on the host with:\n` +
|
|
1583
|
+
` ${brokerHostPath()}\n` +
|
|
1584
|
+
ONLY_ROUTE_NOTE
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/** Reached only when the pre-flight probe found the host alive but the
|
|
1589
|
+
* forwarded call itself failed (a transport error the retry ladder gave up
|
|
1590
|
+
* on, or a genuine RPC error). Relays the host's own message VERBATIM --
|
|
1591
|
+
* never paraphrased -- and deliberately carries no restart instruction,
|
|
1592
|
+
* since restarting a live, correctly-answering host is the wrong fix for a
|
|
1593
|
+
* rejected tool call. Still names an absolute path and the only-route note
|
|
1594
|
+
* (both required of every unreachable-adjacent message this proxy emits),
|
|
1595
|
+
* worded so as never to suggest the action a restart message would. */
|
|
1596
|
+
function aliveButFailedMessage(errMessage: string): string {
|
|
1597
|
+
const hostRef = brokerHostPath().split("\n")[0];
|
|
1598
|
+
return (
|
|
1599
|
+
`vice: the host VICE MCP server (reachable via the host-side launcher at ${hostRef}) rejected ` +
|
|
1600
|
+
`this call: ${errMessage} ${ONLY_ROUTE_NOTE}`
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// RESOLVED RESIDUAL (originally quick-260801-ccn task 3; re-examined by
|
|
1605
|
+
// 01.6.2-09, T-01.6.2-54/T-01.6.2-59): this comment used to record that
|
|
1606
|
+
// aliveButFailedMessage() above still named a SEPARATE per-instance
|
|
1607
|
+
// supervisor path even under a broker-granted session -- a genuine
|
|
1608
|
+
// mismatch, because two different launchers existed. That mismatch is
|
|
1609
|
+
// dissolved, not merely reworded: supervisorHostPath() is deleted, and
|
|
1610
|
+
// aliveButFailedMessage() now resolves the exact same brokerHostPath()
|
|
1611
|
+
// helper every other message in this file uses, so there is only ever one
|
|
1612
|
+
// launcher path to name, regardless of session type. What still holds,
|
|
1613
|
+
// unchanged, is the REASON this message carries no restart instruction: it
|
|
1614
|
+
// answers a different question from both the host-unreachable triple and
|
|
1615
|
+
// the broker-granted message below -- an instance that IS reachable and
|
|
1616
|
+
// answering rejected ONE call -- where no launcher is the fix and a
|
|
1617
|
+
// restart would be the wrong advice on either route (broker-granted or
|
|
1618
|
+
// fixed-port).
|
|
1619
|
+
|
|
1620
|
+
// ------------------------------------------- broker-granted unreachable diagnostics
|
|
1621
|
+
//
|
|
1622
|
+
// Quick task 260801-ccn task 3 (D-5) introduced ONE message here, distinct
|
|
1623
|
+
// from both the host-unreachable triple above and the broker-ABSENT triple
|
|
1624
|
+
// below, for a granted instance that stopped answering: report the fact and
|
|
1625
|
+
// tell a human to go investigate on the host.
|
|
1626
|
+
//
|
|
1627
|
+
// Plan 01.6.2-08 (D-13) turns that report-and-instruct message into a
|
|
1628
|
+
// replace-and-report: a granted instance not answering no longer waits for
|
|
1629
|
+
// a human -- it costs this session exactly one replacement acquisition,
|
|
1630
|
+
// made automatically, and the triggering call still fails LOUDLY (never a
|
|
1631
|
+
// silently substituted result) naming the replacement. See
|
|
1632
|
+
// handleGrantedInstanceUnreachable() and its own three message builders
|
|
1633
|
+
// (machineReplacedMessage()/replacementFailedMessage()/
|
|
1634
|
+
// sessionMustRestartMessage()) further down this file, right after
|
|
1635
|
+
// ensureBrokerLease() -- the function this diagnostic superseded is gone,
|
|
1636
|
+
// not merely unused: brokerHostPath()'s "go investigate on the host"
|
|
1637
|
+
// framing no longer applies once the proxy investigates (replaces) on its
|
|
1638
|
+
// own first.
|
|
1639
|
+
|
|
1640
|
+
// ------------------------------------------------------------ path rewriting
|
|
1641
|
+
//
|
|
1642
|
+
// Decision D-G (plan 01.1-03 task 3 / criterion 9): container->host path
|
|
1643
|
+
// translation moves from every caller's own discipline into this one seam,
|
|
1644
|
+
// which sees every forwarded call. The structural rule: any string argument
|
|
1645
|
+
// value beginning with "/" is an absolute filesystem path. One that resolves
|
|
1646
|
+
// inside the mounted workspace is rewritten to its host form via
|
|
1647
|
+
// hostPath() -- the host emulator can only ever be handed a HOST path,
|
|
1648
|
+
// since it runs on the host, not in this container. One that resolves
|
|
1649
|
+
// outside the workspace is refused outright, before any forwarding, because
|
|
1650
|
+
// a container path is never correct on the host: forwarding it untouched
|
|
1651
|
+
// can only produce a wrong answer with no error, which is exactly the
|
|
1652
|
+
// silent-failure class this criterion exists to eliminate.
|
|
1653
|
+
//
|
|
1654
|
+
// RELATIVE paths: resolved against the workspace root, but ONLY for the
|
|
1655
|
+
// arguments the tools manifest declares to BE paths.
|
|
1656
|
+
//
|
|
1657
|
+
// The original rule left every relative string byte-identical, on the
|
|
1658
|
+
// reasoning that "a relative-looking string is indistinguishable from a
|
|
1659
|
+
// non-path argument (a tool name, a hex address like "$0400", an arbitrary
|
|
1660
|
+
// label) without guessing". That reasoning was sound for a walker with no
|
|
1661
|
+
// schema, and it pointed callers at a SKILL.md "Paths" section for the
|
|
1662
|
+
// absolute-path requirement -- but that SKILL.md was deleted in db9eed3,
|
|
1663
|
+
// leaving the requirement stated nowhere. CLAUDE.md's surviving wording
|
|
1664
|
+
// ("pass container paths and let the tools handle the boundary") promises
|
|
1665
|
+
// the opposite, so callers reasonably passed "disks/foo.d64" and got a bare
|
|
1666
|
+
// "Failed to attach disk image" from the host, with nothing anywhere
|
|
1667
|
+
// indicating the path was the problem. That cost real session time.
|
|
1668
|
+
//
|
|
1669
|
+
// The premise is also no longer true. tools-manifest.json -- the same file
|
|
1670
|
+
// tools/list is served from -- types every argument, and exactly four
|
|
1671
|
+
// declare a path: vice_disk_attach.path, vice_autostart.path,
|
|
1672
|
+
// vice_display_screenshot.path and vice_symbols_load.path. Consulting it
|
|
1673
|
+
// removes the guessing the residual was protecting against: a relative
|
|
1674
|
+
// string in a DECLARED path argument is a path, full stop, and everything
|
|
1675
|
+
// else keeps the byte-identical pass-through unchanged.
|
|
1676
|
+
//
|
|
1677
|
+
// Resolution is against the workspace root, never process.cwd() -- the
|
|
1678
|
+
// proxy is one long-lived process serving the whole session, so its cwd is
|
|
1679
|
+
// meaningless to the caller. (hostpath.mjs:106 resolves against cwd for its
|
|
1680
|
+
// CLI's benefit; that branch is unreachable from here, and deliberately so.)
|
|
1681
|
+
//
|
|
1682
|
+
// STATED RESIDUAL, narrower than before: a relative string in an argument
|
|
1683
|
+
// the manifest does NOT declare as a path is still left byte-identical, and
|
|
1684
|
+
// so is a relative string nested inside an object or array. Both remain
|
|
1685
|
+
// indistinguishable from non-path data. A worktree caller also resolves
|
|
1686
|
+
// against the MAIN workspace root, not its worktree -- correct for the
|
|
1687
|
+
// read-only disk images this serves, and an absolute path still overrides.
|
|
1688
|
+
const PATH_REWRITE_MAX_DEPTH = 10; // bounded so pathological nesting is left alone rather than looping forever
|
|
1689
|
+
|
|
1690
|
+
class PathOutOfWorkspaceError extends Error {}
|
|
1691
|
+
class PathTranslationError extends Error {}
|
|
1692
|
+
|
|
1693
|
+
// The boundary check MUST run against a normalized path, never the raw
|
|
1694
|
+
// string. `startsWith(root)` on an unnormalized value is satisfied by any
|
|
1695
|
+
// string that merely begins with the root's characters, so a lexical `..`
|
|
1696
|
+
// sequence -- "/workspaces/bruce_lee/../../../etc/passwd" -- passes a raw
|
|
1697
|
+
// prefix test and is then handed to hostPath(), which does NOT refuse it:
|
|
1698
|
+
// when relative() normalizes to a leading "..", hostpath.mjs deliberately
|
|
1699
|
+
// falls through to generic mount-based translation instead of throwing (its
|
|
1700
|
+
// own comment says so, for the CLI's benefit). That makes THIS check the only
|
|
1701
|
+
// workspace boundary on the forwarding path, so it has to be the strict one.
|
|
1702
|
+
//
|
|
1703
|
+
// resolve() collapses "." and ".." segments; callers only reach here after
|
|
1704
|
+
// value.startsWith("/") is confirmed, so it is pure normalization and never
|
|
1705
|
+
// pulls in process.cwd().
|
|
1706
|
+
//
|
|
1707
|
+
// STATED RESIDUAL: this is lexical, not physical -- a symlink inside the
|
|
1708
|
+
// workspace whose target lives outside it still translates. realpathSync()
|
|
1709
|
+
// would catch that but requires the file to already exist, which is wrong for
|
|
1710
|
+
// the write-side tools (snapshot_save and friends name a path that does not
|
|
1711
|
+
// exist yet). Lexical normalization is the part that can be enforced for both
|
|
1712
|
+
// directions without breaking writes.
|
|
1713
|
+
function isInsideWorkspace(absPath: string, root: string): boolean {
|
|
1714
|
+
return absPath === root || absPath.startsWith(root.endsWith("/") ? root : root + "/");
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Recursively walk `value`, applying decision D-G's structural rule to
|
|
1719
|
+
* every string found. Objects and arrays are walked (bounded by
|
|
1720
|
+
* PATH_REWRITE_MAX_DEPTH); numbers, booleans, null, and non-absolute
|
|
1721
|
+
* strings are returned byte-identical. `argPath` accumulates a
|
|
1722
|
+
* human-readable position (e.g. "arguments.path" or "arguments.files[2]")
|
|
1723
|
+
* used in a refusal message so the caller can find exactly which argument
|
|
1724
|
+
* was the problem.
|
|
1725
|
+
*/
|
|
1726
|
+
function rewritePathsIn(value: unknown, argPath: string, root: string, depth: number, asWritten?: string): unknown {
|
|
1727
|
+
if (depth > PATH_REWRITE_MAX_DEPTH) return value;
|
|
1728
|
+
if (typeof value === "string") {
|
|
1729
|
+
if (!value.startsWith("/")) return value; // the stated residual: undeclared relative strings untouched
|
|
1730
|
+
// Normalize FIRST, then check, then translate the normalized form -- so a
|
|
1731
|
+
// path that only looks like it is inside the workspace cannot slip through,
|
|
1732
|
+
// and the host is never handed a path still carrying ".." segments.
|
|
1733
|
+
const normalized = resolve(value);
|
|
1734
|
+
// `asWritten` is set only when rewriteArguments() already resolved a
|
|
1735
|
+
// declared-path argument from a relative string. Quoting the resolved
|
|
1736
|
+
// form alone would show the caller a path they never typed, so BOTH
|
|
1737
|
+
// failure branches below name what they wrote and what it became.
|
|
1738
|
+
const escapedRelative = asWritten !== undefined && asWritten !== value;
|
|
1739
|
+
if (!isInsideWorkspace(normalized, root)) {
|
|
1740
|
+
throw new PathOutOfWorkspaceError(
|
|
1741
|
+
(escapedRelative
|
|
1742
|
+
? `vice: ${argPath} is the relative path "${asWritten}", which resolves to ${normalized} -- ` +
|
|
1743
|
+
`outside the mounted workspace (${root})`
|
|
1744
|
+
: `vice: ${argPath} is an absolute path (${value}) outside the mounted workspace (${root})` +
|
|
1745
|
+
(normalized === value ? "" : `; it resolves to ${normalized}`)) +
|
|
1746
|
+
`. The host emulator can only be handed paths that live inside the mounted workspace -- move the ` +
|
|
1747
|
+
`artifact inside the workspace and call again.`
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
try {
|
|
1751
|
+
return hostPath(normalized, { workspaceRoot: root });
|
|
1752
|
+
} catch (e) {
|
|
1753
|
+
// Name what the CALLER wrote first, and the container path it became --
|
|
1754
|
+
// never lead with the host path. The caller reasons in container terms
|
|
1755
|
+
// and cannot act on a host-side location, so quoting only the resolved
|
|
1756
|
+
// form makes a fixable mistake look like an emulator fault.
|
|
1757
|
+
throw new PathTranslationError(
|
|
1758
|
+
`vice: ${argPath} ` +
|
|
1759
|
+
(escapedRelative ? `("${asWritten}", which resolves to ${normalized})` : `(${value})`) +
|
|
1760
|
+
` could not be translated to a host path: ${(e as Error).message}\n ${SET_ENV_HINT}`
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (Array.isArray(value)) {
|
|
1765
|
+
return value.map((v, i) => rewritePathsIn(v, `${argPath}[${i}]`, root, depth + 1));
|
|
1766
|
+
}
|
|
1767
|
+
if (value && typeof value === "object") {
|
|
1768
|
+
const out: Record<string, unknown> = {};
|
|
1769
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1770
|
+
out[k] = rewritePathsIn(v, `${argPath}.${k}`, root, depth + 1);
|
|
1771
|
+
}
|
|
1772
|
+
return out;
|
|
1773
|
+
}
|
|
1774
|
+
return value; // numbers, booleans, null -- byte-identical, never touched
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const NO_PATH_ARGS: Set<string> = new Set();
|
|
1778
|
+
let PATH_ARGS_BY_TOOL: Map<string, Set<string>> | null = null; // built once per process, from the manifest
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* The set of argument names `toolName` declares to be filesystem paths,
|
|
1782
|
+
* read off tools-manifest.json -- the SAME file tools/list is served from,
|
|
1783
|
+
* so this can never become a second, drifting copy of "which arguments are
|
|
1784
|
+
* paths". An argument qualifies when it is declared `type: "string"` and
|
|
1785
|
+
* either is named exactly `path` or opens its description with "Path to" /
|
|
1786
|
+
* "File path" (both tests agree on all four current cases; either alone
|
|
1787
|
+
* would also suffice, and keeping both means a future manifest entry that
|
|
1788
|
+
* satisfies only one is still caught).
|
|
1789
|
+
*
|
|
1790
|
+
* Deliberately name/description-driven rather than a hardcoded tool list:
|
|
1791
|
+
* a manifest refresh that adds a path-taking tool gets the behaviour for
|
|
1792
|
+
* free, which a literal list here would silently miss.
|
|
1793
|
+
*/
|
|
1794
|
+
function pathArgsFor(toolName: string): Set<string> {
|
|
1795
|
+
if (!PATH_ARGS_BY_TOOL) {
|
|
1796
|
+
PATH_ARGS_BY_TOOL = new Map();
|
|
1797
|
+
for (const t of readManifestTools()) {
|
|
1798
|
+
const props = isPlainObject(t.inputSchema) ? (t.inputSchema.properties as unknown) : undefined;
|
|
1799
|
+
if (!props || typeof props !== "object") continue;
|
|
1800
|
+
const names = new Set<string>();
|
|
1801
|
+
for (const [k, v] of Object.entries(props as Record<string, unknown>)) {
|
|
1802
|
+
if (!isPlainObject(v) || v.type !== "string") continue;
|
|
1803
|
+
if (k === "path" || /^(path|file path)\b/i.test((v.description as string) || "")) names.add(k);
|
|
1804
|
+
}
|
|
1805
|
+
if (names.size) PATH_ARGS_BY_TOOL.set(t.name, names);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return PATH_ARGS_BY_TOOL.get(toolName) || NO_PATH_ARGS;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** One `arguments.<key>` resolved from a relative, manifest-declared path
|
|
1812
|
+
* argument to its absolute container form, before hostPath() translation --
|
|
1813
|
+
* the record resolutionNote() below renders for the agent. */
|
|
1814
|
+
interface PathResolution {
|
|
1815
|
+
arg: string;
|
|
1816
|
+
asWritten: string;
|
|
1817
|
+
container: string;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Rewrite every in-workspace path inside `args` to its host form. A relative
|
|
1821
|
+
* string in a manifest-declared path argument is resolved against the
|
|
1822
|
+
* workspace root first; everything else keeps the byte-identical
|
|
1823
|
+
* pass-through. Throws PathOutOfWorkspaceError / PathTranslationError on the
|
|
1824
|
+
* two refusal cases above; the caller (handleToolsCall) converts either into
|
|
1825
|
+
* an isError:true result rather than letting it escape. */
|
|
1826
|
+
function rewriteArguments(
|
|
1827
|
+
args: Record<string, unknown> | undefined,
|
|
1828
|
+
toolName: string
|
|
1829
|
+
): { args: Record<string, unknown>; resolutions: PathResolution[] } {
|
|
1830
|
+
const root = repoRoot();
|
|
1831
|
+
const pathArgs = pathArgsFor(toolName);
|
|
1832
|
+
const out: Record<string, unknown> = {};
|
|
1833
|
+
const resolutions: PathResolution[] = [];
|
|
1834
|
+
for (const [k, v] of Object.entries(args || {})) {
|
|
1835
|
+
// Only a top-level, declared-path, non-empty relative string is resolved.
|
|
1836
|
+
// Empty stays empty (resolve() would silently turn "" into the workspace
|
|
1837
|
+
// root, i.e. a directory, which is never what a caller meant).
|
|
1838
|
+
if (pathArgs.has(k) && typeof v === "string" && v !== "" && !v.startsWith("/")) {
|
|
1839
|
+
const container = resolve(root, v);
|
|
1840
|
+
out[k] = rewritePathsIn(container, `arguments.${k}`, root, 1, v);
|
|
1841
|
+
resolutions.push({ arg: k, asWritten: v, container });
|
|
1842
|
+
} else {
|
|
1843
|
+
out[k] = rewritePathsIn(v, `arguments.${k}`, root, 1);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return { args: out, resolutions };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* One line naming, in full, every relative path this call resolved -- so the
|
|
1851
|
+
* absolute path actually handed to the emulator is never something the caller
|
|
1852
|
+
* has to infer. Returned to the AGENT, not just stderr: the failure this
|
|
1853
|
+
* prevents ("Failed to attach disk image", with no indication which file was
|
|
1854
|
+
* even attempted) is one the agent has to diagnose, and it cost a real session
|
|
1855
|
+
* before the resolution existed at all. Empty string when nothing was resolved,
|
|
1856
|
+
* so a call that passed absolute paths reads exactly as it always did.
|
|
1857
|
+
*/
|
|
1858
|
+
function resolutionNote(resolutions: PathResolution[] | undefined): string {
|
|
1859
|
+
if (!resolutions || !resolutions.length) return "";
|
|
1860
|
+
const parts = resolutions.map((r) => `${r.arg}: "${r.asWritten}" -> ${r.container}`);
|
|
1861
|
+
return `vice: resolved relative path${resolutions.length > 1 ? "s" : ""} against the workspace root -- ${parts.join("; ")}`;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ------------------------------------------------------- oversized results
|
|
1865
|
+
//
|
|
1866
|
+
// Decision D-E: the `_meta["anthropic/maxResultSizeChars"]` declaration
|
|
1867
|
+
// above raises the real limit far past the 25,000-token default, but a
|
|
1868
|
+
// second, proxy-side cap catches whatever still overruns it (a 64K RAM read
|
|
1869
|
+
// in any plausible encoding, per ROADMAP criterion 5). Nothing on this path
|
|
1870
|
+
// may silently shorten a payload -- there is no truncation branch. An
|
|
1871
|
+
// oversized result is split and served in full across an explicit
|
|
1872
|
+
// continuation sequence via one synthetic tool (`vice_result_continue`,
|
|
1873
|
+
// declared above), so the caller can always reassemble the whole payload.
|
|
1874
|
+
//
|
|
1875
|
+
// The store is bounded so a long session cannot grow it without limit: at
|
|
1876
|
+
// most MAX_CONTINUATIONS outstanding sequences, oldest evicted first (a
|
|
1877
|
+
// `Map` preserves insertion order, so its first key is always the oldest).
|
|
1878
|
+
// An evicted or exhausted token fails loudly with advice to narrow the
|
|
1879
|
+
// original call rather than resume it -- there is nothing left to resume.
|
|
1880
|
+
interface ContinuationEntry {
|
|
1881
|
+
chunks: string[];
|
|
1882
|
+
nextIndex: number;
|
|
1883
|
+
totalChunks: number;
|
|
1884
|
+
totalChars: number;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const CONTINUATION_STORE: Map<string, ContinuationEntry> = new Map(); // token -> { chunks: string[], nextIndex: number, totalChunks: number, totalChars: number }
|
|
1888
|
+
const MAX_CONTINUATIONS = 5;
|
|
1889
|
+
let continuationCounter = 0;
|
|
1890
|
+
|
|
1891
|
+
function nextContinuationToken(): string {
|
|
1892
|
+
continuationCounter += 1;
|
|
1893
|
+
return `cont-${process.pid}-${Date.now()}-${continuationCounter}`;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
interface ChunkMarkerArgs {
|
|
1897
|
+
chunkIndex: number;
|
|
1898
|
+
totalChunks: number;
|
|
1899
|
+
totalChars: number;
|
|
1900
|
+
token: string;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function chunkMarkerText({ chunkIndex, totalChunks, totalChars, token }: ChunkMarkerArgs): string {
|
|
1904
|
+
if (chunkIndex >= totalChunks) {
|
|
1905
|
+
return (
|
|
1906
|
+
`vice: chunk ${chunkIndex} of ${totalChunks} (last chunk) -- ${totalChars} total characters ` +
|
|
1907
|
+
`served across this continuation sequence.`
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
return (
|
|
1911
|
+
`vice: chunk ${chunkIndex} of ${totalChunks} -- ${totalChars} total characters. Call ` +
|
|
1912
|
+
`vice_result_continue with arguments {"token":"${token}"} to retrieve the next chunk.`
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Wrap a successful call's serialised text, splitting it across a
|
|
1918
|
+
* continuation sequence if (and only if) it exceeds OUTPUT_CHAR_CAP. Under
|
|
1919
|
+
* the cap, behaves exactly as an unchunked result always has: a single
|
|
1920
|
+
* `content` item, nothing else appended. Over the cap, the FIRST content
|
|
1921
|
+
* item is the pure payload chunk -- byte-for-byte, no marker text mixed in,
|
|
1922
|
+
* so reassembly is a plain concatenation -- and a SECOND content item
|
|
1923
|
+
* carries the marker, naming the exact next call to make.
|
|
1924
|
+
*/
|
|
1925
|
+
function wrapPossiblyChunked(text: string): OkTextResult {
|
|
1926
|
+
if (text.length <= OUTPUT_CHAR_CAP) {
|
|
1927
|
+
return { content: [{ type: "text", text }], isError: false };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const totalChars = text.length;
|
|
1931
|
+
const pieces: string[] = [];
|
|
1932
|
+
for (let i = 0; i < text.length; i += OUTPUT_CHAR_CAP) {
|
|
1933
|
+
pieces.push(text.slice(i, i + OUTPUT_CHAR_CAP));
|
|
1934
|
+
}
|
|
1935
|
+
const totalChunks = pieces.length;
|
|
1936
|
+
const [first, ...remaining] = pieces;
|
|
1937
|
+
|
|
1938
|
+
const token = nextContinuationToken();
|
|
1939
|
+
while (CONTINUATION_STORE.size >= MAX_CONTINUATIONS) {
|
|
1940
|
+
const oldestToken = CONTINUATION_STORE.keys().next().value as string;
|
|
1941
|
+
CONTINUATION_STORE.delete(oldestToken);
|
|
1942
|
+
}
|
|
1943
|
+
CONTINUATION_STORE.set(token, { chunks: remaining, nextIndex: 2, totalChunks, totalChars });
|
|
1944
|
+
|
|
1945
|
+
return {
|
|
1946
|
+
content: [
|
|
1947
|
+
{ type: "text", text: first },
|
|
1948
|
+
{ type: "text", text: chunkMarkerText({ chunkIndex: 1, totalChunks, totalChars, token }) },
|
|
1949
|
+
],
|
|
1950
|
+
isError: false,
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** Handles `vice_result_continue` -- served entirely inside this proxy;
|
|
1955
|
+
* NEVER reaches `call()` or the network. */
|
|
1956
|
+
function handleResultContinue(args: Record<string, unknown>): ToolCallResult {
|
|
1957
|
+
const token = args && typeof args.token === "string" ? args.token : null;
|
|
1958
|
+
if (!token || !CONTINUATION_STORE.has(token)) {
|
|
1959
|
+
return isErrorText(
|
|
1960
|
+
`vice: continuation token "${token}" is unknown or has already expired. Re-issue the ` +
|
|
1961
|
+
`original tools/call with a narrower range instead of resuming.`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const entry = CONTINUATION_STORE.get(token) as ContinuationEntry;
|
|
1965
|
+
const chunk = entry.chunks.shift() as string;
|
|
1966
|
+
const chunkIndex = entry.nextIndex;
|
|
1967
|
+
entry.nextIndex += 1;
|
|
1968
|
+
const isLast = entry.chunks.length === 0;
|
|
1969
|
+
if (isLast) {
|
|
1970
|
+
CONTINUATION_STORE.delete(token);
|
|
1971
|
+
}
|
|
1972
|
+
return {
|
|
1973
|
+
content: [
|
|
1974
|
+
{ type: "text", text: chunk },
|
|
1975
|
+
{
|
|
1976
|
+
type: "text",
|
|
1977
|
+
text: chunkMarkerText({ chunkIndex, totalChunks: entry.totalChunks, totalChars: entry.totalChars, token }),
|
|
1978
|
+
},
|
|
1979
|
+
],
|
|
1980
|
+
isError: false,
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// -------------------------------------------------------------- broker lease
|
|
1985
|
+
//
|
|
1986
|
+
// On-demand acquisition (Phase 01.2): deferred to the FIRST forwarded
|
|
1987
|
+
// tools/call, never to initialize/tools/list, matching the measured "spawn
|
|
1988
|
+
// is eager, acquisition must not be" finding (spike-findings-bruce-lee
|
|
1989
|
+
// skill, proxy-lifecycle-and-process-identity.md) -- a session that never
|
|
1990
|
+
// forwards a call never asks the broker for anything (C3).
|
|
1991
|
+
//
|
|
1992
|
+
// Plan 01.6.2-07: the lease is now the CONTROL CONNECTION itself, not a
|
|
1993
|
+
// file. controlSession holds the open BrokerControlSession (plan 06's
|
|
1994
|
+
// completed client) for this session's lifetime; grantId is the acquired
|
|
1995
|
+
// grant's own id -- the PRIMARY noun of the protocol carries over unchanged
|
|
1996
|
+
// (still promoted from port to request id, since ports are recycled across
|
|
1997
|
+
// sessions under on-demand launch), it is just no longer a filename. Both
|
|
1998
|
+
// null means either no session has been opened yet, or VICE_MCP_URL
|
|
1999
|
+
// overrides the broker entirely. There is no heartbeat timer any more --
|
|
2000
|
+
// nothing needs touching to prove a TCP connection is still alive; it
|
|
2001
|
+
// either is, or the broker's own "close" handler has already torn the
|
|
2002
|
+
// instance down.
|
|
2003
|
+
let controlSession: BrokerControlSession | null = null;
|
|
2004
|
+
let grantId: string | null = null;
|
|
2005
|
+
|
|
2006
|
+
// ----------------------------------------------------- grant containerization
|
|
2007
|
+
//
|
|
2008
|
+
// Quick task 260801-ccn (the inverse of Phase 01.1 criterion 9). The broker
|
|
2009
|
+
// runs on the HOST, legitimately resolves its own repo root, and writes a
|
|
2010
|
+
// grant carrying host-local coordinates: a loopback `url`, and
|
|
2011
|
+
// `epoch_file`/`supervisor_dir` paths rooted at the host's own checkout --
|
|
2012
|
+
// entirely correct from where the broker stands. Nothing inverted them
|
|
2013
|
+
// before this task: loopback meant the CONTAINER's own loopback
|
|
2014
|
+
// (ECONNREFUSED, since nothing listens there) and the host-rooted epoch
|
|
2015
|
+
// path simply never resolved, so every broker-granted instance was silently
|
|
2016
|
+
// unreachable. containerizeGrant() is the seam that fixes this -- called in
|
|
2017
|
+
// ensureBrokerLease() below between session.acquire() returning a grant and
|
|
2018
|
+
// useInstance() adopting it, since that is the LAST point before the
|
|
2019
|
+
// coordinates become the session's identity (D-1).
|
|
2020
|
+
function containerizeGrant(grant: Record<string, unknown>): Record<string, unknown> {
|
|
2021
|
+
const grantId = grant && typeof grant.id === "string" ? grant.id : "(no id)";
|
|
2022
|
+
const port = Number(grant && grant.port);
|
|
2023
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2024
|
+
// T-mef-01's rule, reused here: nothing downstream can be trusted
|
|
2025
|
+
// without a validated port, so no translation is even attempted --
|
|
2026
|
+
// useInstance() fails on its own terms, exactly as it would have before
|
|
2027
|
+
// this function existed.
|
|
2028
|
+
console.error(
|
|
2029
|
+
`vice-proxy: containerizeGrant ${grantId}: grant.port (${grant && grant.port}) is not a valid integer ` +
|
|
2030
|
+
`port -- skipping translation entirely.`
|
|
2031
|
+
);
|
|
2032
|
+
return grant;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
const alias = mcpHost();
|
|
2036
|
+
// containerizeRecord() (containerpath.ts) does the translation itself:
|
|
2037
|
+
// `url` through the loopback-rewrite (D-4), `epoch_file`/`supervisor_dir`
|
|
2038
|
+
// through the host->container path inverse (D-2 -- all three fields). An
|
|
2039
|
+
// already container-shaped record (every pre-existing broker test's
|
|
2040
|
+
// tmpdir-rooted VICE_POOL_DIR) matches no known host root and comes back
|
|
2041
|
+
// byte-identical -- D-7's whole point.
|
|
2042
|
+
const { record, changes } = containerizeRecord(grant, {
|
|
2043
|
+
pathFields: ["epoch_file", "supervisor_dir"],
|
|
2044
|
+
urlFields: ["url"],
|
|
2045
|
+
alias,
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
// Safety net (T-ccn-01, T-ccn-02), mirroring the outbound seam's own
|
|
2049
|
+
// posture: never open/connect to an unvalidated string read out of a
|
|
2050
|
+
// grant file. On either failure below, substitute the coordinate DERIVED
|
|
2051
|
+
// FROM THE VALIDATED PORT instead (instanceFor()'s own T-mef-01 rule,
|
|
2052
|
+
// reused here) and report the substitution -- never silently.
|
|
2053
|
+
const root = repoRoot();
|
|
2054
|
+
const fallbackDir = join(brokerRootDir(), String(port));
|
|
2055
|
+
const fallbackEpochFile = join(fallbackDir, "epoch.json");
|
|
2056
|
+
const fallbackUrl = `http://${alias}:${port}/mcp`;
|
|
2057
|
+
const changedFields = new Set(changes.map((c) => c.field));
|
|
2058
|
+
const substituted: Record<string, boolean> = { url: false, epoch_file: false, supervisor_dir: false };
|
|
2059
|
+
|
|
2060
|
+
// T-ccn-01: only a field that was ACTUALLY TRANSLATED (its host root
|
|
2061
|
+
// matched) is re-checked for workspace containment -- an already
|
|
2062
|
+
// container-shaped path was never translated at all (D-7's passthrough)
|
|
2063
|
+
// and is trusted exactly as every pre-existing broker test already relies
|
|
2064
|
+
// on. A translated path escaping the workspace (a lexical ".." sequence
|
|
2065
|
+
// in the grant's own host-rooted field) is exactly what this check
|
|
2066
|
+
// catches.
|
|
2067
|
+
if (changedFields.has("epoch_file") && !isInsideWorkspace(resolve(record.epoch_file as string), root)) {
|
|
2068
|
+
record.epoch_file = fallbackEpochFile;
|
|
2069
|
+
substituted.epoch_file = true;
|
|
2070
|
+
}
|
|
2071
|
+
if (changedFields.has("supervisor_dir") && !isInsideWorkspace(resolve(record.supervisor_dir as string), root)) {
|
|
2072
|
+
record.supervisor_dir = fallbackDir;
|
|
2073
|
+
substituted.supervisor_dir = true;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// T-ccn-02: the FINAL url's port must equal the validated grant port,
|
|
2077
|
+
// checked UNCONDITIONALLY (translated or not) -- a grant could simply
|
|
2078
|
+
// declare a mismatched port from the start, translation aside, and that
|
|
2079
|
+
// is exactly the spoofing shape this check exists to catch.
|
|
2080
|
+
let urlPortOk = false;
|
|
2081
|
+
if (typeof record.url === "string") {
|
|
2082
|
+
try {
|
|
2083
|
+
urlPortOk = Number(new URL(record.url).port) === port;
|
|
2084
|
+
} catch {
|
|
2085
|
+
urlPortOk = false;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (!urlPortOk) {
|
|
2089
|
+
record.url = fallbackUrl;
|
|
2090
|
+
substituted.url = true;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// Exactly ONE stderr line, naming every field's before/after (or
|
|
2094
|
+
// "unchanged") -- this is the signal whose absence made the original bug
|
|
2095
|
+
// invisible; it must never become a line per field (D-2's own reporting
|
|
2096
|
+
// requirement).
|
|
2097
|
+
const parts = ["url", "epoch_file", "supervisor_dir"].map((field) => {
|
|
2098
|
+
const original = grant ? grant[field] : undefined;
|
|
2099
|
+
const final = record[field];
|
|
2100
|
+
if (substituted[field]) {
|
|
2101
|
+
return `${field}: SUBSTITUTED ${JSON.stringify(original)} -> ${JSON.stringify(final)} (port-derived fallback)`;
|
|
2102
|
+
}
|
|
2103
|
+
if (final === original) {
|
|
2104
|
+
return `${field}: unchanged (${JSON.stringify(final)})`;
|
|
2105
|
+
}
|
|
2106
|
+
return `${field}: ${JSON.stringify(original)} -> ${JSON.stringify(final)}`;
|
|
2107
|
+
});
|
|
2108
|
+
console.error(`vice-proxy: containerized grant ${grantId} -- ${parts.join("; ")}`);
|
|
2109
|
+
|
|
2110
|
+
return record;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Acquire a broker-granted instance for this session, once. Returns
|
|
2115
|
+
* immediately (no broker traffic at all) when a session is already held,
|
|
2116
|
+
* and immediately when VICE_MCP_URL is set -- an explicit endpoint override
|
|
2117
|
+
* means the caller already chose an instance, which is both the principled
|
|
2118
|
+
* rule and what keeps every pre-existing proxy test passing with no edit.
|
|
2119
|
+
*
|
|
2120
|
+
* Plan 01.6.2-07: the lease is now the CONTROL CONNECTION itself. The prior
|
|
2121
|
+
* ordering constraint here -- create a lease file BEFORE awaiting the grant,
|
|
2122
|
+
* because the host's own sweep tore a grant down whenever its lease file was
|
|
2123
|
+
* absent -- DISSOLVES entirely under this design: the connection is open,
|
|
2124
|
+
* and is therefore already the proof this session holds a claim, before the
|
|
2125
|
+
* acquire request is even sent. There is no window between "a grant exists"
|
|
2126
|
+
* and "a lease exists" for a sweep to land in, because there is no longer a
|
|
2127
|
+
* second artifact for the two to disagree about. D-09's shutdown-deletion
|
|
2128
|
+
* question dissolves the same way, for the same reason: under D-01 the
|
|
2129
|
+
* request/lease/grant/denial directories never exist at all, so there is
|
|
2130
|
+
* nothing to delete on shutdown and nothing to reconcile -- a reader meeting
|
|
2131
|
+
* that earlier decision needs to know it no longer applies, not that it was
|
|
2132
|
+
* quietly dropped.
|
|
2133
|
+
*/
|
|
2134
|
+
type BrokerLeaseResult = { ok: true } | { ok: false; message: string };
|
|
2135
|
+
|
|
2136
|
+
async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
2137
|
+
if (controlSession) return { ok: true };
|
|
2138
|
+
if (process.env.VICE_MCP_URL) return { ok: true }; // explicit override -- broker never contacted
|
|
2139
|
+
|
|
2140
|
+
// Classify liveness FIRST, before ever opening a connection (C10).
|
|
2141
|
+
// never_started and stale both return their message immediately, with no
|
|
2142
|
+
// connection attempted -- there is nothing on the other side to answer
|
|
2143
|
+
// one, so attempting it would only delay the diagnosis. readBrokerLiveness()
|
|
2144
|
+
// re-reads broker.json fresh on every call (see its own implementation in
|
|
2145
|
+
// vice-broker-client.ts); nothing here memoises the verdict, so this is the
|
|
2146
|
+
// broker-path instance of the same never-cache-a-negative-result invariant
|
|
2147
|
+
// the comment above ensureViceSession() already states for the host path --
|
|
2148
|
+
// the call after a human starts the broker just works, with no session
|
|
2149
|
+
// restart required. openBrokerControl() performs this SAME classification
|
|
2150
|
+
// again internally (over its own read of broker.json) before it ever
|
|
2151
|
+
// connects -- a second, independent read, not a second answer to trust
|
|
2152
|
+
// instead of this one; fetching liveness here first is what gives the
|
|
2153
|
+
// diagnoses below (dead-or-hung's own pid) something to quote.
|
|
2154
|
+
const liveness = readBrokerLiveness();
|
|
2155
|
+
if (liveness.state === "never_started") {
|
|
2156
|
+
return { ok: false, message: brokerNeverStartedMessage() };
|
|
2157
|
+
}
|
|
2158
|
+
if (liveness.state === "stale") {
|
|
2159
|
+
return { ok: false, message: brokerDeadOrHungMessage(liveness) };
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const acquireStartedAt = Date.now();
|
|
2163
|
+
const opened = await openBrokerControl();
|
|
2164
|
+
if (!opened.ok) {
|
|
2165
|
+
// openBrokerControl() re-classifies liveness from its OWN read of
|
|
2166
|
+
// broker.json before ever connecting -- never_started/stale here means
|
|
2167
|
+
// that SECOND read found a genuine race (the broker died between the
|
|
2168
|
+
// classification above and this one), so both route to their usual two
|
|
2169
|
+
// messages, unchanged. EVERY other kind (unreachable_control_plane,
|
|
2170
|
+
// connect_refused, protocol, broker_gone, ...) is reached only when that
|
|
2171
|
+
// second read agreed the broker is alive -- reading those as
|
|
2172
|
+
// dead-or-hung was the exact mis-attribution this plan closes (see
|
|
2173
|
+
// brokerControlUnreachableMessage()'s own header comment for the full
|
|
2174
|
+
// incident record): a connect failure against a healthy heartbeat is a
|
|
2175
|
+
// control-plane CONNECTIVITY problem, not a broker liveness one, so it
|
|
2176
|
+
// gets its own message naming the address and port instead.
|
|
2177
|
+
if (opened.kind === "never_started") {
|
|
2178
|
+
return { ok: false, message: brokerNeverStartedMessage() };
|
|
2179
|
+
}
|
|
2180
|
+
if (opened.kind === "stale") {
|
|
2181
|
+
return { ok: false, message: brokerDeadOrHungMessage(liveness) };
|
|
2182
|
+
}
|
|
2183
|
+
return { ok: false, message: brokerControlUnreachableMessage(opened, liveness) };
|
|
2184
|
+
}
|
|
2185
|
+
const session = opened.session;
|
|
2186
|
+
|
|
2187
|
+
const result = await session.acquire();
|
|
2188
|
+
if (!result.ok) {
|
|
2189
|
+
// No grant is coming for this session -- nothing to hold the connection
|
|
2190
|
+
// open for. Closing it here is the control-plane's entire equivalent of
|
|
2191
|
+
// the old cleanup (releaseLease(id) + removeRequestFile(id)): there was
|
|
2192
|
+
// never a file to remove in the first place.
|
|
2193
|
+
await session.release();
|
|
2194
|
+
if (result.kind === "deadline") {
|
|
2195
|
+
return { ok: false, message: brokerWarmingMessage(Date.now() - acquireStartedAt) };
|
|
2196
|
+
}
|
|
2197
|
+
return { ok: false, message: brokerLaunchFailedMessage(result.message) };
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// adoptGrant() is the ONE seam that inverts the grant's host-local
|
|
2201
|
+
// coordinates (D-1, quick task 260801-ccn) and adopts them as this
|
|
2202
|
+
// session's active instance -- the LAST point before the coordinates
|
|
2203
|
+
// become the session's identity: the endpoint every later tool call is
|
|
2204
|
+
// sent to, and the path the epoch guard opens. Plan 08 (D-13) reuses this
|
|
2205
|
+
// EXACT function for a replacement acquisition too (see
|
|
2206
|
+
// handleGrantedInstanceUnreachable() below) -- one code path for adopting
|
|
2207
|
+
// an instance, never a second one for a replacement.
|
|
2208
|
+
adoptGrant({ ...result.grant });
|
|
2209
|
+
viceSession = null; // re-baseline: the next ensureViceSession() reads the GRANTED instance's own epoch file
|
|
2210
|
+
controlSession = session;
|
|
2211
|
+
return { ok: true };
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* The ONE adoption seam (D-13): containerize a grant's host-local
|
|
2216
|
+
* coordinates and adopt them as this session's active instance, recording
|
|
2217
|
+
* the grant id. Called by ensureBrokerLease() above for an ORDINARY
|
|
2218
|
+
* acquisition and by handleGrantedInstanceUnreachable() below for BOTH of
|
|
2219
|
+
* its replacement acquisitions (the same-session retry and the
|
|
2220
|
+
* fresh-session retry) -- never a second, parallel adoption path for a
|
|
2221
|
+
* replacement.
|
|
2222
|
+
*/
|
|
2223
|
+
function adoptGrant(grant: Record<string, unknown>): void {
|
|
2224
|
+
grantId = typeof grant.id === "string" ? grant.id : null;
|
|
2225
|
+
const containerized = containerizeGrant({ ...grant });
|
|
2226
|
+
useInstance({
|
|
2227
|
+
port: containerized.port as number,
|
|
2228
|
+
url: containerized.url as string,
|
|
2229
|
+
epochFile: containerized.epoch_file as string,
|
|
2230
|
+
pooled: true,
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// --------------------------------------- D-13/D-14: replace-and-report
|
|
2235
|
+
//
|
|
2236
|
+
// Plan 01.6.2-08. A granted instance's pre-flight probe failing used to
|
|
2237
|
+
// produce ONE report-and-instruct message (the retired
|
|
2238
|
+
// brokerGrantedUnreachableMessage(), see this file's earlier comment naming
|
|
2239
|
+
// where it lived) and stop there, leaving a human to go investigate on the
|
|
2240
|
+
// host. D-13 changes that into a replace-and-report: the proxy acquires a
|
|
2241
|
+
// replacement itself, immediately, over the SAME control session (only the
|
|
2242
|
+
// emulator instance is suspected dead here, not necessarily the connection
|
|
2243
|
+
// to the broker) -- but still fails the TRIGGERING call LOUDLY, naming the
|
|
2244
|
+
// replacement, rather than silently substituting a result read from a
|
|
2245
|
+
// machine the caller never asked for. A memory read served quietly against
|
|
2246
|
+
// a blank replacement returns zeroed RAM indistinguishable from real data,
|
|
2247
|
+
// which is exactly the hazard the epoch guard elsewhere in this file exists
|
|
2248
|
+
// to catch -- a notice buried inside an otherwise-successful payload is
|
|
2249
|
+
// easy to skim past, so this never returns one.
|
|
2250
|
+
//
|
|
2251
|
+
// D-14 is what happens when that SAME-session replacement attempt itself
|
|
2252
|
+
// discovers the connection is gone (kind "broker_gone"): an ACCEPTED,
|
|
2253
|
+
// KNOWING regression, recorded here rather than left to be rediscovered as
|
|
2254
|
+
// a defect. Before plan 07's transport swap, the only broker dependency
|
|
2255
|
+
// surviving past a grant was a file write (the retiring lease heartbeat)
|
|
2256
|
+
// whose failure was a silent no-op -- broker death was survivable by
|
|
2257
|
+
// construction, because nothing after the grant still needed the broker at
|
|
2258
|
+
// all. Under one held TCP connection, that is no longer true: recycle and
|
|
2259
|
+
// (as of this plan) replacement both need a live connection. That safety
|
|
2260
|
+
// margin is given up DELIBERATELY, per the tolerance decision recorded in
|
|
2261
|
+
// broker-control-plane-over-tcp.md -- the compensation is that a session is
|
|
2262
|
+
// told LOUDLY rather than left to quietly keep working against whatever a
|
|
2263
|
+
// still-reachable granted instance happens to answer, for as long as it
|
|
2264
|
+
// happens to stay reachable.
|
|
2265
|
+
//
|
|
2266
|
+
// Both D-13 and D-14 reuse the SAME machineReplacedMessage() builder (which
|
|
2267
|
+
// itself reuses epochDriftMessage(), the existing voided-run vocabulary the
|
|
2268
|
+
// fixed-port epoch-drift guard already carries) -- one vocabulary for a
|
|
2269
|
+
// voided run, never a second one paralleling it. Neither outcome is ever
|
|
2270
|
+
// cached: controlSession is deliberately left pointing at a known-dead
|
|
2271
|
+
// session on every failure branch below, so the NEXT call's own probe
|
|
2272
|
+
// failure repeats this exact same from-scratch attempt (a fresh
|
|
2273
|
+
// openBrokerControl() reads broker.json fresh every time, never memoised),
|
|
2274
|
+
// rather than short-circuiting on a remembered verdict -- see the
|
|
2275
|
+
// NEVER-CACHE-A-NEGATIVE-RESULT invariant above ensureViceSession().
|
|
2276
|
+
|
|
2277
|
+
/**
|
|
2278
|
+
* D-13/D-14's shared report text: a call was refused because the machine
|
|
2279
|
+
* behind it was REPLACED out from under it. Reuses epochDriftMessage() --
|
|
2280
|
+
* the SAME builder the fixed-port epoch-drift guard already uses -- for the
|
|
2281
|
+
* epoch-comparison sentence, rather than inventing a second wording for
|
|
2282
|
+
* "this is not the machine you had a moment ago" (D-13's own instruction:
|
|
2283
|
+
* no second notion of a voided run). States the three facts an agent needs,
|
|
2284
|
+
* literally: the machine was REPLACED, the replacement is FRESH, and prior
|
|
2285
|
+
* state on the old instance is GONE.
|
|
2286
|
+
*
|
|
2287
|
+
* 2026-08-05 defect fix, two parts, both kept INLINE here (not extracted to
|
|
2288
|
+
* a helper) so this function's own body still literally contains the
|
|
2289
|
+
* `epochDriftMessage(` call the structural test
|
|
2290
|
+
* ("the replaced-machine report is built from the existing voided-run
|
|
2291
|
+
* vocabulary") pins:
|
|
2292
|
+
*
|
|
2293
|
+
* 1. Epoch sentence -- three cases, not two. The old code called
|
|
2294
|
+
* epochDriftMessage() whenever BOTH epochs were merely present,
|
|
2295
|
+
* with no inequality check -- so an unmoved-but-both-present pair
|
|
2296
|
+
* (oldEpoch.epoch === newEpoch.epoch) rendered the literally false
|
|
2297
|
+
* "epoch changed from 1 to 1" (the exact sighting on file). Now:
|
|
2298
|
+
* both present AND different -> epochDriftMessage(), unchanged; both
|
|
2299
|
+
* present but EQUAL -> an honest "did not change" sentence (each
|
|
2300
|
+
* port's epoch file is an independent counter, so a coincidental
|
|
2301
|
+
* match is expected, not evidence of anything -- and a genuinely
|
|
2302
|
+
* reused port can still read stale-equal if the host had not yet
|
|
2303
|
+
* written its post-respawn bump at the moment this was sampled); not
|
|
2304
|
+
* both present -> unchanged from before, "could not both be
|
|
2305
|
+
* compared".
|
|
2306
|
+
* 2. Port sentence -- when oldPort === newPort (a real, legitimate
|
|
2307
|
+
* outcome in this broker's fixed-slot design: a "replacement" can
|
|
2308
|
+
* land back on the exact port it replaced), the OLD wording named
|
|
2309
|
+
* that single port number as both "the old instance (port X)" and
|
|
2310
|
+
* "the replacement instance (port X)" -- two different entities
|
|
2311
|
+
* sharing one label, which a reader cannot reconcile ("one port
|
|
2312
|
+
* cannot be both"). Now branches on whether the port actually
|
|
2313
|
+
* changed: same port says so plainly ("replaced in place"), rather
|
|
2314
|
+
* than implying two distinct ports that happen to print the same
|
|
2315
|
+
* digits.
|
|
2316
|
+
*/
|
|
2317
|
+
function machineReplacedMessage(opts: {
|
|
2318
|
+
where: string;
|
|
2319
|
+
reason: string;
|
|
2320
|
+
oldPort: number;
|
|
2321
|
+
oldEpoch: EpochResult;
|
|
2322
|
+
newPort: number;
|
|
2323
|
+
newEpoch: EpochResult;
|
|
2324
|
+
}): string {
|
|
2325
|
+
const { where, reason, oldPort, oldEpoch, newPort, newEpoch } = opts;
|
|
2326
|
+
let driftSentence: string;
|
|
2327
|
+
if (oldEpoch.present && newEpoch.present) {
|
|
2328
|
+
driftSentence =
|
|
2329
|
+
oldEpoch.epoch !== newEpoch.epoch
|
|
2330
|
+
? epochDriftMessage(where, oldEpoch, newEpoch)
|
|
2331
|
+
: `vice: the epoch recorded ${where} did not change (still ${oldEpoch.epoch}) -- this is NOT evidence ` +
|
|
2332
|
+
`the machine stayed the same: each port's epoch counter is independent, so a coincidental match is ` +
|
|
2333
|
+
`expected between two unrelated files, and a genuinely reused port can still read stale-equal if the ` +
|
|
2334
|
+
`host had not yet recorded its post-respawn bump at the moment this was sampled. Treat every result ` +
|
|
2335
|
+
`since the previous call as void and redo that work regardless -- the replacement itself (below) is ` +
|
|
2336
|
+
`the operative fact here, not this epoch read.`;
|
|
2337
|
+
} else {
|
|
2338
|
+
driftSentence =
|
|
2339
|
+
`vice: treat every result since the previous call as void and redo that work -- the old instance's ` +
|
|
2340
|
+
`epoch and the new instance's epoch could not both be compared ` +
|
|
2341
|
+
`(old epoch present: ${oldEpoch.present}, new epoch present: ${newEpoch.present}).`;
|
|
2342
|
+
}
|
|
2343
|
+
const portSentence =
|
|
2344
|
+
oldPort === newPort
|
|
2345
|
+
? `The instance behind port ${oldPort} was REPLACED IN PLACE -- the process is a FRESH emulator (this ` +
|
|
2346
|
+
`broker's fixed-slot design can hand the replacement the SAME port back), and all prior state from ` +
|
|
2347
|
+
`before the replacement is GONE (${reason}).`
|
|
2348
|
+
: `The machine was REPLACED, the replacement is a FRESH emulator, and all prior state on the old ` +
|
|
2349
|
+
`instance (port ${oldPort}) is GONE (${reason}).`;
|
|
2350
|
+
return (
|
|
2351
|
+
`${driftSentence} ${portSentence} Make this call again -- it will run on the replacement instance ` +
|
|
2352
|
+
`(port ${newPort}), already acquired and adopted for this session.`
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** D-13: the replacement acquisition itself failed for a reason OTHER than
|
|
2357
|
+
* the broker connection being gone (denied, no_free_port, at_capacity, its
|
|
2358
|
+
* own deadline, ...). Names both failures -- the original unreachability
|
|
2359
|
+
* and the failed replacement -- and does not retry: a retry loop against a
|
|
2360
|
+
* broker that cannot currently grant is how one failure becomes a hang. */
|
|
2361
|
+
function replacementFailedMessage(probe: ProbeResult, failure: { kind: string; message: string }): string {
|
|
2362
|
+
return (
|
|
2363
|
+
`vice: retry this call yourself once the underlying problem is fixed -- no further replacement will ` +
|
|
2364
|
+
`be attempted automatically. The granted instance stopped answering (${probe.reason}), and a ` +
|
|
2365
|
+
`same-session replacement attempt also failed (${failure.kind}: ${failure.message}).`
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/** D-14: the broker connection is gone and a fresh one could not be opened
|
|
2370
|
+
* either (or could be opened but could not itself acquire) -- there is
|
|
2371
|
+
* nothing left this proxy can do on its own. Names the broker, not this
|
|
2372
|
+
* proxy, as the cause, and states plainly that no further call in THIS
|
|
2373
|
+
* session can succeed until it is running again. */
|
|
2374
|
+
function sessionMustRestartMessage(failure: { kind: string; message: string }): string {
|
|
2375
|
+
return (
|
|
2376
|
+
`vice: this session must be restarted -- no further call in this session can succeed until the ` +
|
|
2377
|
+
`broker is running again. The on-demand VICE broker connection is gone and a fresh session could ` +
|
|
2378
|
+
`not be opened (${failure.kind}: ${failure.message}). The broker itself is the cause.`
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* D-13/D-14's entry point, reached only when the pre-flight probe found the
|
|
2384
|
+
* session's granted instance unreachable AND a control session is held.
|
|
2385
|
+
* Exactly one same-session replacement attempt, then (only if THAT attempt
|
|
2386
|
+
* discovers the connection itself is gone) exactly one fresh-session
|
|
2387
|
+
* attempt -- never a loop, never more than these two acquisitions for one
|
|
2388
|
+
* triggering call. Always returns a report string; never a result, even on
|
|
2389
|
+
* the success paths -- see this section's own header comment for why.
|
|
2390
|
+
*/
|
|
2391
|
+
async function handleGrantedInstanceUnreachable(probe: ProbeResult, oldEpoch: EpochResult): Promise<string> {
|
|
2392
|
+
const { port: oldPort } = activeInstance();
|
|
2393
|
+
const session = controlSession as BrokerControlSession;
|
|
2394
|
+
|
|
2395
|
+
// Attempt 1: a replacement over the SAME session (D-13). Only the granted
|
|
2396
|
+
// EMULATOR is suspected dead here -- the connection to the broker may
|
|
2397
|
+
// still be perfectly good, and reusing it is the whole point of "costs
|
|
2398
|
+
// one acquisition, not the session."
|
|
2399
|
+
//
|
|
2400
|
+
// Gap closure (plan 14, WR-03 / T-01.6.2-90): release the grant this
|
|
2401
|
+
// session currently holds BEFORE acquiring its replacement -- two
|
|
2402
|
+
// independent reasons, both load-bearing, and order matters for both.
|
|
2403
|
+
//
|
|
2404
|
+
// Reason one: releasing frees the OLD instance's port and capacity slot
|
|
2405
|
+
// FIRST, before the acquire below ever asks for one -- a broker already
|
|
2406
|
+
// sitting at its instance ceiling can still serve this replacement, where
|
|
2407
|
+
// acquiring first could not.
|
|
2408
|
+
//
|
|
2409
|
+
// Reason two, the actual leak this closes: grantId (this proxy's own
|
|
2410
|
+
// module-level grant slot, declared above) is a SINGLE value --
|
|
2411
|
+
// adoptGrant() below simply overwrites it. Acquiring a replacement
|
|
2412
|
+
// without first releasing what is about to be overwritten is what
|
|
2413
|
+
// abandons the prior grant: the broker goes on holding an instance this
|
|
2414
|
+
// proxy no longer remembers asking to release, and every further
|
|
2415
|
+
// lost-machine event on this same session leaks one more, compounding
|
|
2416
|
+
// toward the instance ceiling. No new control-plane message is needed to
|
|
2417
|
+
// close this -- the existing release request already releases exactly
|
|
2418
|
+
// the grant THIS connection currently holds (no target id on the wire at
|
|
2419
|
+
// all), which is precisely the right one, provided it lands before the
|
|
2420
|
+
// slot below is overwritten.
|
|
2421
|
+
//
|
|
2422
|
+
// session.release() performs that release by closing the underlying
|
|
2423
|
+
// connection (a synchronous socket.destroy() under the hood -- D-12: the
|
|
2424
|
+
// connection IS the lease). The acquire immediately below therefore
|
|
2425
|
+
// finds THIS session already gone and answers "broker_gone" -- which is
|
|
2426
|
+
// NOT a new failure mode invented for this fix: it is handled by the
|
|
2427
|
+
// SAME broker-gone branch a few lines down this function already had,
|
|
2428
|
+
// exactly as it already handles any other dead-connection discovery. A
|
|
2429
|
+
// failed release introduces no new branch of its own: release() never
|
|
2430
|
+
// rejects (closing an already-closed socket is an idempotent no-op), and
|
|
2431
|
+
// even if it somehow did, the acquire that follows would classify and
|
|
2432
|
+
// report it exactly the same way.
|
|
2433
|
+
await session.release();
|
|
2434
|
+
grantId = null;
|
|
2435
|
+
const result = await session.acquire();
|
|
2436
|
+
if (result.ok) {
|
|
2437
|
+
adoptGrant({ ...result.grant });
|
|
2438
|
+
viceSession = null;
|
|
2439
|
+
ensureViceSession(); // re-baseline BEFORE returning -- see the never-cache invariant
|
|
2440
|
+
const newInstance = activeInstance();
|
|
2441
|
+
return machineReplacedMessage({
|
|
2442
|
+
where: "at the pre-flight liveness probe",
|
|
2443
|
+
reason: `the granted instance (port ${oldPort}) stopped answering -- ${probe.reason}`,
|
|
2444
|
+
oldPort,
|
|
2445
|
+
oldEpoch,
|
|
2446
|
+
newPort: newInstance.port,
|
|
2447
|
+
newEpoch: currentEpoch(),
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
if (result.kind !== "broker_gone") {
|
|
2452
|
+
// Bounded: exactly one replacement attempt, and it failed for a reason
|
|
2453
|
+
// that has nothing to do with the connection itself -- report both
|
|
2454
|
+
// failures and stop.
|
|
2455
|
+
return replacementFailedMessage(probe, result);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// D-14: attempt 1 itself discovered the control connection is gone --
|
|
2459
|
+
// now the ORDINARY way this branch is reached, since the release just
|
|
2460
|
+
// above always closes it (T-01.6.2-90's own fix, not a regression: the
|
|
2461
|
+
// grant that release protects against leaking is already gone by
|
|
2462
|
+
// construction before this line ever runs). No release is sent over
|
|
2463
|
+
// `session` here, and none is needed: a release cannot be sent over a
|
|
2464
|
+
// connection that is already gone, and connection close IS the release
|
|
2465
|
+
// in this design, kernel-enforced -- the broker's own close handler has
|
|
2466
|
+
// already released the prior grant and killed its instance the moment
|
|
2467
|
+
// that close event fired, whichever branch triggered it. If the broker
|
|
2468
|
+
// itself died instead, there is nothing left to leak into either.
|
|
2469
|
+
// Attempt 2: open a GENUINELY FRESH session -- a brand-new broker.json
|
|
2470
|
+
// read (never the stale record the dead session above was opened
|
|
2471
|
+
// against), never reusing `session`. controlSession is deliberately left
|
|
2472
|
+
// pointing at the dead session on every failure branch below, so a LATER
|
|
2473
|
+
// call's own probe failure repeats this exact same from-scratch sequence.
|
|
2474
|
+
const opened = await openBrokerControl();
|
|
2475
|
+
if (!opened.ok) {
|
|
2476
|
+
return sessionMustRestartMessage(opened);
|
|
2477
|
+
}
|
|
2478
|
+
const freshResult = await opened.session.acquire();
|
|
2479
|
+
if (!freshResult.ok) {
|
|
2480
|
+
await opened.session.release(); // nothing to hold this connection open for
|
|
2481
|
+
return sessionMustRestartMessage(freshResult);
|
|
2482
|
+
}
|
|
2483
|
+
adoptGrant({ ...freshResult.grant });
|
|
2484
|
+
controlSession = opened.session; // the fresh session replaces the dead one, held for the rest of this proxy's life
|
|
2485
|
+
viceSession = null;
|
|
2486
|
+
ensureViceSession();
|
|
2487
|
+
const newInstance = activeInstance();
|
|
2488
|
+
return machineReplacedMessage({
|
|
2489
|
+
where: "after the broker connection itself was found gone and a fresh session was opened",
|
|
2490
|
+
reason: `the broker connection was gone (${result.message})`,
|
|
2491
|
+
oldPort,
|
|
2492
|
+
oldEpoch,
|
|
2493
|
+
newPort: newInstance.port,
|
|
2494
|
+
newEpoch: currentEpoch(),
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// ------------------------------------------------ D-16 seam hazard annotation
|
|
2499
|
+
//
|
|
2500
|
+
// Plan 01.3-04. Structurally the OPPOSITE of the deny-list refusal below (the
|
|
2501
|
+
// DENY_LIST.includes(name) branch a little further into this same function):
|
|
2502
|
+
// the refusal fires BEFORE forwarding and the call never reaches the host;
|
|
2503
|
+
// this fires AFTER call() returns a real payload and appends to a
|
|
2504
|
+
// SUCCESSFUL result. The call is never refused and the error flag is never
|
|
2505
|
+
// set (D-16) -- a stopping checkpoint on an IRQ handler is core reverse-
|
|
2506
|
+
// engineering technique that Phase 2's exhaustive trace depends on, so this
|
|
2507
|
+
// warns instead of blocking it, the way the deny list blocks vice_disk_list
|
|
2508
|
+
// (which has no legitimate use at all).
|
|
2509
|
+
|
|
2510
|
+
// The set of capability names whose OWN arguments can express an armed,
|
|
2511
|
+
// stopping, exec checkpoint. Today that is vice_checkpoint_add alone.
|
|
2512
|
+
// Re-enabling an already-armed stopping checkpoint via vice_checkpoint_toggle
|
|
2513
|
+
// or a checkpoint group (vice_checkpoint_group_toggle/_add) can ALSO re-arm
|
|
2514
|
+
// one, but neither call's own arguments carry the stop flag -- only the
|
|
2515
|
+
// id/group being toggled -- so that re-enable path is NOT detectable from
|
|
2516
|
+
// the call alone and is deliberately excluded from this set. That gap is
|
|
2517
|
+
// covered by both tools' own descriptions and by vice_diagnose's checkpoint-
|
|
2518
|
+
// trap check, and it is stated in the annotation text below rather than left
|
|
2519
|
+
// for a reader to discover.
|
|
2520
|
+
const CHECKPOINT_ARMING_TOOLS = new Set(["vice_checkpoint_add"]);
|
|
2521
|
+
|
|
2522
|
+
// Per-session suppression: an address (as rendered by formatAddress(), or an
|
|
2523
|
+
// "unparseable:<raw>" key for an address that could not be parsed) already
|
|
2524
|
+
// warned about this session maps to true. Cleared whenever the observed
|
|
2525
|
+
// epoch changes -- a new machine has seen none of these. currentEpoch() is a
|
|
2526
|
+
// synchronous LOCAL file read (see its own definition above), never a
|
|
2527
|
+
// forwarded call, so consulting it here does not violate the "makes no
|
|
2528
|
+
// forwarded call of its own" requirement below.
|
|
2529
|
+
let seamHazardSeen: Set<string> = new Set();
|
|
2530
|
+
let seamHazardEpochKey: number | null = null;
|
|
2531
|
+
|
|
2532
|
+
function seamHazardObserveEpoch(): void {
|
|
2533
|
+
const epoch = currentEpoch();
|
|
2534
|
+
const key = epoch && epoch.present ? epoch.epoch : null;
|
|
2535
|
+
if (seamHazardEpochKey !== null && key !== seamHazardEpochKey) {
|
|
2536
|
+
seamHazardSeen = new Set(); // a new machine has seen none of these
|
|
2537
|
+
}
|
|
2538
|
+
seamHazardEpochKey = key;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/** detectCheckpointArmingHazard()'s own return shape -- consumed only by
|
|
2542
|
+
* renderCheckpointArmingHazard() below. */
|
|
2543
|
+
interface CheckpointArmingHazardDetection {
|
|
2544
|
+
addrLabel: string;
|
|
2545
|
+
repeat: boolean;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/**
|
|
2549
|
+
* D-16's hazard annotation. Returns the annotation text for a successful
|
|
2550
|
+
* checkpoint-arming call, or nothing. Returns nothing unless the capability
|
|
2551
|
+
* is in CHECKPOINT_ARMING_TOOLS and the arguments express an exec operation
|
|
2552
|
+
* with the stop flag set -- callers only reach this after a successful
|
|
2553
|
+
* call(), so a rejected arm never reaches here at all (a failed arm has no
|
|
2554
|
+
* hazard to warn about). Makes NO forwarded call of its own (T-01.3-13) --
|
|
2555
|
+
* the detection is entirely over the arguments the agent already supplied.
|
|
2556
|
+
* An unparseable address is still annotated, naming the address as unread
|
|
2557
|
+
* rather than silently skipping: an unparseable address is not evidence of
|
|
2558
|
+
* safety.
|
|
2559
|
+
*/
|
|
2560
|
+
function detectCheckpointArmingHazard(
|
|
2561
|
+
name: string,
|
|
2562
|
+
args: Record<string, unknown>
|
|
2563
|
+
): CheckpointArmingHazardDetection | undefined {
|
|
2564
|
+
if (!CHECKPOINT_ARMING_TOOLS.has(name)) return undefined;
|
|
2565
|
+
// vice_checkpoint_add's own schema: `stop` defaults true, `exec` defaults
|
|
2566
|
+
// true -- an ABSENT field is armed, not merely "true when written out".
|
|
2567
|
+
const stopArmed = !(args && args.stop === false);
|
|
2568
|
+
const execArmed = !(args && args.exec === false);
|
|
2569
|
+
if (!stopArmed || !execArmed) return undefined;
|
|
2570
|
+
|
|
2571
|
+
seamHazardObserveEpoch();
|
|
2572
|
+
|
|
2573
|
+
const addrNum = toAddressNumber(args && args.start);
|
|
2574
|
+
const addrLabel =
|
|
2575
|
+
addrNum === null ? `an unparseable address (raw value: ${JSON.stringify(args && args.start)})` : formatAddress(addrNum);
|
|
2576
|
+
const suppressionKey = addrNum === null ? `unparseable:${JSON.stringify(args && args.start)}` : addrLabel;
|
|
2577
|
+
|
|
2578
|
+
const repeat = seamHazardSeen.has(suppressionKey);
|
|
2579
|
+
if (!repeat) seamHazardSeen.add(suppressionKey);
|
|
2580
|
+
return { addrLabel, repeat };
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function renderCheckpointArmingHazard(detection: CheckpointArmingHazardDetection): string {
|
|
2584
|
+
const { addrLabel, repeat } = detection;
|
|
2585
|
+
if (repeat) {
|
|
2586
|
+
return (
|
|
2587
|
+
`vice hazard (repeat): a stopping exec checkpoint was armed again at ${addrLabel} -- the full ` +
|
|
2588
|
+
"hazard note for this address was already issued earlier this session; see that note."
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
return [
|
|
2592
|
+
`vice hazard: a stopping exec checkpoint was just armed at ${addrLabel}, and the call was NOT ` +
|
|
2593
|
+
"blocked -- it will not be, because this is core reverse-engineering technique.",
|
|
2594
|
+
"",
|
|
2595
|
+
"This shape -- a stopping exec checkpoint armed, then execution resumed -- is common to every recorded " +
|
|
2596
|
+
"freeze on this project. Two variants are on record: a mid-routine stop that froze two independent " +
|
|
2597
|
+
"sessions at an identical program counter, and an IRQ-handler-entry stop whose tell was a hit count " +
|
|
2598
|
+
"of zero on a screen the machine must have been executing.",
|
|
2599
|
+
"",
|
|
2600
|
+
"Whether THIS address is the live IRQ handler is a question vice_diagnose answers, by resolving the " +
|
|
2601
|
+
"vector pair live -- this warning deliberately does not resolve it here, because doing so on every " +
|
|
2602
|
+
"arm would disturb the machine it is protecting.",
|
|
2603
|
+
"",
|
|
2604
|
+
"Recovery, in order: run vice_diagnose first; reach for vice_recycle only when the bracket says wedge " +
|
|
2605
|
+
"with no checkpoint explanation.",
|
|
2606
|
+
"",
|
|
2607
|
+
"Stated residual: re-enabling this checkpoint later via vice_checkpoint_toggle or a checkpoint group " +
|
|
2608
|
+
"carries no stop flag in its own arguments and is therefore NOT annotated by this mechanism -- covered " +
|
|
2609
|
+
"by both tools' own descriptions and by vice_diagnose's checkpoint-trap check instead.",
|
|
2610
|
+
].join("\n");
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* Plan 01.3-04 task 2: turns task 1's single hazard into the general
|
|
2615
|
+
* mechanism D-06 needs -- a table, so the next confirmed trigger (plan
|
|
2616
|
+
* 01.3-05's bounded hunt) is a single entry rather than new plumbing at this
|
|
2617
|
+
* seam. Each entry:
|
|
2618
|
+
* - id: a short identifier that MUST be named by at least one test in
|
|
2619
|
+
* vice-proxy.test.mjs (this file's own structural completeness test
|
|
2620
|
+
* enforces it) -- an entry that ships without a matching test fails the
|
|
2621
|
+
* suite rather than shipping unproven.
|
|
2622
|
+
* - capabilities: the Set of tool names this entry's own detect() can ever
|
|
2623
|
+
* match against. Used ONLY by the disjointness structural test below
|
|
2624
|
+
* (never for dispatch -- the walk tries every entry against every
|
|
2625
|
+
* call). Every capability named here must be ABSENT from DENY_LIST: a
|
|
2626
|
+
* capability with no legitimate use is refused before forwarding, and
|
|
2627
|
+
* one with a legitimate use is annotated after it, and none is both
|
|
2628
|
+
* (D-16).
|
|
2629
|
+
* - detect(name, args, payload): returns a truthy detection payload, or
|
|
2630
|
+
* nothing. MUST make no forwarded call of its own (T-01.3-13).
|
|
2631
|
+
* - render(detection): returns the annotation text for a truthy
|
|
2632
|
+
* detection.
|
|
2633
|
+
*
|
|
2634
|
+
* Plan 01.3-05 is this table's expected next writer, adding the bounded
|
|
2635
|
+
* hunt's own confirmed trigger as one more entry here -- not new plumbing.
|
|
2636
|
+
*/
|
|
2637
|
+
// Method-shorthand syntax deliberately (not `detect: (...) => ...`): TS's
|
|
2638
|
+
// bivariant method-parameter check is what lets each entry's own narrower
|
|
2639
|
+
// detect()/render() pair (e.g. CheckpointArmingHazardDetection, not
|
|
2640
|
+
// `unknown`) slot into this shared, heterogeneous table -- exactly the
|
|
2641
|
+
// polymorphism the table's own doc comment above describes ("the next
|
|
2642
|
+
// confirmed trigger is a single entry"). The production entry below is cast
|
|
2643
|
+
// `as SeamHazardEntry` (not the whole SEAM_HAZARDS declaration -- that
|
|
2644
|
+
// exact line is vice-proxy.test.mjs's own oracle anchor, `indexOf("const
|
|
2645
|
+
// SEAM_HAZARDS = [")`, and must stay byte-identical) so the array's own
|
|
2646
|
+
// inferred element type is this interface, which is what lets the
|
|
2647
|
+
// TEST-ONLY .push() below (a structurally different detect/render pair)
|
|
2648
|
+
// type-check without a second cast at that call site.
|
|
2649
|
+
interface SeamHazardEntry {
|
|
2650
|
+
id: string;
|
|
2651
|
+
capabilities: Set<string>;
|
|
2652
|
+
detect(name: string, args: Record<string, unknown>, payload?: unknown): unknown;
|
|
2653
|
+
render(detection: unknown): string;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
const SEAM_HAZARDS = [
|
|
2657
|
+
{
|
|
2658
|
+
id: "checkpoint-arming",
|
|
2659
|
+
capabilities: CHECKPOINT_ARMING_TOOLS,
|
|
2660
|
+
detect: detectCheckpointArmingHazard,
|
|
2661
|
+
render: renderCheckpointArmingHazard,
|
|
2662
|
+
} as SeamHazardEntry,
|
|
2663
|
+
];
|
|
2664
|
+
|
|
2665
|
+
// TEST-ONLY escape hatch (plan 01.3-04 task 2's data-driven proof): proves
|
|
2666
|
+
// the walk below is genuinely data-driven, not hand-wired to the one
|
|
2667
|
+
// production entry above, by injecting a SECOND entry the same way a real
|
|
2668
|
+
// plan 01.3-05 entry would arrive. Matches against vice_ping -- an existing,
|
|
2669
|
+
// universally-forwardable tool -- rather than inventing a synthetic
|
|
2670
|
+
// capability name that would need its own manifest/deny-list bookkeeping.
|
|
2671
|
+
// Never set outside this file's own test suite.
|
|
2672
|
+
if (process.env.VICE_SEAM_HAZARDS_TEST_FIXTURE === "1") {
|
|
2673
|
+
SEAM_HAZARDS.push({
|
|
2674
|
+
id: "test-fixture-synthetic-entry",
|
|
2675
|
+
capabilities: new Set(["vice_ping"]),
|
|
2676
|
+
detect: (name: string) => (name === "vice_ping" ? { fixture: true } : undefined),
|
|
2677
|
+
render: () => "vice-proxy hazard (TEST FIXTURE): synthetic second SEAM_HAZARDS entry, detected and annotated through the same walk.",
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* Walks SEAM_HAZARDS, concatenating every annotation a successful call
|
|
2683
|
+
* attracts. Short-circuits per entry on a falsy detection -- a call matching
|
|
2684
|
+
* no entry costs one array pass and returns undefined, leaving the payload
|
|
2685
|
+
* untouched.
|
|
2686
|
+
*/
|
|
2687
|
+
function renderSeamHazardAnnotations(name: string, args: Record<string, unknown>, payload: unknown): string | undefined {
|
|
2688
|
+
const notes: string[] = [];
|
|
2689
|
+
for (const entry of SEAM_HAZARDS) {
|
|
2690
|
+
const detection = entry.detect(name, args, payload);
|
|
2691
|
+
if (detection) {
|
|
2692
|
+
notes.push(entry.render(detection));
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
return notes.length ? notes.join("\n\n") : undefined;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// forwardToVice() is the retained BODY of what used to be handleToolsCall()
|
|
2699
|
+
// -- renamed and trimmed of the name/args extraction, the three synthetic-
|
|
2700
|
+
// tool short-circuits, and the deny-list check, all now handled one layer
|
|
2701
|
+
// out by the CallToolRequestSchema override and the tool registry
|
|
2702
|
+
// construction (both near the bottom of this file, right after the
|
|
2703
|
+
// teardown region): each real manifest tool's own buildViceTool() entry
|
|
2704
|
+
// wraps this function as its `execute`, so this is reached only for a name
|
|
2705
|
+
// already known to be a real, non-deny-listed manifest tool with an
|
|
2706
|
+
// already-parsed `args` object. Every function called below is reused
|
|
2707
|
+
// completely unchanged from its pre-swap form.
|
|
2708
|
+
async function forwardToVice(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
2709
|
+
const leaseResult = await ensureBrokerLease();
|
|
2710
|
+
if (!leaseResult.ok) {
|
|
2711
|
+
return isErrorText(leaseResult.message);
|
|
2712
|
+
}
|
|
2713
|
+
// No touch-on-every-forwarded-call any more (C6's old mechanism, alongside
|
|
2714
|
+
// the heartbeat timer, both retired under D-12): the connection itself is
|
|
2715
|
+
// the claim, kernel-enforced, with nothing to refresh. Either the socket is
|
|
2716
|
+
// still open, or the broker's own "close" handler has already reclaimed
|
|
2717
|
+
// the instance -- there is no third, ambiguous state a touch could rescue.
|
|
2718
|
+
|
|
2719
|
+
ensureViceSession();
|
|
2720
|
+
|
|
2721
|
+
const beforeDrift = checkEpochAndRebaseline("before forwarding");
|
|
2722
|
+
if (beforeDrift) {
|
|
2723
|
+
// Refused BEFORE any request is serialised -- the whole point of the
|
|
2724
|
+
// pre-forward check.
|
|
2725
|
+
return isErrorText(beforeDrift);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// Pre-flight liveness probe (task 2 / criterion 7), ordered AFTER the
|
|
2729
|
+
// deny-list refusal and the epoch comparison above (a refused tool and a
|
|
2730
|
+
// restarted machine both need answering without any network activity at
|
|
2731
|
+
// all) and BEFORE delegating to call() -- see vice-probe.ts's header for
|
|
2732
|
+
// why this is a single 1500ms-budget round trip with no retry, never
|
|
2733
|
+
// wrapped in withReconnect()'s ladder. One call site, not inside a loop.
|
|
2734
|
+
const { url, port } = activeInstance();
|
|
2735
|
+
const probe = await probeInstance({ url, port });
|
|
2736
|
+
if (!probe.alive) {
|
|
2737
|
+
const epoch = currentEpoch();
|
|
2738
|
+
// D-5 (quick-260801-ccn task 3): the lease check runs FIRST, before the
|
|
2739
|
+
// refused-and-no-epoch test below -- under the bug this fixes, BOTH of
|
|
2740
|
+
// that test's arms hold true for a fresh broker grant (a just-granted
|
|
2741
|
+
// instance's own epoch_file rarely has a baseline recorded yet), so a
|
|
2742
|
+
// broker-granted instance was being answered by the RETIRED fixed-port
|
|
2743
|
+
// triple instead of naming the broker. That ordering was the whole
|
|
2744
|
+
// defect.
|
|
2745
|
+
if (controlSession) {
|
|
2746
|
+
// D-13/D-14 (plan 08): a granted instance not answering no longer
|
|
2747
|
+
// gets a report-and-instruct message -- it gets a replace-and-report.
|
|
2748
|
+
// handleGrantedInstanceUnreachable() acquires a replacement over this
|
|
2749
|
+
// same session (or, if the session itself turns out to be gone, a
|
|
2750
|
+
// genuinely fresh one) and returns an ERROR naming the replacement --
|
|
2751
|
+
// never a silently substituted result, even though a working
|
|
2752
|
+
// instance is now held for the NEXT call.
|
|
2753
|
+
return isErrorText(await handleGrantedInstanceUnreachable(probe, epoch));
|
|
2754
|
+
}
|
|
2755
|
+
if (isConnectionRefusedReason(probe.reason) && !epoch.present) {
|
|
2756
|
+
return isErrorText(neverStartedMessage(probe));
|
|
2757
|
+
}
|
|
2758
|
+
// Every other unreachable shape -- refused-with-an-epoch-on-record,
|
|
2759
|
+
// timed out, or something answered but didn't look like VICE -- is
|
|
2760
|
+
// "dead or hung"; probe.reason itself says which, verbatim.
|
|
2761
|
+
return isErrorText(deadOrHungMessage(probe, epoch));
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// Path translation at the seam (task 3 / decision D-G / criterion 9),
|
|
2765
|
+
// ordered after the deny-list refusal, the epoch comparison and the
|
|
2766
|
+
// liveness probe above, and before delegating to call(). A refusal here
|
|
2767
|
+
// (out-of-workspace absolute path, or a translation failure) is returned
|
|
2768
|
+
// exactly like every other tools/call outcome: a well-formed isError:true
|
|
2769
|
+
// result, never a throw.
|
|
2770
|
+
let translatedArgs: Record<string, unknown>;
|
|
2771
|
+
let pathNote = "";
|
|
2772
|
+
try {
|
|
2773
|
+
const rewritten = rewriteArguments(args, name);
|
|
2774
|
+
translatedArgs = rewritten.args;
|
|
2775
|
+
pathNote = resolutionNote(rewritten.resolutions);
|
|
2776
|
+
} catch (e) {
|
|
2777
|
+
if (e instanceof PathOutOfWorkspaceError || e instanceof PathTranslationError) {
|
|
2778
|
+
return isErrorText(e.message);
|
|
2779
|
+
}
|
|
2780
|
+
throw e; // unexpected -- let the never-throw dispatch one layer up handle it
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
let payload: unknown;
|
|
2784
|
+
try {
|
|
2785
|
+
payload = await call(name, translatedArgs);
|
|
2786
|
+
} catch (e) {
|
|
2787
|
+
if (e instanceof MachineRestartedError) {
|
|
2788
|
+
// call()'s own post-reconnect fast path detected this first -- convert
|
|
2789
|
+
// to the same isError frame shape and re-baseline identically. Two
|
|
2790
|
+
// layers, one observable behaviour.
|
|
2791
|
+
const current = currentEpoch();
|
|
2792
|
+
epochBaseline = current;
|
|
2793
|
+
return isErrorText(
|
|
2794
|
+
`vice: treat every result since the previous call as void and redo that work -- the emulator was ` +
|
|
2795
|
+
`replaced mid-call (epoch changed from ${e.baselineEpoch} to ${e.currentEpoch}). (${e.message})`
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
2798
|
+
// NEVER rethrow past this point -- a tool-execution failure (transport
|
|
2799
|
+
// error, a rejected RPC) is a normal, expected outcome for this method
|
|
2800
|
+
// and must come back as a well-formed result, not crash the read loop.
|
|
2801
|
+
// The probe above already proved the host alive, so this is the "alive
|
|
2802
|
+
// but the operation failed" state -- relay verbatim, no restart advice.
|
|
2803
|
+
// The path note rides along on the FAILURE too, and this is the case it
|
|
2804
|
+
// was written for: a host-side "Failed to attach disk image" says nothing
|
|
2805
|
+
// about which file was attempted, so naming the resolved absolute path
|
|
2806
|
+
// here is the difference between a one-line fix and an hour spent
|
|
2807
|
+
// suspecting the emulator.
|
|
2808
|
+
const failure = aliveButFailedMessage(e && (e as Error).message ? (e as Error).message : String(e));
|
|
2809
|
+
return isErrorText(pathNote ? `${failure}\n${pathNote}` : failure);
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
const afterDrift = checkEpochAndRebaseline("after the call returned");
|
|
2813
|
+
if (afterDrift) {
|
|
2814
|
+
// A payload read from a machine whose identity changed mid-call is not
|
|
2815
|
+
// trustworthy -- return the restart frame INSTEAD OF the call's result.
|
|
2816
|
+
return isErrorText(afterDrift);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
const rawText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
2820
|
+
// D-16 seam hazard annotation (plan 01.3-04): computed by walking
|
|
2821
|
+
// SEAM_HAZARDS and merged into the TEXT itself, BEFORE wrapPossiblyChunked()
|
|
2822
|
+
// runs, so an oversized annotated result still carries the note inside its
|
|
2823
|
+
// own chunking (T-01.3-15) -- a warning appended AFTER chunking would be
|
|
2824
|
+
// lost off the end. Never routes through isErrorText and never touches the
|
|
2825
|
+
// error flag (D-16, T-01.3-12).
|
|
2826
|
+
const hazardNote = renderSeamHazardAnnotations(name, args, payload);
|
|
2827
|
+
const text = hazardNote ? `${rawText}\n\n${hazardNote}` : rawText;
|
|
2828
|
+
const wrapped = wrapPossiblyChunked(text);
|
|
2829
|
+
// Append the path note as a trailing content item, never mixed into the
|
|
2830
|
+
// payload: wrapPossiblyChunked()'s contract is that the FIRST item is the
|
|
2831
|
+
// payload byte-for-byte, so reassembly stays a plain concatenation. Only
|
|
2832
|
+
// the unchunked shape is annotated -- a chunked result is already carrying
|
|
2833
|
+
// a continuation marker as its second item, and the four tools that can
|
|
2834
|
+
// resolve a path (disk_attach, autostart, display_screenshot, symbols_load)
|
|
2835
|
+
// never produce output anywhere near the cap.
|
|
2836
|
+
if (pathNote && wrapped.content.length === 1) {
|
|
2837
|
+
wrapped.content.push({ type: "text", text: pathNote });
|
|
2838
|
+
}
|
|
2839
|
+
return wrapped;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// -------------------------------------------------------------- teardown
|
|
2843
|
+
//
|
|
2844
|
+
// TWO ladders, not one, firing DIFFERENT handlers (spike-findings-bruce-lee
|
|
2845
|
+
// skill, shutdown-and-lease-release.md -- measured, not assumed): a
|
|
2846
|
+
// graceful client ending delivers SIGINT first, then SIGTERM ~100ms later,
|
|
2847
|
+
// then SIGKILL at ~490ms total, and NEVER closes stdin. Abrupt client death
|
|
2848
|
+
// closes stdin (`end` then `close`) and NEVER signals. Each family covers
|
|
2849
|
+
// exactly the ending the other misses, so both are wired below; SIGINT is a
|
|
2850
|
+
// teardown trigger here, not a user Ctrl-C to ignore -- it is the FIRST
|
|
2851
|
+
// signal of every graceful ending.
|
|
2852
|
+
//
|
|
2853
|
+
// The measured numbers this depends on: ~490ms from the first signal to
|
|
2854
|
+
// SIGKILL, on the order of microseconds for closing a socket handle --
|
|
2855
|
+
// roughly as many orders of magnitude of headroom as the retiring lease
|
|
2856
|
+
// file's own unlinkSync had. The entire handler body below calls exactly
|
|
2857
|
+
// one release and AWAITS NOTHING (C5): introducing anything that blocks on
|
|
2858
|
+
// a response here (an await, a fetch, a child process, a round trip to the
|
|
2859
|
+
// broker) reintroduces leaked leases silently, since there would be no time
|
|
2860
|
+
// left for it to complete before SIGKILL cuts the process off. Plan
|
|
2861
|
+
// 01.6.2-07: the lease is now the control connection itself, so "release"
|
|
2862
|
+
// is `socket.destroy()` -- a synchronous, in-process handle close, not a
|
|
2863
|
+
// network round trip; nothing here waits for the broker to acknowledge
|
|
2864
|
+
// anything, matching the retiring unlinkSync's own fire-and-forget shape.
|
|
2865
|
+
// BrokerControlSession.release() is declared `async` (vice-broker-client.ts),
|
|
2866
|
+
// so a synchronous throw inside it becomes a REJECTED PROMISE, not a thrown
|
|
2867
|
+
// exception -- a plain try/catch around a bare, unawaited call would never
|
|
2868
|
+
// see it. `.catch(...)` (not `await`, not `.then(`) is the correct way to
|
|
2869
|
+
// observe that failure without awaiting or chaining a success handler,
|
|
2870
|
+
// and is not itself a promise-awaiting construct: nothing in this region
|
|
2871
|
+
// blocks on the release settling before returning.
|
|
2872
|
+
//
|
|
2873
|
+
// This removes the file's only explicit process.exit( call: nothing needs
|
|
2874
|
+
// it any more. The graceful path is killed by SIGKILL ~490ms after the
|
|
2875
|
+
// first signal regardless of anything this process does, and the abrupt
|
|
2876
|
+
// path exits naturally once stdin is gone and nothing else is listening.
|
|
2877
|
+
//
|
|
2878
|
+
// TEARDOWN-REGION-BEGIN -- vice-proxy.test.mjs's source assertion slices
|
|
2879
|
+
// the file between this marker and its closing counterpart further below,
|
|
2880
|
+
// and asserts that slice contains no promise-awaiting construct and calls
|
|
2881
|
+
// the control session's release function exactly once. Do not move either
|
|
2882
|
+
// marker away from the code each one bounds.
|
|
2883
|
+
let teardownRan = false;
|
|
2884
|
+
|
|
2885
|
+
function releaseLeaseNow(trigger: string): void {
|
|
2886
|
+
if (!controlSession) return;
|
|
2887
|
+
controlSession.release().catch((err: unknown) => {
|
|
2888
|
+
console.error(`vice-proxy: lease_release_failed trigger=${trigger}: ${err && (err as Error).message ? (err as Error).message : err}`);
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
function onTeardown(trigger: string): void {
|
|
2893
|
+
if (teardownRan) return; // idempotent -- SIGINT then SIGTERM ~100ms later both call in
|
|
2894
|
+
teardownRan = true;
|
|
2895
|
+
releaseLeaseNow(trigger);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
process.stdin.on("end", () => onTeardown("stdin_end"));
|
|
2899
|
+
process.stdin.on("close", () => onTeardown("stdin_close"));
|
|
2900
|
+
// Registered as three explicit calls, not a loop over an array, so a
|
|
2901
|
+
// durable source-grep for "is SIGINT/SIGTERM/SIGHUP each really wired"
|
|
2902
|
+
// (this task's own acceptance criteria) has a literal string to find for
|
|
2903
|
+
// each one -- SIGINT first, since it is the first signal of every graceful
|
|
2904
|
+
// ending and must never be mistaken for a plain user Ctrl-C to ignore.
|
|
2905
|
+
process.on("SIGINT", () => onTeardown("SIGINT"));
|
|
2906
|
+
process.on("SIGTERM", () => onTeardown("SIGTERM"));
|
|
2907
|
+
process.on("SIGHUP", () => onTeardown("SIGHUP"));
|
|
2908
|
+
// TEARDOWN-REGION-END
|
|
2909
|
+
|
|
2910
|
+
warnOnceAboutOutputLimit(); // D-1.2-H -- one stderr line, at most once per process, never a refusal
|
|
2911
|
+
|
|
2912
|
+
// ------------------------------------------------------- @mastra/mcp seam
|
|
2913
|
+
//
|
|
2914
|
+
// D-01 (this plan): the wire layer is now MCPServer + startStdio(), with
|
|
2915
|
+
// broker leasing, epoch, probe, path rewriting, call() and chunking all
|
|
2916
|
+
// reused completely unchanged inside forwardToVice() above -- only the
|
|
2917
|
+
// top-level caller changed. See this plan's PLAN.md "Ground truth" section
|
|
2918
|
+
// (read directly from @mastra/mcp's compiled source, not its docs) for why
|
|
2919
|
+
// tools/call is answered by the CallToolRequestSchema override below rather
|
|
2920
|
+
// than by MCPServer's own dispatch.
|
|
2921
|
+
|
|
2922
|
+
/**
|
|
2923
|
+
* Adapts a manifest tool's raw JSON Schema into the minimal
|
|
2924
|
+
* StandardSchemaWithJSON shape createTool() requires, matching TODAY's
|
|
2925
|
+
* zero-validation-at-the-proxy behaviour exactly: `~standard.validate`
|
|
2926
|
+
* always succeeds (this proxy has never validated argument shape itself --
|
|
2927
|
+
* the host does), and `~standard.jsonSchema.input()`/`.output()` both
|
|
2928
|
+
* return the SAME schema object verbatim regardless of `target`/`io`, so
|
|
2929
|
+
* tools/list's wire output stays byte-identical to the manifest's own raw
|
|
2930
|
+
* schema (proven by a deep-equal assertion in vice-proxy.test.ts, not
|
|
2931
|
+
* assumed from either library's documentation).
|
|
2932
|
+
*/
|
|
2933
|
+
function rawJsonSchemaAsStandardSchema(schema: unknown): StandardSchemaWithJSON {
|
|
2934
|
+
const jsonSchema = isPlainObject(schema) ? schema : { type: "object", properties: {} };
|
|
2935
|
+
return {
|
|
2936
|
+
"~standard": {
|
|
2937
|
+
version: 1,
|
|
2938
|
+
vendor: "vice-proxy",
|
|
2939
|
+
validate: (value: unknown) => ({ value }),
|
|
2940
|
+
jsonSchema: {
|
|
2941
|
+
input: () => jsonSchema,
|
|
2942
|
+
output: () => jsonSchema,
|
|
2943
|
+
},
|
|
2944
|
+
},
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/** Turns a `ToolCallResult` (this file's own internal `{content, isError}`
|
|
2949
|
+
* shape) into the SDK's `CallToolResult` wire shape -- a direct, lossless
|
|
2950
|
+
* pass-through, since the two shapes are structurally identical. The whole
|
|
2951
|
+
* point of the CallToolRequestSchema override below building the response
|
|
2952
|
+
* itself is that no translation or mangling happens here. */
|
|
2953
|
+
function toolCallResultToWire(result: ToolCallResult): { content: ToolCallResult["content"]; isError: boolean } {
|
|
2954
|
+
return { content: result.content, isError: result.isError };
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
/** Narrows an `unknown` execute() return value to this file's own
|
|
2958
|
+
* ToolCallResult shape before trusting it. Every tool this file registers
|
|
2959
|
+
* is one this file itself wrote (buildViceTool()'s own `run` callbacks
|
|
2960
|
+
* always return this shape), but the override still checks rather than
|
|
2961
|
+
* casting blind, matching this file's own isPlainObject() discipline. */
|
|
2962
|
+
function isToolCallResult(value: unknown): value is ToolCallResult {
|
|
2963
|
+
return isPlainObject(value) && Array.isArray(value.content) && typeof value.isError === "boolean";
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/**
|
|
2967
|
+
* Wraps a ToolDefinition (a manifest tool, or one of this file's own three
|
|
2968
|
+
* proxy-local synthetic tools) plus its own runner into a Mastra Tool via
|
|
2969
|
+
* createTool(), reproducing exactly the `_meta` merge handleToolsList() used
|
|
2970
|
+
* to perform at read time (now construction-time, see the registry below).
|
|
2971
|
+
*/
|
|
2972
|
+
function buildViceTool(def: ToolDefinition, run: (args: Record<string, unknown>) => Promise<ToolCallResult>) {
|
|
2973
|
+
return createTool({
|
|
2974
|
+
id: def.name,
|
|
2975
|
+
description: def.description ?? "",
|
|
2976
|
+
inputSchema: rawJsonSchemaAsStandardSchema(def.inputSchema),
|
|
2977
|
+
mcp: {
|
|
2978
|
+
_meta: {
|
|
2979
|
+
...((def._meta as Record<string, unknown> | undefined) || {}),
|
|
2980
|
+
"anthropic/maxResultSizeChars": OUTPUT_CHAR_CAP,
|
|
2981
|
+
},
|
|
2982
|
+
},
|
|
2983
|
+
execute: async (inputData) => run(isPlainObject(inputData) ? inputData : {}),
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// Plan 01.6.3-02's tracer proved this mechanism on `vice_ping` alone
|
|
2988
|
+
// (TRACER_MANIFEST_TOOL_NAMES, since removed). Plan 01.6.3-03 widens the
|
|
2989
|
+
// input set to the FULL manifest -- the loop body itself is unchanged from
|
|
2990
|
+
// the tracer: no per-tool special case, only the same DENY_LIST filter
|
|
2991
|
+
// already proven in Plan 02. The manifest also lists the host's own
|
|
2992
|
+
// generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
|
|
2993
|
+
// `notifications_initialized`) as ordinary tools; 01.6.3-03 registered them
|
|
2994
|
+
// like any other manifest entry (a real, disclosed generic-dispatch risk it
|
|
2995
|
+
// did not itself widen -- see the CallToolRequestSchema override below for
|
|
2996
|
+
// the historical shape of that risk), and 01.4-01 (tasks 1+2) closed it by
|
|
2997
|
+
// adding all four to DENY_LIST itself, so this SAME skip now filters them
|
|
2998
|
+
// out of `tools` at construction time exactly like vice_disk_list always
|
|
2999
|
+
// was.
|
|
3000
|
+
//
|
|
3001
|
+
// Construction-time, not read-time -- a deliberate, disclosed narrowing this
|
|
3002
|
+
// plan records explicitly: tools/list is now served entirely by MCPServer's
|
|
3003
|
+
// own ListToolsRequestSchema handler (unmodified, not overridden), reading
|
|
3004
|
+
// from this SAME `tools` object, so vice_disk_list's absence from it is the
|
|
3005
|
+
// ONLY enforcement discovery-time needs any more -- no separate filter
|
|
3006
|
+
// function runs at read time. A manifest hot-reload mid-session is
|
|
3007
|
+
// therefore no longer picked up until the proxy restarts; the manifest is
|
|
3008
|
+
// regenerated by a manual, rare build step, never mid-session in practice.
|
|
3009
|
+
const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
|
|
3010
|
+
for (const def of readManifestTools()) {
|
|
3011
|
+
if (DENY_LIST.includes(def.name)) continue;
|
|
3012
|
+
tools[def.name] = buildViceTool(def, (args) => forwardToVice(def.name, args));
|
|
3013
|
+
}
|
|
3014
|
+
tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
|
|
3015
|
+
tools[RECYCLE_TOOL.name] = buildViceTool(RECYCLE_TOOL, (args) => handleRecycle(args));
|
|
3016
|
+
tools[DIAGNOSE_TOOL.name] = buildViceTool(DIAGNOSE_TOOL, (args) => handleDiagnose(args));
|
|
3017
|
+
|
|
3018
|
+
const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
|
|
3019
|
+
await server.startStdio();
|
|
3020
|
+
// Installed with ZERO await between this line and the one above (see this
|
|
3021
|
+
// plan's "Ground truth" section for why that ordering is load-bearing --
|
|
3022
|
+
// StdioServerTransport.start() has already wired its 'data' listener by the
|
|
3023
|
+
// time startStdio()'s promise resolves, but Node does not deliver a queued
|
|
3024
|
+
// 'data' event until the next event-loop turn): MCPServer's own tools/call
|
|
3025
|
+
// dispatch always forces isError:false on success and prepends "Error: " on
|
|
3026
|
+
// a thrown failure (read directly from @mastra/mcp's compiled source this
|
|
3027
|
+
// session, not its docs), which matches neither this file's own
|
|
3028
|
+
// {content, isError} contract nor the deny-list's exact refusal wording a
|
|
3029
|
+
// pre-existing test pins verbatim -- so tools/call is answered entirely by
|
|
3030
|
+
// this override, never by MCPServer's own handler. tools/list is NOT
|
|
3031
|
+
// overridden -- MCPServer's own ListToolsRequestSchema handler answers it,
|
|
3032
|
+
// the one piece of genuine library value this swap adopts.
|
|
3033
|
+
server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3034
|
+
const name = request.params.name;
|
|
3035
|
+
// Layer 1 (unchanged mechanism, now here instead of the retired
|
|
3036
|
+
// handleToolsCall()): call-time deny-list refusal, before any tool lookup
|
|
3037
|
+
// and before any network attempt -- independent from `tools`'s own
|
|
3038
|
+
// construction-time absence of vice_disk_list (layer 2, the
|
|
3039
|
+
// discovery-time enforcement tools/list reads from). Removing either
|
|
3040
|
+
// layer leaves the other standing.
|
|
3041
|
+
if (DENY_LIST.includes(name)) {
|
|
3042
|
+
return {
|
|
3043
|
+
content: [{ type: "text", text: denyListRefusalMessage(name) }],
|
|
3044
|
+
isError: true,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
// CLOSED BY 01.4-01 (tasks 1+2), closing Phase 01.4 criterion 3's
|
|
3048
|
+
// already-recorded open breach concern. This check inspects only the
|
|
3049
|
+
// OUTER `name` -- the literal MCP tool being called -- and always has;
|
|
3050
|
+
// that outer-name-only shape is unchanged by this fix and is NOT itself
|
|
3051
|
+
// the hazard. The hazard was that the manifest also lists the host's own
|
|
3052
|
+
// generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
|
|
3053
|
+
// `notifications_initialized`) as ordinary forwardable tools, and
|
|
3054
|
+
// `tools_call` specifically could carry a forbidden name (e.g.
|
|
3055
|
+
// `vice_disk_list`) as a NESTED `arguments.name`, bypassing this exact
|
|
3056
|
+
// guard by never presenting the forbidden name as the OUTER one. All four
|
|
3057
|
+
// meta-tool names are now themselves on DENY_LIST (task 1 added
|
|
3058
|
+
// `tools_list`; task 2 added `tools_call`, `initialize` and
|
|
3059
|
+
// `notifications_initialized` after confirming, via a repo-wide grep, that
|
|
3060
|
+
// none has a sanctioned caller): `tools_call` itself is refused before its
|
|
3061
|
+
// own nested argument is ever read, closing the bypass without teaching
|
|
3062
|
+
// this guard to parse nested argument shapes -- one array, no new
|
|
3063
|
+
// mechanism, exactly 01.4-RESEARCH.md's own Pattern 1 and primary
|
|
3064
|
+
// recommendation. The historical bypass-proving test in
|
|
3065
|
+
// vice-proxy.test.ts is repointed (not deleted) to assert this closure.
|
|
3066
|
+
// Full history in 01.6.3-03-SUMMARY.md and
|
|
3067
|
+
// .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
|
|
3068
|
+
const tool = tools[name];
|
|
3069
|
+
if (!tool || !tool.execute) {
|
|
3070
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
3071
|
+
}
|
|
3072
|
+
try {
|
|
3073
|
+
const raw = await tool.execute(request.params.arguments ?? {}, { observe: noopObserve });
|
|
3074
|
+
if (!isToolCallResult(raw)) {
|
|
3075
|
+
return {
|
|
3076
|
+
content: [
|
|
3077
|
+
{ type: "text", text: `vice: internal error -- tool "${name}"'s execute() returned an unexpected shape` },
|
|
3078
|
+
],
|
|
3079
|
+
isError: true,
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
return toolCallResultToWire(raw);
|
|
3083
|
+
} catch (e) {
|
|
3084
|
+
// The never-throw discipline this file already lives by (matching the
|
|
3085
|
+
// retired handleToolsCall()'s own "NEVER rethrow past this point"
|
|
3086
|
+
// comment) -- forwardToVice() and the synthetic-tool handlers should
|
|
3087
|
+
// never actually throw in normal operation, but this override must not
|
|
3088
|
+
// depend on that being true.
|
|
3089
|
+
return { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true };
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
console.error(`vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`);
|