c64bridge 1.0.6 → 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 (136) 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 +70 -15
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/chargen.js +0 -4
  19. package/dist/config.js +53 -28
  20. package/dist/device.js +92 -37
  21. package/dist/diagnostics.js +66 -5
  22. package/dist/hostPaths.js +128 -0
  23. package/dist/logger.js +3 -2
  24. package/dist/loggingHttpClient.js +71 -2
  25. package/dist/mcp-server.js +416 -291
  26. package/dist/numericArg.js +55 -0
  27. package/dist/petscii.js +13 -6
  28. package/dist/platform.js +12 -3
  29. package/dist/prompts/registry.js +6 -1
  30. package/dist/redact.js +166 -0
  31. package/dist/tools/access.js +66 -0
  32. package/dist/tools/debug.js +6 -0
  33. package/dist/tools/developer.js +77 -36
  34. package/dist/tools/errors.js +54 -3
  35. package/dist/tools/input.js +25 -8
  36. package/dist/tools/machineControl.js +113 -21
  37. package/dist/tools/memory.js +135 -30
  38. package/dist/tools/meta/artifacts.js +5 -5
  39. package/dist/tools/meta/background.js +216 -54
  40. package/dist/tools/meta/compilation.js +6 -5
  41. package/dist/tools/meta/config.js +20 -10
  42. package/dist/tools/meta/configInventory.js +25 -2
  43. package/dist/tools/meta/diagnostics.js +2 -1
  44. package/dist/tools/meta/filesystem.js +16 -14
  45. package/dist/tools/meta/graphics.js +48 -20
  46. package/dist/tools/meta/memory.js +45 -19
  47. package/dist/tools/meta/program.js +10 -7
  48. package/dist/tools/meta/screen.js +116 -25
  49. package/dist/tools/pauseGuard.js +23 -0
  50. package/dist/tools/pollValidator.js +53 -8
  51. package/dist/tools/programRunners.js +62 -18
  52. package/dist/tools/registry/batch.js +17 -11
  53. package/dist/tools/registry/config.js +9 -2
  54. package/dist/tools/registry/disk.js +12 -2
  55. package/dist/tools/registry/drive.js +3 -0
  56. package/dist/tools/registry/extract.js +3 -0
  57. package/dist/tools/registry/graphics.js +59 -6
  58. package/dist/tools/registry/index.js +17 -1
  59. package/dist/tools/registry/memory.js +11 -1
  60. package/dist/tools/registry/platform.js +4 -1
  61. package/dist/tools/registry/printer.js +2 -1
  62. package/dist/tools/registry/program.js +5 -1
  63. package/dist/tools/registry/rag.js +1 -0
  64. package/dist/tools/registry/sound.js +12 -1
  65. package/dist/tools/registry/stream.js +2 -1
  66. package/dist/tools/registry/system.js +8 -1
  67. package/dist/tools/schema.js +7 -7
  68. package/dist/tools/streaming.js +34 -4
  69. package/dist/tools/translation/assembler.js +247 -24
  70. package/dist/tools/translation/basicDetokenizer.js +80 -0
  71. package/dist/tools/translation/basicTokenizer.js +8 -3
  72. package/dist/tools/translation/disassembler.js +14 -0
  73. package/dist/tools/types.js +258 -7
  74. package/dist/tools/vice.js +5 -16
  75. package/dist/vicBitmap.js +1 -1
  76. package/dist/vice/mockServer.js +3 -0
  77. package/dist/vice/process.js +19 -0
  78. package/dist/vice/resourcePolicy.js +36 -0
  79. package/dist/vice/viceClient.js +22 -5
  80. package/doc/vice/support-matrix.md +17 -11
  81. package/mcp/schemas/c64_batch.schema.json +2 -1
  82. package/mcp/schemas/c64_config.schema.json +33 -466
  83. package/mcp/schemas/c64_debug.schema.json +3 -602
  84. package/mcp/schemas/c64_disk.schema.json +17 -344
  85. package/mcp/schemas/c64_drive.schema.json +2 -157
  86. package/mcp/schemas/c64_extract.schema.json +10 -364
  87. package/mcp/schemas/c64_graphics.schema.json +19 -426
  88. package/mcp/schemas/c64_input.schema.json +5 -318
  89. package/mcp/schemas/c64_memory.schema.json +23 -491
  90. package/mcp/schemas/c64_printer.schema.json +3 -258
  91. package/mcp/schemas/c64_program.schema.json +16 -491
  92. package/mcp/schemas/c64_rag.schema.json +1 -81
  93. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  94. package/mcp/schemas/c64_sound.schema.json +20 -822
  95. package/mcp/schemas/c64_stream.schema.json +4 -75
  96. package/mcp/schemas/c64_system.schema.json +28 -348
  97. package/mcp/schemas/c64_vice.schema.json +2 -71
  98. package/mcp/server.json +1 -1
  99. package/mcp/tools.json +1990 -6900
  100. package/mcp.json +21 -1
  101. package/package.json +19 -8
  102. package/scripts/mockC64Server.mjs +44 -0
  103. package/scripts/run-coverage.mjs +27 -1
  104. package/scripts/smoke-run.sh +3 -2
  105. package/scripts/start.mjs +23 -1
  106. package/server.json +53 -3
  107. package/dist/.tsbuildinfo +0 -1
  108. package/doc/agent-state-spec.md +0 -243
  109. package/doc/plans/bun/baseline-metrics.md +0 -16
  110. package/doc/plans/bun/migration-checklist.md +0 -55
  111. package/doc/plans/bun/migration.md +0 -83
  112. package/doc/plans/improve-knowledge/notes.md +0 -43
  113. package/doc/plans/improve-knowledge/plan.md +0 -121
  114. package/doc/plans/improve-knowledge/rollout.md +0 -110
  115. package/doc/plans/meta-tools/README.md +0 -110
  116. package/doc/plans/meta-tools/analysis.md +0 -272
  117. package/doc/plans/meta-tools/catalog.md +0 -435
  118. package/doc/plans/meta-tools/notes.md +0 -192
  119. package/doc/plans/meta-tools/rollout.md +0 -368
  120. package/doc/plans/prompts/README.md +0 -96
  121. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  122. package/doc/plans/tool-consolidation/notes.md +0 -28
  123. package/doc/plans/tool-consolidation/plan.md +0 -169
  124. package/doc/plans/tool-consolidation/rollout.md +0 -104
  125. package/doc/plans/vice-backend-switch/plan.md +0 -486
  126. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  127. package/doc/plans/vice-support/notes.md +0 -63
  128. package/doc/plans/vice-support/plan.md +0 -88
  129. package/doc/plans/vice-support/rollout.md +0 -99
  130. package/doc/reviews/audit.md +0 -143
  131. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  132. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  133. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  134. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  135. package/doc/reviews/vice-support-review.md +0 -185
  136. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
