c64bridge 1.0.7 → 1.1.0

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.
Files changed (132) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +69 -14
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/config.js +48 -26
  19. package/dist/device.js +92 -37
  20. package/dist/diagnostics.js +62 -2
  21. package/dist/hostPaths.js +128 -0
  22. package/dist/loggingHttpClient.js +59 -1
  23. package/dist/mcp-server.js +409 -286
  24. package/dist/numericArg.js +55 -0
  25. package/dist/petscii.js +13 -1
  26. package/dist/platform.js +12 -3
  27. package/dist/prompts/registry.js +6 -1
  28. package/dist/tools/access.js +66 -0
  29. package/dist/tools/debug.js +6 -0
  30. package/dist/tools/developer.js +67 -34
  31. package/dist/tools/errors.js +54 -3
  32. package/dist/tools/input.js +23 -6
  33. package/dist/tools/machineControl.js +112 -20
  34. package/dist/tools/memory.js +135 -30
  35. package/dist/tools/meta/artifacts.js +5 -5
  36. package/dist/tools/meta/background.js +216 -54
  37. package/dist/tools/meta/compilation.js +6 -5
  38. package/dist/tools/meta/config.js +15 -6
  39. package/dist/tools/meta/filesystem.js +16 -14
  40. package/dist/tools/meta/graphics.js +48 -20
  41. package/dist/tools/meta/memory.js +45 -19
  42. package/dist/tools/meta/program.js +10 -7
  43. package/dist/tools/meta/screen.js +116 -25
  44. package/dist/tools/pauseGuard.js +23 -0
  45. package/dist/tools/pollValidator.js +53 -8
  46. package/dist/tools/programRunners.js +62 -18
  47. package/dist/tools/registry/batch.js +16 -4
  48. package/dist/tools/registry/config.js +9 -2
  49. package/dist/tools/registry/disk.js +12 -2
  50. package/dist/tools/registry/drive.js +3 -0
  51. package/dist/tools/registry/extract.js +3 -0
  52. package/dist/tools/registry/graphics.js +59 -6
  53. package/dist/tools/registry/index.js +17 -1
  54. package/dist/tools/registry/memory.js +11 -1
  55. package/dist/tools/registry/platform.js +4 -1
  56. package/dist/tools/registry/printer.js +2 -1
  57. package/dist/tools/registry/program.js +5 -1
  58. package/dist/tools/registry/rag.js +1 -0
  59. package/dist/tools/registry/sound.js +12 -1
  60. package/dist/tools/registry/stream.js +2 -1
  61. package/dist/tools/registry/system.js +8 -1
  62. package/dist/tools/schema.js +7 -7
  63. package/dist/tools/streaming.js +34 -4
  64. package/dist/tools/translation/assembler.js +247 -24
  65. package/dist/tools/translation/basicDetokenizer.js +80 -0
  66. package/dist/tools/translation/basicTokenizer.js +8 -3
  67. package/dist/tools/translation/disassembler.js +14 -0
  68. package/dist/tools/types.js +258 -7
  69. package/dist/tools/vice.js +5 -16
  70. package/dist/vicBitmap.js +1 -1
  71. package/dist/vice/mockServer.js +3 -0
  72. package/dist/vice/process.js +19 -0
  73. package/dist/vice/resourcePolicy.js +36 -0
  74. package/dist/vice/viceClient.js +22 -5
  75. package/doc/vice/support-matrix.md +17 -11
  76. package/mcp/schemas/c64_batch.schema.json +2 -1
  77. package/mcp/schemas/c64_config.schema.json +25 -475
  78. package/mcp/schemas/c64_debug.schema.json +3 -602
  79. package/mcp/schemas/c64_disk.schema.json +17 -344
  80. package/mcp/schemas/c64_drive.schema.json +2 -157
  81. package/mcp/schemas/c64_extract.schema.json +10 -364
  82. package/mcp/schemas/c64_graphics.schema.json +19 -426
  83. package/mcp/schemas/c64_input.schema.json +5 -318
  84. package/mcp/schemas/c64_memory.schema.json +23 -491
  85. package/mcp/schemas/c64_printer.schema.json +3 -258
  86. package/mcp/schemas/c64_program.schema.json +16 -491
  87. package/mcp/schemas/c64_rag.schema.json +1 -81
  88. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  89. package/mcp/schemas/c64_sound.schema.json +20 -822
  90. package/mcp/schemas/c64_stream.schema.json +4 -75
  91. package/mcp/schemas/c64_system.schema.json +28 -348
  92. package/mcp/schemas/c64_vice.schema.json +2 -71
  93. package/mcp/server.json +1 -1
  94. package/mcp/tools.json +1983 -6910
  95. package/mcp.json +21 -1
  96. package/package.json +16 -8
  97. package/scripts/mockC64Server.mjs +44 -0
  98. package/scripts/run-coverage.mjs +27 -1
  99. package/scripts/smoke-run.sh +3 -2
  100. package/scripts/start.mjs +23 -1
  101. package/server.json +35 -2
  102. package/dist/.tsbuildinfo +0 -1
  103. package/doc/agent-state-spec.md +0 -243
  104. package/doc/audit/secret-handling.md +0 -61
  105. package/doc/plans/bun/baseline-metrics.md +0 -16
  106. package/doc/plans/bun/migration-checklist.md +0 -55
  107. package/doc/plans/bun/migration.md +0 -83
  108. package/doc/plans/improve-knowledge/notes.md +0 -43
  109. package/doc/plans/improve-knowledge/plan.md +0 -121
  110. package/doc/plans/improve-knowledge/rollout.md +0 -110
  111. package/doc/plans/meta-tools/README.md +0 -110
  112. package/doc/plans/meta-tools/analysis.md +0 -272
  113. package/doc/plans/meta-tools/catalog.md +0 -435
  114. package/doc/plans/meta-tools/notes.md +0 -192
  115. package/doc/plans/meta-tools/rollout.md +0 -368
  116. package/doc/plans/prompts/README.md +0 -96
  117. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  118. package/doc/plans/tool-consolidation/notes.md +0 -28
  119. package/doc/plans/tool-consolidation/plan.md +0 -169
  120. package/doc/plans/tool-consolidation/rollout.md +0 -104
  121. package/doc/plans/vice-backend-switch/plan.md +0 -486
  122. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  123. package/doc/plans/vice-support/notes.md +0 -63
  124. package/doc/plans/vice-support/plan.md +0 -88
  125. package/doc/plans/vice-support/rollout.md +0 -99
  126. package/doc/reviews/audit.md +0 -143
  127. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  128. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  129. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  130. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  131. package/doc/reviews/vice-support-review.md +0 -185
  132. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
