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
@@ -0,0 +1,55 @@
1
+ /*
2
+ C64 Bridge - An MCP Server for the Commodore 64 Ultimate
3
+ Copyright (C) 2025 Christian Gleissner
4
+
5
+ Licensed under the GNU General Public License v2.0 or later.
6
+ See <https://www.gnu.org/licenses/> for details.
7
+ */
8
+ import { ToolValidationError } from "./tools/errors.js";
9
+ /**
10
+ * Shared parser for memory address and length arguments.
11
+ *
12
+ * Accepted forms: `$C000`, `0xC000`, `%1010`, and decimal. Returns undefined
13
+ * when the text is not a numeric literal at all, so the caller can try a symbol
14
+ * name next. A decimal with a leading zero (`0400`) is rejected as ambiguous,
15
+ * because it was previously read as decimal 400 although it looks like hex.
16
+ */
17
+ export function parseNumericLiteral(raw, path) {
18
+ const text = raw.trim();
19
+ if (/^\$[0-9a-f]+$/i.test(text))
20
+ return Number.parseInt(text.slice(1), 16);
21
+ if (/^0x[0-9a-f]+$/i.test(text))
22
+ return Number.parseInt(text.slice(2), 16);
23
+ if (/^%[01]+$/.test(text))
24
+ return Number.parseInt(text.slice(1), 2);
25
+ if (/^[0-9]+$/.test(text)) {
26
+ if (text.length > 1 && text.startsWith("0")) {
27
+ throw new ToolValidationError(`Ambiguous number "${text}": a decimal with a leading zero could also be hexadecimal. Write "$${text}" for hex or "${Number.parseInt(text, 10)}" for decimal.`, { path });
28
+ }
29
+ return Number.parseInt(text, 10);
30
+ }
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Throws for a bare string of hex digits that contains A-F (`C000`), which
35
+ * would otherwise be reported as an unknown symbol or an unparsable number.
36
+ * Call it after symbol lookup so names that happen to be hex digits still work.
37
+ */
38
+ export function assertNotBareHex(raw, path) {
39
+ const text = raw.trim();
40
+ if (/^[0-9a-f]*[a-f][0-9a-f]*$/i.test(text)) {
41
+ throw new ToolValidationError(`Ambiguous value "${text}": hexadecimal needs a prefix. Write "$${text.toUpperCase()}" or "0x${text.toUpperCase()}".`, { path });
42
+ }
43
+ }
44
+ export function formatAddressForMessage(value) {
45
+ return `$${value.toString(16).toUpperCase().padStart(4, "0")}`;
46
+ }
47
+ /** Validates that an address (and optional length) lies within $0000-$FFFF. */
48
+ export function assertAddressRange(address, length, path) {
49
+ if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
50
+ throw new ToolValidationError("Address must be in range $0000-$FFFF", { path });
51
+ }
52
+ if (length !== undefined && address + length - 1 > 0xffff) {
53
+ throw new ToolValidationError(`Memory range ${formatAddressForMessage(address)} + ${length} bytes passes $FFFF`, { path });
54
+ }
55
+ }
package/dist/petscii.js CHANGED
@@ -63,12 +63,22 @@ function ensureScreenCodeMap() {
63
63
  }
64
64
  screenCodeMapInitialised = true;
65
65
  }
