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.
- package/AGENTS.md +1 -1
- package/README.md +3 -3
- package/data/sound/sidwave.md +113 -36
- package/data/sound/sidwave.schema.json +138 -28
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +83 -93
- package/dist/chargen.js +0 -4
- package/dist/config.js +26 -6
- package/dist/device.js +130 -24
- package/dist/diagnostics.js +14 -5
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +12 -1
- package/dist/mcp-server.js +19 -9
- package/dist/petscii.js +0 -5
- package/dist/redact.js +166 -0
- package/dist/sidwave.js +453 -61
- package/dist/sidwaveCompiler.js +1424 -269
- package/dist/tools/audio.js +70 -12
- package/dist/tools/developer.js +17 -9
- package/dist/tools/errors.js +7 -0
- package/dist/tools/graphics.js +4 -4
- package/dist/tools/input.js +10 -13
- package/dist/tools/machineControl.js +7 -7
- package/dist/tools/memory.js +6 -6
- package/dist/tools/meta/audio.js +8 -2
- package/dist/tools/meta/config.js +5 -4
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/meta/graphics.js +1 -1
- package/dist/tools/meta/memory.js +4 -4
- package/dist/tools/meta/program.js +12 -8
- package/dist/tools/printer.js +4 -4
- package/dist/tools/programRunners.js +8 -8
- package/dist/tools/registry/batch.js +1 -7
- package/dist/tools/registry/disk.js +7 -5
- package/dist/tools/storage.js +21 -17
- package/dist/tools/streaming.js +2 -2
- package/dist/tools/translation/assembler.js +44 -12
- package/dist/tools/translation/basicTokenizer.js +5 -1
- package/dist/tools/translation/opcodeInventory.js +1 -1
- package/dist/tools/vice.js +1 -1
- package/dist/vice/mockServer.js +92 -5
- package/dist/vice/readiness.js +4 -9
- package/dist/vice/viceClient.js +18 -0
- package/doc/audit/secret-handling.md +61 -0
- package/mcp/schemas/c64_config.schema.json +19 -2
- package/mcp/schemas/c64_disk.schema.json +2 -2
- package/mcp/schemas/c64_input.schema.json +3 -3
- package/mcp/schemas/c64_program.schema.json +2 -2
- package/mcp/schemas/c64_vice.schema.json +3 -3
- package/mcp/server.json +1 -1
- package/mcp/tools.json +33 -12
- package/mcp.json +1 -1
- package/package.json +5 -2
- package/scripts/mockC64Server.mjs +15 -1
- package/scripts/run-coverage.mjs +31 -1
- package/scripts/update-readme.ts +3 -2
- package/server.json +20 -3
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
558
|
+
const infoUrl = connectivityProbeUrl(baseUrl);
|
|
554
559
|
let response = null;
|
|
555
560
|
try {
|
|
556
|
-
|
|
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;
|