package/dist/config.js CHANGED
@@ -24,6 +24,12 @@ export function loadConfig() {
24
24
  }
25
25
  catch (error) {
26
26
  if (error.code !== "ENOENT") {
27
+ if (candidatePath === configPath) {
28
+ // An explicitly named file that cannot be used must not silently
29
+ // hand control to another candidate, which may be another machine.
30
+ // The parser message is omitted because it can quote file content.
31
+ throw new Error(`C64BRIDGE_CONFIG file '${candidatePath}' cannot be parsed (${error instanceof Error ? error.name : "unreadable"}); fix or remove it`);
32
+ }
27
33
  // Deliberately omit file contents/credentials. Configuration mistakes
28
34
  // should not prevent the MCP server from exposing a recoverable path.
29
35
  console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.name : "unreadable"}`);
@@ -86,14 +92,16 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
86
92
  const envHost = profile === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST;
87
93
  const envPort = profile === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT;
88
94
  const envPassword = profile === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD;
89
- const parsedSectionHost = parseEndpoint(configuredString(section?.host));
90
- const parsedSectionHostname = parseEndpoint(configuredString(section?.hostname));
91
- const parsedLegacyHost = parseEndpoint(configuredString(legacy?.c64_host));
92
- const parsedLegacyIp = parseEndpoint(configuredString(legacy?.c64_ip));
93
- const parsedEnvHost = parseEndpoint(configuredString(envHost));
95
+ const envHostName = profile === "u2" && process.env.U2_HOST !== undefined ? "U2_HOST" : "C64U_HOST";
96
+ const envPortName = profile === "u2" && process.env.U2_PORT !== undefined ? "U2_PORT" : "C64U_PORT";
97
+ const parsedSectionHost = parseEndpoint(configuredString(section?.host), `${profile}.host`);
98
+ const parsedSectionHostname = parseEndpoint(configuredString(section?.hostname), `${profile}.hostname`);
99
+ const parsedLegacyHost = parseEndpoint(configuredString(legacy?.c64_host), "c64_host");
100
+ const parsedLegacyIp = parseEndpoint(configuredString(legacy?.c64_ip), "c64_ip");
101
+ const parsedEnvHost = parseEndpoint(configuredString(envHost), envHostName);
94
102
  const parsedBaseOverrides = [
95
- parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl))),
96
- parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl))),
103
+ parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl)), `${profile}.baseUrl`),
104
+ parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl)), "baseUrl"),
97
105
  ];
98
106
  const hostCandidates = [
99
107
  parsedEnvHost.hostname,
@@ -104,12 +112,12 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
104
112
  ...parsedBaseOverrides.map((entry) => entry.hostname),
105
113
  ];
106
114
  const portCandidates = [
107
- configuredPort(envPort),
115
+ configuredPort(envPort, envPortName),
108
116
  parsedEnvHost.port,
109
- configuredPort(section?.port),
117
+ configuredPort(section?.port, `${profile}.port`),
110
118
  parsedSectionHost.port,
111
119
  parsedSectionHostname.port,
112
- configuredPort(legacy?.c64_port),
120
+ configuredPort(legacy?.c64_port, "c64_port"),
113
121
  parsedLegacyHost.port,
114
122
  parsedLegacyIp.port,
115
123
  ...parsedBaseOverrides.map((entry) => entry.port),
@@ -149,17 +157,23 @@ function nonEmptyString(value) {
149
157
  const trimmed = configuredString(value);
150
158
  return trimmed ? trimmed : undefined;
151
159
  }
152
- function configuredPort(value) {
153
- if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
154
- return value;
160
+ /** An absent or empty value is "not set"; any other value must be a valid port. */
161
+ function configuredPort(value, source) {
162
+ if (value === undefined || value === null)
163
+ return undefined;
164
+ if (typeof value === "string" && !value.trim())
165
+ return undefined;
166
+ let parsed;
167
+ if (typeof value === "number") {
168
+ parsed = value;
155
169
  }
156
- if (typeof value === "string" && value.trim()) {
157
- const parsed = Number(value.trim());
158
- if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
159
- return parsed;
160
- }
170
+ else if (typeof value === "string" && /^\d+$/.test(value.trim())) {
171
+ parsed = Number(value.trim());
161
172
  }
162
- return undefined;
173
+ if (parsed !== undefined && Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
174
+ return parsed;
175
+ }
176
+ throw new Error(`Invalid port ${JSON.stringify(typeof value === "string" ? value.trim() : value)} in ${source}: expected an integer from 1 to 65535`);
163
177
  }
164
178
  function configuredBoolean(value) {
165
179
  if (typeof value === "boolean") {
@@ -189,23 +203,31 @@ function normaliseBaseUrl(value) {
189
203
  }
190
204
  return stripTrailingSlash(input);
191
205
  }
192
- function parseEndpoint(value) {
206
+ function parseEndpoint(value, source) {
193
207
  const input = configuredString(value);
194
208
  if (!input)
195
209
  return {};
210
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input);
211
+ // A bare IPv6 address (two or more colons, no brackets) is not a valid URL
212
+ // authority until it is bracketed.
213
+ const bareIpv6 = !hasScheme && !input.startsWith("[") && (input.match(/:/g)?.length ?? 0) >= 2 && !input.includes("/");
214
+ let url;
196
215
  try {
197
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input);
198
- const url = new URL(hasScheme ? input : `http://${input}`);
216
+ url = new URL(hasScheme ? input : `http://${bareIpv6 ? `[${input}]` : input}`);
217
+ }
218
+ catch {
219
+ // Drop any user:password@ prefix before echoing the value.
220
+ const shown = input.replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^@/]*@/i, "$1");
221
+ throw new Error(`Invalid host ${JSON.stringify(shown)} in ${source}: expected a host name or IP address, optionally with a port`);
222
+ }
223
+ {
199
224
  const hostname = url.hostname || undefined;
200
- const port = url.port ? configuredPort(url.port) : undefined;
225
+ const port = url.port ? configuredPort(url.port, source) : undefined;
201
226
  const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
202
227
  // Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
203
228
  const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
204
229
  return { hostname, port, baseUrl, fullUrl };
205
230
  }