66
+ /** Letters of the lower-case set; reverse-video codes (+128) show the same letter. */
67
+ function lowerCaseSetChar(code) {
68
+ const base = code & 0x7f;
69
+ if (base >= 1 && base <= 26)
70
+ return String.fromCharCode(0x60 + base);
71
+ if (base >= 0x41 && base <= 0x5a)
72
+ return String.fromCharCode(base);
73
+ return undefined;
74
+ }
66
75
  export function screenCodesToAscii(bytes, options) {
67
76
  ensureScreenCodeMap();
68
77
  const columns = options?.columns && options.columns > 0 ? options.columns : 40;
69
78
  const maxRows = options?.rows && options.rows > 0 ? options.rows : undefined;
70
79
  const trimSpaces = options?.trimTrailingSpaces !== false;
71
80
  const trimEmptyRows = options?.trimTrailingEmptyRows !== false;
81
+ const lowerCase = options?.charset === "lower";
72
82
  const lines = [];
73
83
  const totalCells = maxRows ? Math.min(bytes.length, columns * maxRows) : bytes.length;
74
84
  for (let index = 0; index < totalCells; index += columns) {
@@ -79,7 +89,9 @@ export function screenCodesToAscii(bytes, options) {
79
89
  let line = "";
80
90
  for (let idx = index; idx < end; idx += 1) {
81
91
  const code = bytes[idx] ?? 0;
82
- const char = SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff);
92
+ const char = lowerCase
93
+ ? lowerCaseSetChar(code & 0xff) ?? SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff)
94
+ : SCREEN_CODE_TO_ASCII.get(code & 0xff) ?? petsciiByteToChar(code & 0xff);
83
95
  line += char;
84
96
  }
85
97
  if (trimSpaces) {
@@ -137,11 +149,6 @@ const NAMED_CHARS = Object.freeze({
137
149
  "leftarrow": 95,
138
150
  "pi": 222,
139
151
  });
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
152
  export function lookupNamedPetscii(name) {
146
153
  const key = name.trim().toLowerCase().replace(/\s+/g, "_");
147
154
  // Own keys only, so names like "constructor" don't resolve to Object.prototype members.
package/dist/platform.js CHANGED
@@ -51,15 +51,21 @@ export function isPlatformSupported(platform, supported) {
51
51
  }
52
52
  export function describePlatformCapabilities(tools) {
53
53
  const map = {
54
- c64u: { available: [], unsupported: [] },
55
- u2: { available: [], unsupported: [] },
56
- vice: { available: [], unsupported: [] },
54
+ c64u: { available: [], unsupported: [], unsupportedOperations: [] },
55
+ u2: { available: [], unsupported: [], unsupportedOperations: [] },
56
+ vice: { available: [], unsupported: [], unsupportedOperations: [] },
57
57
  };
58
58
  for (const tool of tools) {
59
59
  const supported = tool.metadata.platforms ?? ["c64u"];
60
60
  for (const id of ["c64u", "u2", "vice"]) {
61
61
  if (supported.includes(id)) {
62
62
  map[id].available.push(tool.name);
63
+ // Operations of an available tool that this backend does not provide.
64
+ for (const [op, platforms] of Object.entries(tool.metadata.operationPlatforms ?? {})) {
65
+ if (platforms.length > 0 && !platforms.includes(id)) {
66
+ map[id].unsupportedOperations.push(`${tool.name}.${op}`);
67
+ }
68
+ }
63
69
  }
64
70
  else {
65
71
  map[id].unsupported.push(tool.name);
@@ -73,18 +79,21 @@ export function describePlatformCapabilities(tools) {
73
79
  limited_features: PLATFORM_FEATURES.c64u.limited,
74
80
  tools: map.c64u.available.sort(),
75
81
  unsupported_tools: map.c64u.unsupported.sort(),
82
+ unsupported_operations: map.c64u.unsupportedOperations.sort(),
76
83
  },
77
84
  u2: {
78
85
  features: PLATFORM_FEATURES.u2.features,
79
86
  limited_features: PLATFORM_FEATURES.u2.limited,
80
87
  tools: map.u2.available.sort(),
81
88
  unsupported_tools: map.u2.unsupported.sort(),
89
+ unsupported_operations: map.u2.unsupportedOperations.sort(),
82
90
  },
83
91
  vice: {
84
92
  features: PLATFORM_FEATURES.vice.features,
85
93
  limited_features: PLATFORM_FEATURES.vice.limited,
86
94
  tools: map.vice.available.sort(),
87
95
  unsupported_tools: map.vice.unsupported.sort(),
96
+ unsupported_operations: map.vice.unsupportedOperations.sort(),
88
97
  },
89
98
  },
90
99
  };
@@ -1,6 +1,7 @@
1
1
  import { listKnowledgeResources } from "../rag/knowledgeIndex.js";
2
2
  import { RESOURCE_URIS } from "../rag/resourceUris.js";
3
3
  import { toolRegistry } from "../tools/registry/index.js";
4
+ import { ToolValidationError } from "../tools/errors.js";
4
5
  const ASSEMBLY_HARDWARE_NOTES = {
5
6
  sid: [
6
7
  "When routing this request, keep SID register usage, voice allocation, and timing side effects in scope.",
@@ -354,7 +355,11 @@ export function createPromptRegistry() {
354
355
  resolve(name, args) {
355
356
  const definition = definitionByName.get(name);
356
357
  if (!definition) {
357
- throw new Error(`Unknown prompt: ${name}`);
358
+ const allowed = Array.from(definitionByName.keys()).sort();
359
+ throw new ToolValidationError(`Unknown prompt: ${name}. Valid prompts: ${allowed.join(", ")}`, {
360
+ path: "$.name",
361
+ details: { allowed, received: name },
362
+ });
358
363
  }
359
364
  const rawArgs = args ?? {};
360
365
  const prepared = definition.prepareArgs ? definition.prepareArgs(rawArgs) : {};
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;
@@ -0,0 +1,66 @@
1
+ import { ToolValidationError } from "./errors.js";
2
+ export const ACCESS_ENV_VAR = "C64BRIDGE_ACCESS";
3
+ const ACCESS_MODES = ["full", "no-destructive", "read-only"];
4
+ const warnedValues = new Set();
5
+ /**
6
+ * Reads the access mode from the environment at call time. An unset or empty variable means
7
+ * "full". An unknown value fails closed to "read-only" and logs a warning once per value.
8
+ */
9
+ export function getAccessMode(env = process.env) {
10
+ const raw = env[ACCESS_ENV_VAR];
11
+ if (raw === undefined || raw.trim() === "") {
12
+ return "full";
13
+ }
14
+ const value = raw.trim().toLowerCase();
15
+ if (ACCESS_MODES.includes(value)) {
16
+ return value;
17
+ }
18
+ if (!warnedValues.has(value)) {
19
+ warnedValues.add(value);
20
+ process.stderr.write(`[c64bridge] Unknown ${ACCESS_ENV_VAR} value "${raw}"; using "read-only". Valid values: ${ACCESS_MODES.join(", ")}.\n`);
21
+ }
22
+ return "read-only";
23
+ }
24
+ export function isEffectAllowed(mode, effect) {
25
+ switch (mode) {
26
+ case "full":
27
+ return true;
28
+ case "no-destructive":
29
+ return effect !== "destructive";
30
+ default:
31
+ return effect === "read";
32
+ }
33
+ }
34
+ /** Throws a validation error when the active access mode does not allow the effect. */
35
+ export function assertAccess(toolName, op, effect) {
36
+ const mode = getAccessMode();
37
+ if (isEffectAllowed(mode, effect)) {
38
+ return;
39
+ }
40
+ const subject = op ? `${toolName} operation "${op}"` : toolName;
41
+ throw new ToolValidationError(`${subject} is blocked: it has effect "${effect}" and the access mode is "${mode}" (${ACCESS_ENV_VAR}=${mode}). `
42
+ + `${mode === "read-only" ? "Only read operations are allowed." : "Destructive operations are not allowed."}`, {
43
+ path: "$.op",
44
+ details: { tool: toolName, op: op ?? null, effect, accessMode: mode, variable: ACCESS_ENV_VAR },
45
+ });
46
+ }
47
+ /** Effect of a background task operation (read, write, read_screen, menu_button and the *_memory aliases). */
48
+ export function taskOperationEffect(operation) {
49
+ switch (operation) {
50
+ case "read":
51
+ case "read_memory":
52
+ case "read_screen":
53
+ return "read";
54
+ default:
55
+ // write, write_memory, menu_button and unknown names (rejected later by validation).
56
+ return "write";
57
+ }
58
+ }
59
+ export function annotationsForEffects(effects) {
60
+ return {
61
+ readOnlyHint: effects.length > 0 && effects.every((effect) => effect === "read"),
62
+ destructiveHint: effects.includes("destructive"),
63
+ idempotentHint: false,
64
+ openWorldHint: false,
65
+ };
66
+ }
@@ -494,6 +494,12 @@ export const debugModuleGroup = defineToolModule({
494
494
  description: "Available debugger operations.",
495
495
  variants: debugOperationSchemas.map((schema) => schema.jsonSchema),
496
496
  }),
497
+ operationEffects: {
498
+ list_checkpoints: "read", get_checkpoint: "read", create_checkpoint: "write", delete_checkpoint: "write",
499
+ toggle_checkpoint: "write", set_condition: "write", list_registers: "read", get_registers: "read",
500
+ set_registers: "write", step: "write", step_return: "write", get_monitor_state: "read",
501
+ wait_for_state: "read", nuclear_reset: "write", continue_execution: "write",
502
+ },
497
503
  tags: ["debug", "breakpoint", "registers", "grouped"],
498
504
  examples: [
499
505
  {
@@ -1,6 +1,7 @@
1
1
  import { defineToolModule } from "./types.js";
2
- import { objectSchema, optionalSchema, stringSchema, } from "./schema.js";
2
+ import { booleanSchema, objectSchema, optionalSchema, stringSchema, } from "./schema.js";
3
3
  import { jsonResult, textResult } from "./responses.js";
4
+ import { redactConfigValue } from "../redact.js";
4
5
  import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
5
6
  function toRecord(details) {
6
7
  if (details === undefined || details === null) {
@@ -19,6 +20,16 @@ const noArgsSchema = objectSchema({
19
20
  properties: {},
20
21
  additionalProperties: false,
21
22
  });
23
+ const flashCategoryArgsSchema = objectSchema({
24
+ description: "Optional configuration category; omit to apply to the whole device configuration.",
25
+ properties: {
26
+ category: optionalSchema(stringSchema({
27
+ description: "Configuration category name (e.g. 'Audio Mixer'). Omit to act on every category.",
28
+ minLength: 1,
29
+ })),
30
+ },
31
+ additionalProperties: false,
32
+ });
22
33
  const categorySchema = stringSchema({
23
34
  description: "Configuration category reported by the Ultimate firmware (e.g. 'Audio').",
24
35
  minLength: 1,
@@ -61,20 +72,52 @@ const configGetArgsSchema = objectSchema({
61
72
  properties: {
62
73
  category: categorySchema,
63
74
  item: itemSchema,
75
+ includeSecretValues: optionalSchema(booleanSchema({
76
+ description: "Return credential values (network password, FTP/Telnet passwords, Wi-Fi keys) in plain text. Default false: those values are replaced with ***.",
77
+ default: false,
78
+ }), false),
64
79
  },
65
80
  required: ["category"],
66
81
  additionalProperties: false,
67
82
  });
83
+ const BATCH_CATEGORY_SCHEMA = {
84
+ type: "object",
85
+ description: "Item/value pairs of one category (primitive values only).",
86
+ additionalProperties: {
87
+ type: ["string", "number", "boolean"],
88
+ },
89
+ };
90
+ function isCategoryMap(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value)
92
+ && Object.values(value).every((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry));
93
+ }
94
+ function parseCategoryMap(input, resolvedPath, into) {
95
+ for (const [category, items] of Object.entries(input)) {
96
+ if (typeof items !== "object" || items === null || Array.isArray(items)) {
97
+ throw new ToolValidationError("Each category must map to an object of item values", {
98
+ path: `${resolvedPath}.${category}`,
99
+ });
100
+ }
101
+ const parsedItems = into[category] ?? {};
102
+ for (const [item, rawValue] of Object.entries(items)) {
103
+ parsedItems[item] = primitiveValueSchema.parse(rawValue, `${resolvedPath}.${category}.${item}`);
104
+ }
105
+ into[category] = parsedItems;
106
+ }
107
+ }
68
108
  const configBatchSchema = {
69
109
  jsonSchema: {
70
- description: "Nested object mapping categories to item/value pairs (primitive values only).",
110
+ description: "Apply configuration changes. Documented form: `updates`, an object mapping categories to item/value pairs. "
111
+ + "For backward compatibility the runtime also accepts the categories as top-level keys next to `op`.",
71
112
  type: "object",
72
- additionalProperties: {
73
- type: "object",
74
- additionalProperties: {
75
- type: ["string", "number", "boolean"],
113
+ properties: {
114
+ updates: {
115
+ type: "object",
116
+ description: "Categories mapped to item/value pairs (primitive values only), for example {\"Audio\": {\"Volume\": 80}}.",
117
+ additionalProperties: BATCH_CATEGORY_SCHEMA,
76
118
  },
77
119
  },
120
+ additionalProperties: BATCH_CATEGORY_SCHEMA,
78
121
  },
79
122
  parse(value, path) {
80
123
  const resolvedPath = path ?? "$";
@@ -83,21 +126,16 @@ const configBatchSchema = {
83
126
  path: resolvedPath,
84
127
  });
85
128
  }
86
- const input = value;
129
+ const input = { ...value };
87
130
  const categories = {};
88
- for (const [category, items] of Object.entries(input)) {
89
- if (typeof items !== "object" || items === null || Array.isArray(items)) {
90
- throw new ToolValidationError("Each category must map to an object of item values", {
91
- path: `${resolvedPath}.${category}`,
92
- });
93
- }
94
- const parsedItems = {};
95
- for (const [item, rawValue] of Object.entries(items)) {
96
- const parsedValue = primitiveValueSchema.parse(rawValue, `${resolvedPath}.${category}.${item}`);
97
- parsedItems[item] = parsedValue;
98
- }
99
- categories[category] = parsedItems;
131
+ // `updates` is the documented form. A legacy category literally named "updates"
132
+ // holds primitive values, so it is not a category map and stays a legacy category.
133
+ if (Object.prototype.hasOwnProperty.call(input, "updates") && isCategoryMap(input.updates)) {
134
+ const updates = input.updates;
135
+ delete input.updates;
136
+ parseCategoryMap(updates, `${resolvedPath}.updates`, categories);
100
137
  }
138
+ parseCategoryMap(input, resolvedPath, categories);
101
139
  if (Object.keys(categories).length === 0) {
102
140
  throw new ToolValidationError("Batch payload must contain at least one category", {
103
141
  path: resolvedPath,
@@ -190,7 +228,10 @@ export const developerModule = defineToolModule({
190
228
  category: parsed.category,
191
229
  item: parsed.item ?? null,
192
230
  });
193
- const details = await ctx.client.configGet(parsed.category, parsed.item);
231
+ const raw = await ctx.client.configGet(parsed.category, parsed.item);
232
+ const details = parsed.includeSecretValues === true
233
+ ? raw
234
+ : redactConfigValue(raw, parsed.category, parsed.item);
194
235
  return withStructuredJson({ value: details }, {
195
236
  success: true,
196
237
  category: parsed.category,
@@ -257,7 +298,7 @@ export const developerModule = defineToolModule({
257
298
  relatedResources: ["c64://guide/bootstrap"],
258
299
  tags: ["config", "write"],
259
300
  examples: [
260
- { name: "Batch update", description: "Update multiple settings", arguments: { Audio: { Volume: 80, DACMode: "stereo" } } },
301
+ { name: "Batch update", description: "Update multiple settings", arguments: { updates: { Audio: { Volume: 80, DACMode: "stereo" } } } },
261
302
  ],
262
303
  workflowHints: [
263
304
  "Use when multiple categories must be updated together; remind the user to review validation errors closely.",
@@ -297,7 +338,7 @@ export const developerModule = defineToolModule({
297
338
  name: "config_load_from_flash",
298
339
  description: "Load configuration settings from flash storage.",
299
340
  summary: "Restores the firmware configuration by reading the persisted flash snapshot.",
300
- inputSchema: noArgsSchema.jsonSchema,
341
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
301
342
  relatedResources: ["c64://guide/bootstrap"],
302
343
  tags: ["config", "flash"],
303
344
  examples: [
@@ -308,15 +349,15 @@ export const developerModule = defineToolModule({
308
349
  ],
309
350
  async execute(args, ctx) {
310
351
  try {
311
- noArgsSchema.parse(args ?? {});
312
- ctx.logger.info("Loading configuration from flash");
313
- const result = await ctx.client.configLoadFromFlash();
352
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
353
+ ctx.logger.info("Loading configuration from flash", { category });
354
+ const result = await ctx.client.configLoadFromFlash(category);
314
355
  if (!result.success) {
315
356
  throw new ToolExecutionError("C64 backend reported failure while loading from flash", {
316
357
  details: toRecord(result.details),
317
358
  });
318
359
  }
319
- return textResult("Configuration loaded from flash.", {
360
+ return textResult(`Configuration${category ? ` category ${category}` : ""} loaded from flash.`, {
320
361
  success: true,
321
362
  details: toRecord(result.details) ?? null,
322
363
  });
@@ -333,7 +374,7 @@ export const developerModule = defineToolModule({
333
374
  name: "config_save_to_flash",
334
375
  description: "Persist current configuration settings to flash storage.",
335
376
  summary: "Saves the active configuration snapshot so it survives power cycles.",
336
- inputSchema: noArgsSchema.jsonSchema,
377
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
337
378
  relatedResources: ["c64://guide/bootstrap"],
338
379
  tags: ["config", "flash"],
339
380
  examples: [
@@ -344,15 +385,15 @@ export const developerModule = defineToolModule({
344
385
  ],
345
386
  async execute(args, ctx) {
346
387
  try {
347
- noArgsSchema.parse(args ?? {});
348
- ctx.logger.info("Saving configuration to flash");
349
- const result = await ctx.client.configSaveToFlash();
388
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
389
+ ctx.logger.info("Saving configuration to flash", { category });
390
+ const result = await ctx.client.configSaveToFlash(category);
350
391
  if (!result.success) {
351
392
  throw new ToolExecutionError("C64 backend reported failure while saving to flash", {
352
393
  details: toRecord(result.details),
353
394
  });
354
395
  }
355
- return textResult("Configuration saved to flash.", {
396
+ return textResult(`Configuration${category ? ` category ${category}` : ""} saved to flash.`, {
356
397
  success: true,
357
398
  details: toRecord(result.details) ?? null,
358
399
  });
@@ -369,7 +410,7 @@ export const developerModule = defineToolModule({
369
410
  name: "config_reset_to_default",
370
411
  description: "Reset configuration categories to their factory defaults.",
371
412
  summary: "Instructs the firmware to discard overrides and restore default configuration values.",
372
- inputSchema: noArgsSchema.jsonSchema,
413
+ inputSchema: flashCategoryArgsSchema.jsonSchema,
373
414
  relatedResources: ["c64://guide/bootstrap"],
374
415
  tags: ["config", "reset"],
375
416
  examples: [
@@ -380,15 +421,15 @@ export const developerModule = defineToolModule({
380
421
  ],
381
422
  async execute(args, ctx) {
382
423
  try {
383
- noArgsSchema.parse(args ?? {});
384
- ctx.logger.info("Resetting configuration to defaults");
385
- const result = await ctx.client.configResetToDefault();
424
+ const { category } = flashCategoryArgsSchema.parse(args ?? {});
425
+ ctx.logger.info("Resetting configuration to defaults", { category });
426
+ const result = await ctx.client.configResetToDefault(category);
386
427
  if (!result.success) {
387
428
  throw new ToolExecutionError("C64 backend reported failure while resetting configuration", {
388
429
  details: toRecord(result.details),
389
430
  });
390
431
  }
391
- return textResult("Configuration reset to defaults.", {
432
+ return textResult(`Configuration${category ? ` category ${category}` : ""} reset to defaults.`, {
392
433
  success: true,
393
434
  details: toRecord(result.details) ?? null,
394
435
  });