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.
- package/.github/skills/assembly-program/SKILL.md +37 -0
- package/.github/skills/basic-program/SKILL.md +40 -0
- package/.github/skills/cross-platform-demo/SKILL.md +32 -0
- package/.github/skills/drive-manager/SKILL.md +33 -0
- package/.github/skills/graphics-demo/SKILL.md +30 -0
- package/.github/skills/hello-world/SKILL.md +36 -0
- package/.github/skills/memory-debug/SKILL.md +32 -0
- package/.github/skills/pr-converge/SKILL.md +33 -0
- package/.github/skills/printer-job/SKILL.md +30 -0
- package/.github/skills/sid-music/SKILL.md +35 -0
- package/.github/skills/software-launch/SKILL.md +31 -0
- package/.github/skills/stream-control/SKILL.md +29 -0
- package/.github/skills/system-control/SKILL.md +32 -0
- package/AGENTS.md +28 -7
- package/README.md +70 -15
- package/data/assembly/assembly-spec.md +2 -0
- package/dist/c64Client.js +238 -60
- package/dist/chargen.js +0 -4
- package/dist/config.js +53 -28
- package/dist/device.js +92 -37
- package/dist/diagnostics.js +66 -5
- package/dist/hostPaths.js +128 -0
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +71 -2
- package/dist/mcp-server.js +416 -291
- package/dist/numericArg.js +55 -0
- package/dist/petscii.js +13 -6
- package/dist/platform.js +12 -3
- package/dist/prompts/registry.js +6 -1
- package/dist/redact.js +166 -0
- package/dist/tools/access.js +66 -0
- package/dist/tools/debug.js +6 -0
- package/dist/tools/developer.js +77 -36
- package/dist/tools/errors.js +54 -3
- package/dist/tools/input.js +25 -8
- package/dist/tools/machineControl.js +113 -21
- package/dist/tools/memory.js +135 -30
- package/dist/tools/meta/artifacts.js +5 -5
- package/dist/tools/meta/background.js +216 -54
- package/dist/tools/meta/compilation.js +6 -5
- package/dist/tools/meta/config.js +20 -10
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/meta/filesystem.js +16 -14
- package/dist/tools/meta/graphics.js +48 -20
- package/dist/tools/meta/memory.js +45 -19
- package/dist/tools/meta/program.js +10 -7
- package/dist/tools/meta/screen.js +116 -25
- package/dist/tools/pauseGuard.js +23 -0
- package/dist/tools/pollValidator.js +53 -8
- package/dist/tools/programRunners.js +62 -18
- package/dist/tools/registry/batch.js +17 -11
- package/dist/tools/registry/config.js +9 -2
- package/dist/tools/registry/disk.js +12 -2
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +59 -6
- package/dist/tools/registry/index.js +17 -1
- package/dist/tools/registry/memory.js +11 -1
- package/dist/tools/registry/platform.js +4 -1
- package/dist/tools/registry/printer.js +2 -1
- package/dist/tools/registry/program.js +5 -1
- package/dist/tools/registry/rag.js +1 -0
- package/dist/tools/registry/sound.js +12 -1
- package/dist/tools/registry/stream.js +2 -1
- package/dist/tools/registry/system.js +8 -1
- package/dist/tools/schema.js +7 -7
- package/dist/tools/streaming.js +34 -4
- package/dist/tools/translation/assembler.js +247 -24
- package/dist/tools/translation/basicDetokenizer.js +80 -0
- package/dist/tools/translation/basicTokenizer.js +8 -3
- package/dist/tools/translation/disassembler.js +14 -0
- package/dist/tools/types.js +258 -7
- package/dist/tools/vice.js +5 -16
- package/dist/vicBitmap.js +1 -1
- package/dist/vice/mockServer.js +3 -0
- package/dist/vice/process.js +19 -0
- package/dist/vice/resourcePolicy.js +36 -0
- package/dist/vice/viceClient.js +22 -5
- package/doc/vice/support-matrix.md +17 -11
- package/mcp/schemas/c64_batch.schema.json +2 -1
- package/mcp/schemas/c64_config.schema.json +33 -466
- package/mcp/schemas/c64_debug.schema.json +3 -602
- package/mcp/schemas/c64_disk.schema.json +17 -344
- package/mcp/schemas/c64_drive.schema.json +2 -157
- package/mcp/schemas/c64_extract.schema.json +10 -364
- package/mcp/schemas/c64_graphics.schema.json +19 -426
- package/mcp/schemas/c64_input.schema.json +5 -318
- package/mcp/schemas/c64_memory.schema.json +23 -491
- package/mcp/schemas/c64_printer.schema.json +3 -258
- package/mcp/schemas/c64_program.schema.json +16 -491
- package/mcp/schemas/c64_rag.schema.json +1 -81
- package/mcp/schemas/c64_select_backend.schema.json +2 -36
- package/mcp/schemas/c64_sound.schema.json +20 -822
- package/mcp/schemas/c64_stream.schema.json +4 -75
- package/mcp/schemas/c64_system.schema.json +28 -348
- package/mcp/schemas/c64_vice.schema.json +2 -71
- package/mcp/server.json +1 -1
- package/mcp/tools.json +1990 -6900
- package/mcp.json +21 -1
- package/package.json +19 -8
- package/scripts/mockC64Server.mjs +44 -0
- package/scripts/run-coverage.mjs +27 -1
- package/scripts/smoke-run.sh +3 -2
- package/scripts/start.mjs +23 -1
- package/server.json +53 -3
- package/dist/.tsbuildinfo +0 -1
- package/doc/agent-state-spec.md +0 -243
- package/doc/plans/bun/baseline-metrics.md +0 -16
- package/doc/plans/bun/migration-checklist.md +0 -55
- package/doc/plans/bun/migration.md +0 -83
- package/doc/plans/improve-knowledge/notes.md +0 -43
- package/doc/plans/improve-knowledge/plan.md +0 -121
- package/doc/plans/improve-knowledge/rollout.md +0 -110
- package/doc/plans/meta-tools/README.md +0 -110
- package/doc/plans/meta-tools/analysis.md +0 -272
- package/doc/plans/meta-tools/catalog.md +0 -435
- package/doc/plans/meta-tools/notes.md +0 -192
- package/doc/plans/meta-tools/rollout.md +0 -368
- package/doc/plans/prompts/README.md +0 -96
- package/doc/plans/prompts/user-prompt-ideas.md +0 -147
- package/doc/plans/tool-consolidation/notes.md +0 -28
- package/doc/plans/tool-consolidation/plan.md +0 -169
- package/doc/plans/tool-consolidation/rollout.md +0 -104
- package/doc/plans/vice-backend-switch/plan.md +0 -486
- package/doc/plans/vice-backend-switch-2/plan.md +0 -48
- package/doc/plans/vice-support/notes.md +0 -63
- package/doc/plans/vice-support/plan.md +0 -88
- package/doc/plans/vice-support/rollout.md +0 -99
- package/doc/reviews/audit.md +0 -143
- package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
- package/doc/reviews/hardening/01-fable/prompt.md +0 -98
- package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
- package/doc/reviews/hardening/01-fable/review.md +0 -1234
- package/doc/reviews/vice-support-review.md +0 -185
- 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 =
|
|
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
|
};
|
package/dist/prompts/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/tools/debug.js
CHANGED
|
@@ -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
|
{
|
package/dist/tools/developer.js
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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(
|
|
432
|
+
return textResult(`Configuration${category ? ` category ${category}` : ""} reset to defaults.`, {
|
|
392
433
|
success: true,
|
|
393
434
|
details: toRecord(result.details) ?? null,
|
|
394
435
|
});
|