206
- catch {
207
- return {};
208
- }
209
231
  }
210
232
  function stripTrailingSlash(input) {
211
233
  return input.replace(/\/+$/, "");
package/dist/device.js CHANGED
@@ -4,14 +4,25 @@
4
4
  import axios from "axios";
5
5
  import { Buffer } from "node:buffer";
6
6
  import fs from "node:fs";
7
+ import { resolveHostPath, DISK_IMAGE_MAX_READ_BYTES } from "./hostPaths.js";
7
8
  import path from "node:path";
8
9
  import { Api } from "../generated/c64/index.js";
9
- import { createLoggingHttpClient } from "./loggingHttpClient.js";
10
+ import { createLoggingHttpClient, RequestQueue } from "./loggingHttpClient.js";
10
11
  import { ViceClient } from "./vice/viceClient.js";
12
+ import { resourceNameProblem } from "./vice/resourcePolicy.js";
11
13
  import { waitForBasicReady } from "./vice/readiness.js";
12
14
  import { screenCodesToAscii } from "./petscii.js";
13
15
  import { startViceProcess } from "./vice/process.js";
14
16
  import { loadConfig, resolveUltimateEndpoint } from "./config.js";
17
+ let signalDelegate = false;
18
+ /** Stops every VICE process started by this server, synchronously. */
19
+ export function stopManagedViceProcessesSync() {
20
+ ViceBackend.stopAllSupervisorsSync();
21
+ }
22
+ /** Marks that the caller owns signal handling and process exit (see mcp-server shutdown). */
23
+ export function delegateViceSignalHandling() {
24
+ signalDelegate = true;
25
+ }
15
26
  const DEFAULT_VICE_HOST = "127.0.0.1";
16
27
  const DEFAULT_VICE_PORT = 6502;
17
28
  function delay(ms) {
@@ -56,9 +67,14 @@ function coalesceMemoryWriteBlocks(blocks) {
56
67
  function readConfigFile() {
57
68
  const config = loadConfig();
58
69
  const sections = config.backendConfig;
70
+ // An endpoint environment variable selects that backend explicitly, like a
71
+ // config section does, so an offline device is reported as unreachable
72
+ // instead of silently replaced by an emulator.
73
+ const envSelects = (prefix) => ["HOST", "PORT", "PASSWORD"]
74
+ .some((suffix) => (process.env[`${prefix}${suffix}`] ?? "").trim() !== "");
59
75
  return {
60
- c64u: sections.c64u || config.legacyUltimateEndpoint ? config.ultimate.c64u : undefined,
61
- u2: sections.u2 ? config.ultimate.u2 : undefined,
76
+ c64u: sections.c64u || config.legacyUltimateEndpoint || envSelects("C64U_") ? config.ultimate.c64u : undefined,
77
+ u2: sections.u2 || envSelects("U2_") ? config.ultimate.u2 : undefined,
62
78
  vice: sections.vice,
63
79
  endpoints: config.ultimate,
64
80
  };
@@ -82,16 +98,53 @@ function driveImage(info) {
82
98
  function uploadFile(bytes, name) {
83
99
  return new File([Uint8Array.from(bytes)], name, { type: "application/octet-stream" });
84
100
  }
101
+ /**
102
+ * Reachability probe for an Ultimate REST endpoint: GET <baseUrl>/v1/version,
103
+ * keeping any reverse-proxy path prefix. Any HTTP response, including 403 or
104
+ * 404, shows that a server answers at the address; only a transport failure
105
+ * (refused, unreachable, timeout) counts as unreachable.
106
+ */
107
+ export async function probeUltimateReachable(baseUrl, timeout, networkPassword) {
108
+ try {
109
+ await axios.get(`${baseUrl.replace(/\/+$/, "")}/v1/version`, {
110
+ timeout,
111
+ headers: buildC64uHeaders(networkPassword),
112
+ validateStatus: () => true,
113
+ });
114
+ return true;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /**
121
+ * Resources that the VICE config inventory (configsList) advertises and that
122
+ * snapshot/restore round-trip. They are vetted here and are not host file
123
+ * names, so c64_config accepts them in addition to the shared c64_vice policy.
124
+ */
125
+ const VICE_CONFIG_INVENTORY = [
126
+ "SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
127
+ "VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
128
+ ];
129
+ /** c64_config on VICE follows the same resource policy as c64_vice. */
130
+ function assertAllowedViceResource(name) {
131
+ if (VICE_CONFIG_INVENTORY.includes(name))
132
+ return;
133
+ const problem = resourceNameProblem(name);
134
+ if (problem)
135
+ throw new Error(`VICE resource '${name}' is not allowed: ${problem.message}`);
136
+ }
85
137
  class C64uBackend {
86
138
  constructor(endpoint, type = "c64u") {
87
139
  this.type = type;
88
140
  this.baseUrl = endpoint.baseUrl;
141
+ this.requestQueue = RequestQueue.forDevice(this.baseUrl);
89
142
  this.networkPassword = endpoint.networkPassword;
90
143
  const http = createLoggingHttpClient({
91
144
  baseURL: this.baseUrl,
92
145
  timeout: 10000,
93
146
  headers: buildC64uHeaders(this.networkPassword),
94
- });
147
+ }, undefined, this.requestQueue);
95
148
  this.api = new Api(http);
96
149
  }
97
150
  requireC64u(feature) {
@@ -109,16 +162,7 @@ class C64uBackend {
109
162
  : { success: true, details: data };
110
163
  }
111
164
  async ping() {
112
- try {
113
- const res = await axios.get(this.baseUrl, {
114
- timeout: 2000,
115
- headers: buildC64uHeaders(this.networkPassword),
116
- });
117
- return res.status >= 200 && res.status < 500;
118
- }
119
- catch {
120
- return false;
121
- }
165
+ return this.requestQueue.run(() => probeUltimateReachable(this.baseUrl, 2000, this.networkPassword));
122
166
  }
123
167
  async runPrg(prg) {
124
168
  const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
@@ -286,9 +330,9 @@ class C64uBackend {
286
330
  async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(encodeURIComponent(cat), encodeURIComponent(item)) : await this.api.v1.configsDetail(encodeURIComponent(cat)); return res.data; }
287
331
  async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(encodeURIComponent(cat), encodeURIComponent(item), { value }); return this.actionResult(res.data); }
288
332
  async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
289
- async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
290
- async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
291
- async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return this.actionResult(res.data); }
333
+ async configLoadFromFlash(category) { const res = await this.api.v1.configsLoadFromFlashUpdate(configActionRoute(":load_from_flash", category)); return this.actionResult(res.data); }
334
+ async configSaveToFlash(category) { const res = await this.api.v1.configsSaveToFlashUpdate(configActionRoute(":save_to_flash", category)); return this.actionResult(res.data); }
335
+ async configResetToDefault(category) { const res = await this.api.v1.configsResetToDefaultUpdate(configActionRoute(":reset_to_default", category)); return this.actionResult(res.data); }
292
336
  async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeDevicePath(p), ":info"); return res.data; }
293
337
  async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeDevicePath(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
294
338
  async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeDevicePath(p), ":create_d71", { diskname: options?.diskname }); return this.actionResult(res.data); }
@@ -297,6 +341,12 @@ class C64uBackend {
297
341
  async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return this.actionResult(res.data); }
298
342
  }
299
343
  export class ViceBackend {
344
+ static stopAllSupervisorsSync() {
345
+ for (const [, handle] of ViceBackend.supervisors) {
346
+ handle.stopSync();
347
+ }
348
+ ViceBackend.supervisors.clear();
349
+ }
300
350
  constructor(config) {
301
351
  this.type = "vice";
302
352
  this.debugEnabled = process.env.VICE_DEVICE_TEST_DEBUG === "1";
@@ -465,15 +515,16 @@ export class ViceBackend {
465
515
  return;
466
516
  }
467
517
  ViceBackend.cleanupRegistered = true;
468
- const cleanupSync = () => {
469
- for (const [, handle] of ViceBackend.supervisors) {
470
- handle.stopSync();
471
- }
472
- ViceBackend.supervisors.clear();
473
- };
518
+ const cleanupSync = stopManagedViceProcessesSync;
474
519
  process.once("exit", cleanupSync);
475
520
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
476
521
  process.once(signal, () => {
522
+ // When the MCP server installed its shutdown handler, it performs the
523
+ // async cleanup, calls stopManagedViceProcessesSync and exits itself;
524
+ // exactly one handler per signal may call process.exit.
525
+ if (signalDelegate) {
526
+ return;
527
+ }
477
528
  cleanupSync();
478
529
  process.exit(signal === "SIGINT" ? 130 : 143);
479
530
  });
@@ -586,9 +637,12 @@ export class ViceBackend {
586
637
  await this.loadPrgAndFinish(buffer, async () => { });
587
638
  return { success: true };
588
639
  }
589
- async loadPrgFile(_path) { throw unsupported("loadPrgFile"); }
640
+ async loadPrgFile(prgPath) {
641
+ const data = fs.readFileSync(resolveHostPath(prgPath, { purpose: "read" }));
642
+ return this.loadPrg(data);
643
+ }
590
644
  async runPrgFile(prgPath) {
591
- const data = fs.readFileSync(prgPath);
645
+ const data = fs.readFileSync(resolveHostPath(prgPath, { purpose: "read" }));
592
646
  await this.injectPrg(data);
593
647
  return { success: true };
594
648
  }
@@ -785,6 +839,7 @@ export class ViceBackend {
785
839
  if (!VICE_DISK_IMAGE_EXTENSIONS.has(path.extname(imagePath).toLowerCase())) {
786
840
  throw new Error(`VICE can mount only disk images (${[...VICE_DISK_IMAGE_EXTENSIONS].join(", ")}): ${imagePath}`);
787
841
  }
842
+ imagePath = resolveHostPath(imagePath, { purpose: "read", maxBytes: DISK_IMAGE_MAX_READ_BYTES });
788
843
  if (!fs.existsSync(imagePath)) {
789
844
  throw new Error(`Disk image not found on the VICE host: ${imagePath}`);
790
845
  }
@@ -871,10 +926,7 @@ export class ViceBackend {
871
926
  name: "VICE",
872
927
  // Only resources that exist in VICE 3.10; checked against a running x64sc.
873
928
  // VICE has no warp, drive-enable or attached-image resources.
874
- items: [
875
- "SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
876
- "VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
877
- ],
929
+ items: [...VICE_CONFIG_INVENTORY],
878
930
  },
879
931
  ],
880
932
  };
@@ -892,12 +944,14 @@ export class ViceBackend {
892
944
  return values;
893
945
  });
894
946
  }
947
+ assertAllowedViceResource(item);
895
948
  return await this.withClient(async (client) => {
896
949
  const res = await client.resourceGet(item);
897
950
  return { category: _category, item, value: res.value, type: res.type };
898
951
  });
899
952
  }