package/dist/chargen.js CHANGED
@@ -184,10 +184,6 @@ export function getChargenGlyphs() {
184
184
  loadGlyphs();
185
185
  return glyphCache.glyphs;
186
186
  }
187
- export function findGlyphByPetscii(code) {
188
- loadGlyphs();
189
- return glyphCache.byPetscii.get(code);
190
- }
191
187
  export function findGlyphByBasicChar(char) {
192
188
  if (!char) {
193
189
  return undefined;
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
  import os from "node:os";
4
4
  import { fileURLToPath } from "url";
5
+ import { registerSecret } from "./redact.js";
5
6
  const DEFAULT_HOST = "c64u";
6
7
  const DEFAULT_PORT = 80;
7
8
  export function loadConfig() {
@@ -23,9 +24,15 @@ export function loadConfig() {
23
24
  }
24
25
  catch (error) {
25
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
+ }
26
33
  // Deliberately omit file contents/credentials. Configuration mistakes
27
34
  // should not prevent the MCP server from exposing a recoverable path.
28
- console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.message : String(error)}`);
35
+ console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.name : "unreadable"}`);
29
36
  }
30
37
  }
31
38
  }
@@ -85,14 +92,16 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
85
92
  const envHost = profile === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST;
86
93
  const envPort = profile === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT;
87
94
  const envPassword = profile === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD;
88
- const parsedSectionHost = parseEndpoint(configuredString(section?.host));
89
- const parsedSectionHostname = parseEndpoint(configuredString(section?.hostname));
90
- const parsedLegacyHost = parseEndpoint(configuredString(legacy?.c64_host));
91
- const parsedLegacyIp = parseEndpoint(configuredString(legacy?.c64_ip));
92
- 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);
93
102
  const parsedBaseOverrides = [
94
- parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl))),
95
- parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl))),
103
+ parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl)), `${profile}.baseUrl`),
104
+ parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl)), "baseUrl"),
96
105
  ];
97
106
  const hostCandidates = [
98
107
  parsedEnvHost.hostname,
@@ -103,12 +112,12 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
103
112
  ...parsedBaseOverrides.map((entry) => entry.hostname),
104
113
  ];
105
114
  const portCandidates = [
106
- configuredPort(envPort),
115
+ configuredPort(envPort, envPortName),
107
116
  parsedEnvHost.port,
108
- configuredPort(section?.port),
117
+ configuredPort(section?.port, `${profile}.port`),
109
118
  parsedSectionHost.port,
110
119
  parsedSectionHostname.port,
111
- configuredPort(legacy?.c64_port),
120
+ configuredPort(legacy?.c64_port, "c64_port"),
112
121
  parsedLegacyHost.port,
113
122
  parsedLegacyIp.port,
114
123
  ...parsedBaseOverrides.map((entry) => entry.port),
@@ -131,12 +140,14 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
131
140
  url.port = String(explicitPort);
132
141
  baseUrl = stripTrailingSlash(url.toString());
133
142
  }
