c64bridge 1.0.5 → 1.0.7

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 (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +3 -3
  3. package/data/sound/sidwave.md +113 -36
  4. package/data/sound/sidwave.schema.json +138 -28
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/c64Client.js +83 -93
  7. package/dist/chargen.js +0 -4
  8. package/dist/config.js +26 -6
  9. package/dist/device.js +130 -24
  10. package/dist/diagnostics.js +14 -5
  11. package/dist/logger.js +3 -2
  12. package/dist/loggingHttpClient.js +12 -1
  13. package/dist/mcp-server.js +19 -9
  14. package/dist/petscii.js +0 -5
  15. package/dist/redact.js +166 -0
  16. package/dist/sidwave.js +453 -61
  17. package/dist/sidwaveCompiler.js +1424 -269
  18. package/dist/tools/audio.js +70 -12
  19. package/dist/tools/developer.js +17 -9
  20. package/dist/tools/errors.js +7 -0
  21. package/dist/tools/graphics.js +4 -4
  22. package/dist/tools/input.js +10 -13
  23. package/dist/tools/machineControl.js +7 -7
  24. package/dist/tools/memory.js +6 -6
  25. package/dist/tools/meta/audio.js +8 -2
  26. package/dist/tools/meta/config.js +5 -4
  27. package/dist/tools/meta/configInventory.js +25 -2
  28. package/dist/tools/meta/diagnostics.js +2 -1
  29. package/dist/tools/meta/graphics.js +1 -1
  30. package/dist/tools/meta/memory.js +4 -4
  31. package/dist/tools/meta/program.js +12 -8
  32. package/dist/tools/printer.js +4 -4
  33. package/dist/tools/programRunners.js +8 -8
  34. package/dist/tools/registry/batch.js +1 -7
  35. package/dist/tools/registry/disk.js +7 -5
  36. package/dist/tools/storage.js +21 -17
  37. package/dist/tools/streaming.js +2 -2
  38. package/dist/tools/translation/assembler.js +44 -12
  39. package/dist/tools/translation/basicTokenizer.js +5 -1
  40. package/dist/tools/translation/opcodeInventory.js +1 -1
  41. package/dist/tools/vice.js +1 -1
  42. package/dist/vice/mockServer.js +92 -5
  43. package/dist/vice/readiness.js +4 -9
  44. package/dist/vice/viceClient.js +18 -0
  45. package/doc/audit/secret-handling.md +61 -0
  46. package/mcp/schemas/c64_config.schema.json +19 -2
  47. package/mcp/schemas/c64_disk.schema.json +2 -2
  48. package/mcp/schemas/c64_input.schema.json +3 -3
  49. package/mcp/schemas/c64_program.schema.json +2 -2
  50. package/mcp/schemas/c64_vice.schema.json +3 -3
  51. package/mcp/server.json +1 -1
  52. package/mcp/tools.json +33 -12
  53. package/mcp.json +1 -1
  54. package/package.json +5 -2
  55. package/scripts/mockC64Server.mjs +15 -1
  56. package/scripts/run-coverage.mjs +31 -1
  57. package/scripts/update-readme.ts +3 -2
  58. package/server.json +20 -3
@@ -18,6 +18,7 @@ import { unknownErrorResult } from "./tools/errors.js";
18
18
  import { createPromptRegistry } from "./prompts/registry.js";
19
19
  import { describePlatformCapabilities, getPlatformStatus, setPlatform } from "./platform.js";
20
20
  import axios from "axios";
21
+ import { redactValue, registerSecret } from "./redact.js";
21
22
  import { loggerFor, payloadByteLength, formatPayloadForDebug, formatErrorMessage } from "./logger.js";
22
23
  import { getDiagnosticsSessionInfo, installProcessDiagnostics, withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
23
24
  const __filename = fileURLToPath(import.meta.url);
@@ -449,7 +450,7 @@ async function main() {
449
450
  writeDiagnosticEvent("mcp_transport_connected", { mode: "stdio" });
450
451
  console.error("c64bridge MCP server running on stdio");
451
452
  }
452
- await logConnectivity(client, baseUrl);
453
+ await logConnectivity(client, baseUrl, config.networkPassword);
453
454
  writeDiagnosticEvent("server_ready", {
454
455
  diagnosticsFile: diagnostics.filePath,
455
456
  });
@@ -524,7 +525,11 @@ export async function renderPlatformStatusMarkdown(client) {
524
525
  lines.push("> Use `c64_select_backend` to switch to another available backend without restarting the MCP server.");
525
526
  return lines.join("\n");
526
527
  }
527
- async function logConnectivity(client, baseUrl) {
528
+ /** URL of the Ultimate's /v1/info route, keeping any path prefix of the base URL (reverse proxy). */
529
+ export function connectivityProbeUrl(baseUrl) {
530
+ return `${baseUrl.replace(/\/+$/, "")}/v1/info`;
531
+ }
532
+ async function logConnectivity(client, baseUrl, networkPassword) {
528
533
  const backendType = await client.getBackendType();
529
534
  if (backendType === "vice") {
530
535
  writeDiagnosticEvent("connectivity_probe_skipped", { baseUrl, reason: "vice_backend" });
@@ -550,10 +555,15 @@ async function logConnectivity(client, baseUrl) {
550
555
  }
551
556
  const c64Logger = loggerFor("c64u");
552
557
  const startedAt = Date.now();
553
- const infoUrl = new URL("/v1/info", baseUrl).toString();
558
+ const infoUrl = connectivityProbeUrl(baseUrl);
554
559
  let response = null;
555
560
  try {
556
- const probeResponse = await axios.get(infoUrl, { timeout: 2000 });
561
+ // Send the network password like every other request, or a protected device answers 403.
562
+ registerSecret(networkPassword);
563
+ const probeResponse = await axios.get(infoUrl, {
564
+ timeout: 2000,
565
+ ...(networkPassword ? { headers: { "X-Password": networkPassword } } : {}),
566
+ });
557
567
  response = probeResponse;
558
568
  const latency = Date.now() - startedAt;
559
569
  const bytes = payloadByteLength(probeResponse.data);
@@ -598,13 +608,13 @@ async function logConnectivity(client, baseUrl) {
598
608
  console.error(`Zero-page probe skipped or failed (may be unsupported on current backend): ${message}`);
599
609
  }
600
610
  }
601
- function toCallToolResult(result) {
602
- const base = { content: result.content };
611
+ export function toCallToolResult(result) {
612
+ const base = { content: redactValue(result.content) };
603
613
  if (result.structuredContent !== undefined) {
604
- base.structuredContent = result.structuredContent;
614
+ base.structuredContent = redactValue(result.structuredContent);
605
615
  }
606
616
  if (result.metadata !== undefined) {
607
- base.metadata = result.metadata;
617
+ base.metadata = redactValue(result.metadata);
608
618
  }
609
619
  if (result.isError !== undefined) {
610
620
  base.isError = result.isError;
@@ -635,7 +645,7 @@ if (process.env.C64BRIDGE_SKIP_AUTO_START !== "1") {
635
645
  diagnosticsFile: getDiagnosticsSessionInfo()?.filePath,
636
646
  error,
637
647
  });
638
- console.error("Fatal error in MCP server:", error);
648
+ console.error("Fatal error in MCP server:", redactValue(error));
639
649
  process.exit(1);
640
650
  });
641
651
  }
package/dist/petscii.js CHANGED
@@ -137,11 +137,6 @@ const NAMED_CHARS = Object.freeze({
137
137
  "leftarrow": 95,
138
138
  "pi": 222,
139
139
  });
140
- export function listNamedPetscii() {
141
- return Object.entries(NAMED_CHARS)
142
- .sort((a, b) => a[0].localeCompare(b[0]))
143
- .map(([name, code]) => ({ name, code, hex: `$${code.toString(16).toUpperCase().padStart(2, "0")}` }));
144
- }
145
140
  export function lookupNamedPetscii(name) {
146
141
  const key = name.trim().toLowerCase().replace(/\s+/g, "_");
147
142
  // Own keys only, so names like "constructor" don't resolve to Object.prototype members.
package/dist/redact.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Shared redaction for the Ultimate network password and other credentials.
3
+ *
4
+ * The password is meant to leave the process only as the `X-Password` request
5
+ * header. Every sink that can carry arbitrary values (console logging,
6
+ * diagnostics files, MCP tool results) passes its content through this module.
7
+ *
8
+ * Two mechanisms are combined:
9
+ * - values registered with `registerSecret` are replaced wherever they appear
10
+ * inside a string, including error messages and stack traces;
11
+ * - properties whose name is a credential header are replaced regardless of value.
12
+ */
13
+ const REDACTED = "***";
14
+ const SENSITIVE_KEY = /^(x-password|authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
15
+ const MIN_SECRET_LENGTH = 1;
16
+ const secrets = new Set();
17
+ export function registerSecret(value) {
18
+ if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) {
19
+ secrets.add(value);
20
+ }
21
+ }
22
+ /** Test hook: forget all registered secrets. */
23
+ export function clearRegisteredSecrets() {
24
+ secrets.clear();
25
+ }
26
+ export function isSensitiveKey(key) {
27
+ return SENSITIVE_KEY.test(key);
28
+ }
29
+ export function redactText(text) {
30
+ let result = text;
31
+ // Longest first, so a secret that contains another one is replaced as a whole.
32
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
33
+ if (result.includes(secret)) {
34
+ result = result.split(secret).join(REDACTED);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+ function isPlainObject(value) {
40
+ const proto = Object.getPrototypeOf(value);
41
+ return proto === Object.prototype || proto === null;
42
+ }
43
+ /**
44
+ * Returns a copy of `value` that is safe to log, persist or return to a client.
45
+ * Errors (including Axios errors) are reduced to name, message, stack, code and
46
+ * HTTP status, so their request configuration is never copied.
47
+ */
48
+ export function redactValue(value, seen = new WeakSet()) {
49
+ if (typeof value === "string") {
50
+ return redactText(value);
51
+ }
52
+ if (value === null || typeof value !== "object") {
53
+ return value;
54
+ }
55
+ if (seen.has(value)) {
56
+ return "[Circular]";
57
+ }
58
+ if (value instanceof Error) {
59
+ seen.add(value);
60
+ const extra = value;
61
+ return {
62
+ name: value.name,
63
+ message: redactText(value.message),
64
+ ...(value.stack ? { stack: redactText(value.stack) } : {}),
65
+ ...(extra.code !== undefined ? { code: redactValue(extra.code, seen) } : {}),
66
+ ...(extra.response?.status !== undefined ? { status: extra.response.status } : {}),
67
+ ...(extra.cause !== undefined ? { cause: redactValue(extra.cause, seen) } : {}),
68
+ };
69
+ }
70
+ if (Array.isArray(value)) {
71
+ seen.add(value);
72
+ return value.map((entry) => redactValue(entry, seen));
73
+ }
74
+ if (!isPlainObject(value)) {
75
+ return value;
76
+ }
77
+ seen.add(value);
78
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
79
+ redactText(key),
80
+ isSensitiveKey(key) ? REDACTED : redactValue(entry, seen),
81
+ ]));
82
+ }
83
+ function scrubHeaders(headers) {
84
+ if (!headers || typeof headers !== "object")
85
+ return;
86
+ for (const key of Object.keys(headers)) {
87
+ if (isSensitiveKey(key)) {
88
+ headers[key] = REDACTED;
89
+ }
90
+ }
91
+ }
92
+ function scrubRequestHolder(holder) {
93
+ if (!holder || typeof holder !== "object")
94
+ return;
95
+ const target = holder;
96
+ scrubHeaders(target.config?.headers);
97
+ // The raw ClientRequest keeps the serialized header block, including X-Password.
98
+ if (target.request !== undefined) {
99
+ target.request = undefined;
100
+ }
101
+ }
102
+ /**
103
+ * Removes credentials from a rejected Axios error in place and returns it, so
104
+ * callers that inspect, log or serialize the error cannot expose them.
105
+ * Status, code, message and response data stay available.
106
+ */
107
+ export function scrubRequestError(error) {
108
+ if (!error || typeof error !== "object")
109
+ return error;
110
+ scrubRequestHolder(error);
111
+ const response = error.response;
112
+ scrubRequestHolder(response);
113
+ // A device that echoes the rejected password in its error body.
114
+ if (response && typeof response.data === "string") {
115
+ response.data = redactText(response.data);
116
+ }
117
+ return error;
118
+ }
119
+ /**
120
+ * Firmware configuration items that hold credentials. The Ultimate config API
121
+ * lists items by display name (for example "Network Password" in the
122
+ * "Network settings" category), so matching is by item name: passwords,
123
+ * passphrases and pre-shared keys, plus a bare "key" item in a Wi-Fi category.
124
+ */
125
+ const SENSITIVE_CONFIG_ITEM = /pass(word|wd|phrase|code)?\b|\bpsk\b|secret/i;
126
+ const WIFI_CATEGORY = /wi-?fi|wlan|wireless/i;
127
+ const BARE_KEY_ITEM = /^(wifi |wlan )?(key|wpa key|network key)$/i;
128
+ export function isSensitiveConfigItem(category, item) {
129
+ if (!item)
130
+ return false;
131
+ if (SENSITIVE_CONFIG_ITEM.test(item))
132
+ return true;
133
+ return BARE_KEY_ITEM.test(item.trim()) && (item.toLowerCase().includes("wifi") || WIFI_CATEGORY.test(category ?? ""));
134
+ }
135
+ /**
136
+ * Returns a copy of a firmware configuration response in which the value of
137
+ * every credential item is replaced with `***`. Item names stay, so a client can
138
+ * see that the item exists. `category` and `item` describe the request: when an
139
+ * item was requested and the response is its bare value, the value is replaced.
140
+ */
141
+ export function redactConfigValue(value, category, item) {
142
+ if (item && isSensitiveConfigItem(category, item)) {
143
+ return value !== null && typeof value === "object" ? redactConfigTree(value, category) : REDACTED;
144
+ }
145
+ return redactConfigTree(value, category);
146
+ }
147
+ function redactConfigTree(value, category) {
148
+ if (Array.isArray(value)) {
149
+ return value.map((entry) => redactConfigTree(entry, category));
150
+ }
151
+ if (value === null || typeof value !== "object") {
152
+ return value;
153
+ }
154
+ const out = {};
155
+ for (const [key, entry] of Object.entries(value)) {
156
+ if (isSensitiveConfigItem(category, key) && entry !== null && entry !== undefined) {
157
+ out[key] = REDACTED;
158
+ }
159
+ else {
160
+ // Keys at the top level of a response are category names.
161
+ out[key] = redactConfigTree(entry, entry !== null && typeof entry === "object" && !Array.isArray(entry) ? key : category);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ export const REDACTED_PLACEHOLDER = REDACTED;