900
953
  async configSet(_category, item, value) {
954
+ assertAllowedViceResource(item);
901
955
  let parsed = value;
902
956
  await this.withClient(async (client) => {
903
957
  const current = await client.resourceGet(item);
@@ -912,6 +966,7 @@ export class ViceBackend {
912
966
  for (const [category, items] of Object.entries(payload)) {
913
967
  for (const [item, value] of Object.entries(items)) {
914
968
  try {
969
+ assertAllowedViceResource(item);
915
970
  const current = await client.resourceGet(item);
916
971
  const parsed = parseViceConfigValue(value, current.type, item);
917
972
  await client.resourceSet(item, parsed);
@@ -950,6 +1005,10 @@ function parseViceConfigValue(value, type, item) {
950
1005
  return parsed;
951
1006
  }
952
1007
  function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend`); err.code = "UNSUPPORTED"; return err; }
1008
+ /** Whole-device route suffix, or `/{category}:action` for one category (the generated client prefixes `/v1/configs`). */
1009
+ function configActionRoute(action, category) {
1010
+ return category === undefined ? action : `/${encodeURIComponent(category)}${action}`;
1011
+ }
953
1012
  /** Ultimate file APIs treat slash as a path separator. Encode each name once,
954
1013
  * preserving leading and nested separators for firmware routing. */
955
1014
  function encodeDevicePath(value) {
@@ -1189,15 +1248,11 @@ export async function createFacade(logger, options) {
1189
1248
  }
1190
1249
  // No configuration
1191
1250
  const probeBase = cfg.endpoints.c64u.baseUrl;
1192
- try {
1193
- const res = await axios.get(probeBase, { timeout: 1500 });
1194
- if (res.status >= 200 && res.status < 500) {
1195
- const backend = new C64uBackend(cfg.endpoints.c64u);
1196
- logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
1197
- return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
1198
- }
1251
+ if (await probeUltimateReachable(probeBase, 1500, cfg.endpoints.c64u.networkPassword)) {
1252
+ const backend = new C64uBackend(cfg.endpoints.c64u);
1253
+ logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
1254
+ return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
1199
1255
  }
1200
- catch { }
1201
1256
  const backend = new ViceBackend(cfg.vice ?? {});
1202
1257
  logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
1203
1258
  const endpoint = backend.getEndpoint();
@@ -36,7 +36,7 @@ export function writeDiagnosticEvent(event, details) {
36
36
  details: sanitize(details),
37
37
  };
38
38
  try {
39
- fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, "utf8");
39
+ fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
40
40
  }
41
41
  catch (error) {
42
42
  safeStderrWrite(`[diagnostics] failed to append event ${event}: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -250,13 +250,73 @@ function ensureState(component) {
250
250
  }
251
251
  function createSessionInfo(component) {
252
252
  const directory = resolveDiagnosticsDirectory();
253
- fs.mkdirSync(directory, { recursive: true });
253
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
254
254
  const startedAt = new Date().toISOString().replace(/[:.]/g, "-");
255
255
  const safeComponent = component.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "runtime";
256
256
  const sessionId = `${startedAt}-${process.pid}`;
257
257
  const filePath = path.join(directory, `${sessionId}-${safeComponent}.ndjson`);
258
+ pruneDiagnosticsDirectory(directory, { keep: resolveDiagnosticsKeep(), currentFile: filePath });
258
259
  return { component: safeComponent, directory, filePath, sessionId };
259
260
  }
261
+ const DEFAULT_DIAGNOSTICS_KEEP = 200;
262
+ const DIAGNOSTICS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
263
+ const DIAGNOSTICS_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-[a-z0-9_-]+\.ndjson$/i;
264
+ /** Reset the cached session so a test can start a new one. Not used by the server. */
265
+ export function resetDiagnosticsStateForTesting() {
266
+ state = null;
267
+ }
268
+ function resolveDiagnosticsKeep() {
269
+ const raw = process.env.C64BRIDGE_DIAGNOSTICS_KEEP?.trim();
270
+ if (raw === undefined || raw === "") {
271
+ return DEFAULT_DIAGNOSTICS_KEEP;
272
+ }
273
+ if (!/^\d+$/.test(raw)) {
274
+ safeStderrWrite(`[diagnostics] ignoring invalid C64BRIDGE_DIAGNOSTICS_KEEP="${raw}"; using ${DEFAULT_DIAGNOSTICS_KEEP}\n`);
275
+ return DEFAULT_DIAGNOSTICS_KEEP;
276
+ }
277
+ return Number(raw);
278
+ }
279
+ /**
280
+ * Best-effort retention: delete regular diagnostics files (by naming pattern) beyond the newest `keep`
281
+ * or older than 30 days. Uses lstat so symlinks are never followed; never recurses.
282
+ */
283
+ export function pruneDiagnosticsDirectory(directory, options) {
284
+ try {
285
+ const now = options.now ?? Date.now();
286
+ const candidates = [];
287
+ for (const name of fs.readdirSync(directory)) {
288
+ if (!DIAGNOSTICS_FILE_PATTERN.test(name))
289
+ continue;
290
+ const file = path.join(directory, name);
291
+ if (options.currentFile && path.resolve(file) === path.resolve(options.currentFile))
292
+ continue;
293
+ try {
294
+ const stat = fs.lstatSync(file);
295
+ if (stat.isFile())
296
+ candidates.push({ file, mtimeMs: stat.mtimeMs });
297
+ }
298
+ catch {
299
+ // ignore entries that vanish or cannot be inspected
300
+ }
301
+ }
302
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.file < b.file ? 1 : -1));
303
+ // The current session's file counts toward the kept total.
304
+ const keepOthers = Math.max(0, options.keep - (options.currentFile ? 1 : 0));
305
+ candidates.forEach((entry, index) => {
306
+ if (index >= keepOthers || now - entry.mtimeMs > DIAGNOSTICS_MAX_AGE_MS) {
307
+ try {
308
+ fs.unlinkSync(entry.file);
309
+ }
310
+ catch {
311
+ // best effort
312
+ }
313
+ }
314
+ });
315
+ }
316
+ catch {
317
+ // best effort: an unreadable directory must not stop diagnostics
318
+ }
319
+ }
260
320
  function resolveDiagnosticsDirectory() {
261
321
  const configured = process.env.C64BRIDGE_DIAGNOSTICS_DIR?.trim();
262
322
  if (configured) {
@@ -0,0 +1,128 @@
1
+ // Confinement of host file paths that originate from tool arguments.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { ToolValidationError } from "./tools/errors.js";
6
+ export const DEFAULT_MAX_READ_BYTES = 16 * 1024 * 1024;
7
+ export const DISK_IMAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
8
+ export const ALLOWED_DIRS_ENV = "C64BRIDGE_ALLOWED_DIRS";
9
+ /** True when candidate equals root or lies inside it. Both must be absolute. */
10
+ export function isWithin(root, candidate) {
11
+ const rel = path.relative(root, candidate);
12
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
13
+ }
14
+ /** Real path of the deepest existing ancestor, with the nonexistent remainder appended. */
15
+ function realpathOfDeepest(target) {
16
+ const pending = [];
17
+ let current = target;
18
+ for (;;) {
19
+ try {
20
+ return path.join(fs.realpathSync(current), ...pending.reverse());
21
+ }
22
+ catch (error) {
23
+ const code = error.code;
24
+ if (code !== "ENOENT" && code !== "ENOTDIR")
25
+ throw error;
26
+ let isLink = false;
27
+ try {
28
+ isLink = fs.lstatSync(current).isSymbolicLink();
29
+ }
30
+ catch { /* does not exist */ }
31
+ // A dangling symlink would be followed by a later write, so its target cannot be vetted.
32
+ if (isLink)
33
+ throw new ToolValidationError(`Host path is a dangling symbolic link: ${current}`, { path: current });
34
+ const parent = path.dirname(current);
35
+ if (parent === current)
36
+ return path.join(current, ...pending.reverse());
37
+ pending.push(path.basename(current));
38
+ current = parent;
39
+ }
40
+ }
41
+ }
42
+ function safeRealpath(p) {
43
+ try {
44
+ return realpathOfDeepest(path.resolve(p));
45
+ }
46
+ catch {
47
+ return path.resolve(p);
48
+ }
49
+ }
50
+ function homeDirectory() {
51
+ return process.env.HOME || os.homedir();
52
+ }
53
+ /** Allowed roots (real paths). Reads cwd, environment and home directory at call time. */
54
+ export function allowedHostRoots() {
55
+ const roots = [];
56
+ const cwd = safeRealpath(process.cwd());
57
+ const home = safeRealpath(homeDirectory());
58
+ if (cwd !== home && cwd !== path.parse(cwd).root)
59
+ roots.push(cwd);
60
+ roots.push(safeRealpath(path.join(homeDirectory(), ".c64bridge")));
61
+ roots.push(safeRealpath(os.tmpdir()));
62
+ const extra = process.env[ALLOWED_DIRS_ENV];
63
+ if (extra) {
64
+ for (const entry of extra.split(path.delimiter)) {
65
+ const trimmed = entry.trim();
66
+ if (trimmed && trimmed !== "*")
67
+ roots.push(safeRealpath(trimmed));
68
+ }
69
+ }
70
+ return [...new Set(roots)];
71
+ }
72
+ /**
73
+ * Resolve a file name that the server appends to an already resolved directory.
74
+ * The final path is checked again: a symbolic link planted at that name in a
75
+ * shared directory (for example the temp directory) must not redirect the write
76
+ * outside the allowed roots.
77
+ */
78
+ export function resolveHostChildPath(dir, ...names) {
79
+ return resolveHostPath(path.join(dir, ...names), { purpose: "write" });
80
+ }
81
+ /** Default output directory under the working directory, or under ~/.c64bridge when the working directory is not an allowed root. */
82
+ export function defaultHostOutputDir(...names) {
83
+ try {
84
+ return resolveHostPath(path.join(process.cwd(), ...names), { purpose: "write" });
85
+ }
86
+ catch (error) {
87
+ if (!(error instanceof ToolValidationError))
88
+ throw error;
89
+ return resolveHostPath(path.join(homeDirectory(), ".c64bridge", ...names), { purpose: "write" });
90
+ }
91
+ }
92
+ function confinementDisabled() {
93
+ const extra = process.env[ALLOWED_DIRS_ENV];
94
+ return !!extra && extra.trim() === "*";
95
+ }
96
+ /**
97
+ * Resolve a tool-supplied path to an absolute real path inside the allowed roots.
98
+ * Throws ToolValidationError for paths outside the roots, dangling symlinks and oversized reads.
99
+ */
100
+ export function resolveHostPath(input, options) {
101
+ if (typeof input !== "string" || input.length === 0 || input.includes("\0")) {
102
+ throw new ToolValidationError("Host path must be a non-empty string without NUL bytes.");
103
+ }
104
+ const resolved = path.resolve(input);
105
+ const disabled = !options.roots && confinementDisabled();
106
+ const real = disabled ? resolved : realpathOfDeepest(resolved);
107
+ if (!disabled) {
108
+ const roots = options.roots ? options.roots.map(safeRealpath) : allowedHostRoots();
109
+ if (!roots.some((root) => isWithin(root, real))) {
110
+ throw new ToolValidationError(`Host path is outside the allowed directories: ${resolved}. Allowed roots: ${roots.join(", ")}. `
111
+ + `Add directories with the ${ALLOWED_DIRS_ENV} environment variable (entries separated by "${path.delimiter}"; "*" disables confinement).`, { path: resolved, details: { allowedRoots: roots, env: ALLOWED_DIRS_ENV } });
112
+ }
113
+ }
114
+ if (options.purpose === "read") {
115
+ const limit = options.maxBytes ?? DEFAULT_MAX_READ_BYTES;
116
+ let size;
117
+ try {
118
+ size = fs.statSync(real).size;
119
+ }
120
+ catch {
121
+ size = undefined;
122
+ }
123
+ if (size !== undefined && size > limit) {
124
+ throw new ToolValidationError(`Host file is too large to read: ${resolved} is ${size} bytes, limit is ${limit} bytes.`, { path: resolved });
125
+ }
126
+ }
127
+ return real;
128
+ }
@@ -13,7 +13,53 @@ function redactHeaders(headers) {
13
13
  sensitive.test(key) ? "***" : value,
14
14
  ]));
15
15
  }
16
- export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
16
+ /**
17
+ * First-in first-out gate that lets one operation run at a time. The Ultimate
18
+ * firmware serves REST requests on a single thread, so every request to one
19
+ * device goes through one queue. A failed operation releases the gate like a
20
+ * successful one, so the queue cannot deadlock.
21
+ */
22
+ export class RequestQueue {
23
+ constructor() {
24
+ this.tail = Promise.resolve();
25
+ }
26
+ /** The shared queue for one device base URL, so every client of that device (backends, fallback clients) waits in the same line. */
27
+ static forDevice(baseUrl) {
28
+ const key = baseUrl.replace(/\/+$/, "").toLowerCase();
29
+ let queue = RequestQueue.byDevice.get(key);
30
+ if (!queue) {
31
+ queue = new RequestQueue();
32
+ RequestQueue.byDevice.set(key, queue);
33
+ }
34
+ return queue;
35
+ }
36
+ /** Resolves when all earlier holders have released; call the returned function exactly once. */
37
+ async acquire() {
38
+ let release;
39
+ const held = new Promise((resolve) => { release = resolve; });
40
+ const previous = this.tail;
41
+ this.tail = previous.then(() => held);
42
+ await previous;
43
+ let released = false;
44
+ return () => {
45
+ if (!released) {
46
+ released = true;
47
+ release();
48
+ }
49
+ };
50
+ }
51
+ async run(operation) {
52
+ const release = await this.acquire();
53
+ try {
54
+ return await operation();
55
+ }
56
+ finally {
57
+ release();
58
+ }
59
+ }
60
+ }
61
+ RequestQueue.byDevice = new Map();
62
+ export function createLoggingHttpClient(config, logger = loggerFor("c64u"), queue) {
17
63
  const http = new HttpClient(config);
18
64
  registerSecret(readPasswordHeader(config?.headers));
19
65
  http.instance.interceptors.request.use((request) => {
@@ -30,6 +76,18 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
30
76
  request.__c64Meta = meta;
31
77
  return request;
32
78
  });
79
+ if (queue) {
80
+ // The generated client sends every request through `instance.request`, so
81
+ // the gate is taken around that call. Interceptors, request transforms and
82
+ // the adapter all run inside it: the start time is recorded after the wait,
83
+ // the request timeout (started by the adapter) does not include the wait,
84
+ // and the gate is released however the request settles, including a
85
+ // failure before the adapter is called (for example a body that cannot be
86
+ // serialised).
87
+ const instance = http.instance;
88
+ const sendRequest = instance.request.bind(instance);
89
+ instance.request = ((requestConfig) => queue.run(() => sendRequest(requestConfig)));
90
+ }
33
91
  http.instance.interceptors.response.use((response) => {
34
92
  handleResponse(response, logger);
35
93
  return response;