143
+ const networkPassword = firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword));
144
+ registerSecret(networkPassword);
134
145
  return {
135
146
  host,
136
147
  port,
137
148
  baseUrl,
138
149
  c64_host: port === schemeDefaultPort ? hostLabel : `${hostLabel}:${port}`,
139
- networkPassword: firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword)),
150
+ networkPassword,
140
151
  };
141
152
  }
142
153
  function configuredString(value) {
@@ -146,17 +157,23 @@ function nonEmptyString(value) {
146
157
  const trimmed = configuredString(value);
147
158
  return trimmed ? trimmed : undefined;
148
159
  }
149
- function configuredPort(value) {
150
- if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
151
- 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;
152
169
  }
153
- if (typeof value === "string" && value.trim()) {
154
- const parsed = Number(value.trim());
155
- if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
156
- return parsed;
157
- }
170
+ else if (typeof value === "string" && /^\d+$/.test(value.trim())) {
171
+ parsed = Number(value.trim());
158
172
  }
159
- 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`);
160
177
  }
161
178
  function configuredBoolean(value) {
162
179
  if (typeof value === "boolean") {
@@ -186,23 +203,31 @@ function normaliseBaseUrl(value) {
186
203
  }
187
204
  return stripTrailingSlash(input);
188
205
  }
189
- function parseEndpoint(value) {
206
+ function parseEndpoint(value, source) {
190
207
  const input = configuredString(value);
191
208
  if (!input)
192
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;
193
215
  try {
194
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input);
195
- 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
+ {
196
224
  const hostname = url.hostname || undefined;
197
- const port = url.port ? configuredPort(url.port) : undefined;
225
+ const port = url.port ? configuredPort(url.port, source) : undefined;
198
226
  const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
199
227
  // Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
200
228
  const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
201
229
  return { hostname, port, baseUrl, fullUrl };
202
230
  }
203
- catch {
204
- return {};
205
- }
206
231
  }
207
232
  function stripTrailingSlash(input) {
208
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();
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { redactValue } from "./redact.js";
5
6
  let state = null;
6
7
  let handlersInstalled = false;
7
8
  let spanCounter = 0;
@@ -21,9 +22,6 @@ export function installProcessDiagnostics(component) {
21
22
  export function getDiagnosticsSessionInfo() {
22
23
  return state?.info ?? null;
23
24
  }
24
- export function getDiagnosticsDirectory() {
25
- return resolveDiagnosticsDirectory();
26
- }
27
25
  export function writeDiagnosticEvent(event, details) {
28
26
  if (!isDiagnosticsEnabled()) {
29
27
  return;
@@ -38,7 +36,7 @@ export function writeDiagnosticEvent(event, details) {
38
36
  details: sanitize(details),
39
37
  };
40
38
  try {
41
- fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, "utf8");
39
+ fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
42
40
  }
43
41
  catch (error) {
44
42
  safeStderrWrite(`[diagnostics] failed to append event ${event}: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -252,13 +250,73 @@ function ensureState(component) {
252
250
  }
253
251
  function createSessionInfo(component) {
254
252
  const directory = resolveDiagnosticsDirectory();
255
- fs.mkdirSync(directory, { recursive: true });
253
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
256
254
  const startedAt = new Date().toISOString().replace(/[:.]/g, "-");
257
255
  const safeComponent = component.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "runtime";
258
256
  const sessionId = `${startedAt}-${process.pid}`;
259
257
  const filePath = path.join(directory, `${sessionId}-${safeComponent}.ndjson`);
258
+ pruneDiagnosticsDirectory(directory, { keep: resolveDiagnosticsKeep(), currentFile: filePath });
260
259
  return { component: safeComponent, directory, filePath, sessionId };
261
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
+ }
262
320
  function resolveDiagnosticsDirectory() {
263
321
  const configured = process.env.C64BRIDGE_DIAGNOSTICS_DIR?.trim();
264
322
  if (configured) {
@@ -300,6 +358,9 @@ function isDiagnosticsEnabled() {
300
358
  return true;
301
359
  }
302
360
  function sanitize(value, depth = 0) {
361
+ return sanitizeUnredacted(redactValue(value), depth);
362
+ }
363
+ function sanitizeUnredacted(value, depth = 0) {
303
364
  if (value instanceof Error) {
304
365
  const extra = value;
305
366
  return {
@@ -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
+ }