c64bridge 1.0.7 → 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 +69 -14
- package/data/assembly/assembly-spec.md +2 -0
- package/dist/c64Client.js +238 -60
- package/dist/config.js +48 -26
- package/dist/device.js +92 -37
- package/dist/diagnostics.js +62 -2
- package/dist/hostPaths.js +128 -0
- package/dist/loggingHttpClient.js +59 -1
- package/dist/mcp-server.js +409 -286
- package/dist/numericArg.js +55 -0
- package/dist/petscii.js +13 -1
- package/dist/platform.js +12 -3
- package/dist/prompts/registry.js +6 -1
- package/dist/tools/access.js +66 -0
- package/dist/tools/debug.js +6 -0
- package/dist/tools/developer.js +67 -34
- package/dist/tools/errors.js +54 -3
- package/dist/tools/input.js +23 -6
- package/dist/tools/machineControl.js +112 -20
- 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 +15 -6
- 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 +16 -4
- 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 +25 -475
- 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 +1983 -6910
- package/mcp.json +21 -1
- package/package.json +16 -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 +35 -2
- package/dist/.tsbuildinfo +0 -1
- package/doc/agent-state-spec.md +0 -243
- package/doc/audit/secret-handling.md +0 -61
- 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
package/dist/config.js
CHANGED
|
@@ -24,6 +24,12 @@ export function loadConfig() {
|
|
|
24
24
|
}
|
|
25
25
|
catch (error) {
|
|
26
26
|
if (error.code !== "ENOENT") {
|
|
27
|
+
if (candidatePath === configPath) {
|
|
28
|
+
// An explicitly named file that cannot be used must not silently
|
|
29
|
+
// hand control to another candidate, which may be another machine.
|
|
30
|
+
// The parser message is omitted because it can quote file content.
|
|
31
|
+
throw new Error(`C64BRIDGE_CONFIG file '${candidatePath}' cannot be parsed (${error instanceof Error ? error.name : "unreadable"}); fix or remove it`);
|
|
32
|
+
}
|
|
27
33
|
// Deliberately omit file contents/credentials. Configuration mistakes
|
|
28
34
|
// should not prevent the MCP server from exposing a recoverable path.
|
|
29
35
|
console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.name : "unreadable"}`);
|
|
@@ -86,14 +92,16 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
|
|
|
86
92
|
const envHost = profile === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST;
|
|
87
93
|
const envPort = profile === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT;
|
|
88
94
|
const envPassword = profile === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD;
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
95
|
+
const envHostName = profile === "u2" && process.env.U2_HOST !== undefined ? "U2_HOST" : "C64U_HOST";
|
|
96
|
+
const envPortName = profile === "u2" && process.env.U2_PORT !== undefined ? "U2_PORT" : "C64U_PORT";
|
|
97
|
+
const parsedSectionHost = parseEndpoint(configuredString(section?.host), `${profile}.host`);
|
|
98
|
+
const parsedSectionHostname = parseEndpoint(configuredString(section?.hostname), `${profile}.hostname`);
|
|
99
|
+
const parsedLegacyHost = parseEndpoint(configuredString(legacy?.c64_host), "c64_host");
|
|
100
|
+
const parsedLegacyIp = parseEndpoint(configuredString(legacy?.c64_ip), "c64_ip");
|
|
101
|
+
const parsedEnvHost = parseEndpoint(configuredString(envHost), envHostName);
|
|
94
102
|
const parsedBaseOverrides = [
|
|
95
|
-
parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl))),
|
|
96
|
-
parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl))),
|
|
103
|
+
parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl)), `${profile}.baseUrl`),
|
|
104
|
+
parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl)), "baseUrl"),
|
|
97
105
|
];
|
|
98
106
|
const hostCandidates = [
|
|
99
107
|
parsedEnvHost.hostname,
|
|
@@ -104,12 +112,12 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
|
|
|
104
112
|
...parsedBaseOverrides.map((entry) => entry.hostname),
|
|
105
113
|
];
|
|
106
114
|
const portCandidates = [
|
|
107
|
-
configuredPort(envPort),
|
|
115
|
+
configuredPort(envPort, envPortName),
|
|
108
116
|
parsedEnvHost.port,
|
|
109
|
-
configuredPort(section?.port),
|
|
117
|
+
configuredPort(section?.port, `${profile}.port`),
|
|
110
118
|
parsedSectionHost.port,
|
|
111
119
|
parsedSectionHostname.port,
|
|
112
|
-
configuredPort(legacy?.c64_port),
|
|
120
|
+
configuredPort(legacy?.c64_port, "c64_port"),
|
|
113
121
|
parsedLegacyHost.port,
|
|
114
122
|
parsedLegacyIp.port,
|
|
115
123
|
...parsedBaseOverrides.map((entry) => entry.port),
|
|
@@ -149,17 +157,23 @@ function nonEmptyString(value) {
|
|
|
149
157
|
const trimmed = configuredString(value);
|
|
150
158
|
return trimmed ? trimmed : undefined;
|
|
151
159
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
/** An absent or empty value is "not set"; any other value must be a valid port. */
|
|
161
|
+
function configuredPort(value, source) {
|
|
162
|
+
if (value === undefined || value === null)
|
|
163
|
+
return undefined;
|
|
164
|
+
if (typeof value === "string" && !value.trim())
|
|
165
|
+
return undefined;
|
|
166
|
+
let parsed;
|
|
167
|
+
if (typeof value === "number") {
|
|
168
|
+
parsed = value;
|
|
155
169
|
}
|
|
156
|
-
if (typeof value === "string" && value.trim()) {
|
|
157
|
-
|
|
158
|
-
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
|
159
|
-
return parsed;
|
|
160
|
-
}
|
|
170
|
+
else if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
171
|
+
parsed = Number(value.trim());
|
|
161
172
|
}
|
|
162
|
-
|
|
173
|
+
if (parsed !== undefined && Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Invalid port ${JSON.stringify(typeof value === "string" ? value.trim() : value)} in ${source}: expected an integer from 1 to 65535`);
|
|
163
177
|
}
|
|
164
178
|
function configuredBoolean(value) {
|
|
165
179
|
if (typeof value === "boolean") {
|
|
@@ -189,23 +203,31 @@ function normaliseBaseUrl(value) {
|
|
|
189
203
|
}
|
|
190
204
|
return stripTrailingSlash(input);
|
|
191
205
|
}
|
|
192
|
-
function parseEndpoint(value) {
|
|
206
|
+
function parseEndpoint(value, source) {
|
|
193
207
|
const input = configuredString(value);
|
|
194
208
|
if (!input)
|
|
195
209
|
return {};
|
|
210
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(input);
|
|
211
|
+
// A bare IPv6 address (two or more colons, no brackets) is not a valid URL
|
|
212
|
+
// authority until it is bracketed.
|
|
213
|
+
const bareIpv6 = !hasScheme && !input.startsWith("[") && (input.match(/:/g)?.length ?? 0) >= 2 && !input.includes("/");
|
|
214
|
+
let url;
|
|
196
215
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
216
|
+
url = new URL(hasScheme ? input : `http://${bareIpv6 ? `[${input}]` : input}`);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Drop any user:password@ prefix before echoing the value.
|
|
220
|
+
const shown = input.replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^@/]*@/i, "$1");
|
|
221
|
+
throw new Error(`Invalid host ${JSON.stringify(shown)} in ${source}: expected a host name or IP address, optionally with a port`);
|
|
222
|
+
}
|
|
223
|
+
{
|
|
199
224
|
const hostname = url.hostname || undefined;
|
|
200
|
-
const port = url.port ? configuredPort(url.port) : undefined;
|
|
225
|
+
const port = url.port ? configuredPort(url.port, source) : undefined;
|
|
201
226
|
const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
|
|
202
227
|
// Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
|
|
203
228
|
const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
|
|
204
229
|
return { hostname, port, baseUrl, fullUrl };
|
|
205
230
|
}
|
|
206
|
-
catch {
|
|
207
|
-
return {};
|
|
208
|
-
}
|
|
209
231
|
}
|
|
210
232
|
function stripTrailingSlash(input) {
|
|
211
233
|
return input.replace(/\/+$/, "");
|
package/dist/device.js
CHANGED
|
@@ -4,14 +4,25 @@
|
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { resolveHostPath, DISK_IMAGE_MAX_READ_BYTES } from "./hostPaths.js";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { Api } from "../generated/c64/index.js";
|
|
9
|
-
import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
10
|
+
import { createLoggingHttpClient, RequestQueue } from "./loggingHttpClient.js";
|
|
10
11
|
import { ViceClient } from "./vice/viceClient.js";
|
|
12
|
+
import { resourceNameProblem } from "./vice/resourcePolicy.js";
|
|
11
13
|
import { waitForBasicReady } from "./vice/readiness.js";
|
|
12
14
|
import { screenCodesToAscii } from "./petscii.js";
|
|
13
15
|
import { startViceProcess } from "./vice/process.js";
|
|
14
16
|
import { loadConfig, resolveUltimateEndpoint } from "./config.js";
|
|
17
|
+
let signalDelegate = false;
|
|
18
|
+
/** Stops every VICE process started by this server, synchronously. */
|
|
19
|
+
export function stopManagedViceProcessesSync() {
|
|
20
|
+
ViceBackend.stopAllSupervisorsSync();
|
|
21
|
+
}
|
|
22
|
+
/** Marks that the caller owns signal handling and process exit (see mcp-server shutdown). */
|
|
23
|
+
export function delegateViceSignalHandling() {
|
|
24
|
+
signalDelegate = true;
|
|
25
|
+
}
|
|
15
26
|
const DEFAULT_VICE_HOST = "127.0.0.1";
|
|
16
27
|
const DEFAULT_VICE_PORT = 6502;
|
|
17
28
|
function delay(ms) {
|
|
@@ -56,9 +67,14 @@ function coalesceMemoryWriteBlocks(blocks) {
|
|
|
56
67
|
function readConfigFile() {
|
|
57
68
|
const config = loadConfig();
|
|
58
69
|
const sections = config.backendConfig;
|
|
70
|
+
// An endpoint environment variable selects that backend explicitly, like a
|
|
71
|
+
// config section does, so an offline device is reported as unreachable
|
|
72
|
+
// instead of silently replaced by an emulator.
|
|
73
|
+
const envSelects = (prefix) => ["HOST", "PORT", "PASSWORD"]
|
|
74
|
+
.some((suffix) => (process.env[`${prefix}${suffix}`] ?? "").trim() !== "");
|
|
59
75
|
return {
|
|
60
|
-
c64u: sections.c64u || config.legacyUltimateEndpoint ? config.ultimate.c64u : undefined,
|
|
61
|
-
u2: sections.u2 ? config.ultimate.u2 : undefined,
|
|
76
|
+
c64u: sections.c64u || config.legacyUltimateEndpoint || envSelects("C64U_") ? config.ultimate.c64u : undefined,
|
|
77
|
+
u2: sections.u2 || envSelects("U2_") ? config.ultimate.u2 : undefined,
|
|
62
78
|
vice: sections.vice,
|
|
63
79
|
endpoints: config.ultimate,
|
|
64
80
|
};
|
|
@@ -82,16 +98,53 @@ function driveImage(info) {
|
|
|
82
98
|
function uploadFile(bytes, name) {
|
|
83
99
|
return new File([Uint8Array.from(bytes)], name, { type: "application/octet-stream" });
|
|
84
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Reachability probe for an Ultimate REST endpoint: GET <baseUrl>/v1/version,
|
|
103
|
+
* keeping any reverse-proxy path prefix. Any HTTP response, including 403 or
|
|
104
|
+
* 404, shows that a server answers at the address; only a transport failure
|
|
105
|
+
* (refused, unreachable, timeout) counts as unreachable.
|
|
106
|
+
*/
|
|
107
|
+
export async function probeUltimateReachable(baseUrl, timeout, networkPassword) {
|
|
108
|
+
try {
|
|
109
|
+
await axios.get(`${baseUrl.replace(/\/+$/, "")}/v1/version`, {
|
|
110
|
+
timeout,
|
|
111
|
+
headers: buildC64uHeaders(networkPassword),
|
|
112
|
+
validateStatus: () => true,
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resources that the VICE config inventory (configsList) advertises and that
|
|
122
|
+
* snapshot/restore round-trip. They are vetted here and are not host file
|
|
123
|
+
* names, so c64_config accepts them in addition to the shared c64_vice policy.
|
|
124
|
+
*/
|
|
125
|
+
const VICE_CONFIG_INVENTORY = [
|
|
126
|
+
"SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
|
|
127
|
+
"VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
|
|
128
|
+
];
|
|
129
|
+
/** c64_config on VICE follows the same resource policy as c64_vice. */
|
|
130
|
+
function assertAllowedViceResource(name) {
|
|
131
|
+
if (VICE_CONFIG_INVENTORY.includes(name))
|
|
132
|
+
return;
|
|
133
|
+
const problem = resourceNameProblem(name);
|
|
134
|
+
if (problem)
|
|
135
|
+
throw new Error(`VICE resource '${name}' is not allowed: ${problem.message}`);
|
|
136
|
+
}
|
|
85
137
|
class C64uBackend {
|
|
86
138
|
constructor(endpoint, type = "c64u") {
|
|
87
139
|
this.type = type;
|
|
88
140
|
this.baseUrl = endpoint.baseUrl;
|
|
141
|
+
this.requestQueue = RequestQueue.forDevice(this.baseUrl);
|
|
89
142
|
this.networkPassword = endpoint.networkPassword;
|
|
90
143
|
const http = createLoggingHttpClient({
|
|
91
144
|
baseURL: this.baseUrl,
|
|
92
145
|
timeout: 10000,
|
|
93
146
|
headers: buildC64uHeaders(this.networkPassword),
|
|
94
|
-
});
|
|
147
|
+
}, undefined, this.requestQueue);
|
|
95
148
|
this.api = new Api(http);
|
|
96
149
|
}
|
|
97
150
|
requireC64u(feature) {
|
|
@@ -109,16 +162,7 @@ class C64uBackend {
|
|
|
109
162
|
: { success: true, details: data };
|
|
110
163
|
}
|
|
111
164
|
async ping() {
|
|
112
|
-
|
|
113
|
-
const res = await axios.get(this.baseUrl, {
|
|
114
|
-
timeout: 2000,
|
|
115
|
-
headers: buildC64uHeaders(this.networkPassword),
|
|
116
|
-
});
|
|
117
|
-
return res.status >= 200 && res.status < 500;
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
165
|
+
return this.requestQueue.run(() => probeUltimateReachable(this.baseUrl, 2000, this.networkPassword));
|
|
122
166
|
}
|
|
123
167
|
async runPrg(prg) {
|
|
124
168
|
const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
|
|
@@ -286,9 +330,9 @@ class C64uBackend {
|
|
|
286
330
|
async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(encodeURIComponent(cat), encodeURIComponent(item)) : await this.api.v1.configsDetail(encodeURIComponent(cat)); return res.data; }
|
|
287
331
|
async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(encodeURIComponent(cat), encodeURIComponent(item), { value }); return this.actionResult(res.data); }
|
|
288
332
|
async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
|
|
289
|
-
async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
|
|
290
|
-
async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
|
|
291
|
-
async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return this.actionResult(res.data); }
|
|
333
|
+
async configLoadFromFlash(category) { const res = await this.api.v1.configsLoadFromFlashUpdate(configActionRoute(":load_from_flash", category)); return this.actionResult(res.data); }
|
|
334
|
+
async configSaveToFlash(category) { const res = await this.api.v1.configsSaveToFlashUpdate(configActionRoute(":save_to_flash", category)); return this.actionResult(res.data); }
|
|
335
|
+
async configResetToDefault(category) { const res = await this.api.v1.configsResetToDefaultUpdate(configActionRoute(":reset_to_default", category)); return this.actionResult(res.data); }
|
|
292
336
|
async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeDevicePath(p), ":info"); return res.data; }
|
|
293
337
|
async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeDevicePath(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
|
|
294
338
|
async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeDevicePath(p), ":create_d71", { diskname: options?.diskname }); return this.actionResult(res.data); }
|
|
@@ -297,6 +341,12 @@ class C64uBackend {
|
|
|
297
341
|
async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return this.actionResult(res.data); }
|
|
298
342
|
}
|
|
299
343
|
export class ViceBackend {
|
|
344
|
+
static stopAllSupervisorsSync() {
|
|
345
|
+
for (const [, handle] of ViceBackend.supervisors) {
|
|
346
|
+
handle.stopSync();
|
|
347
|
+
}
|
|
348
|
+
ViceBackend.supervisors.clear();
|
|
349
|
+
}
|
|
300
350
|
constructor(config) {
|
|
301
351
|
this.type = "vice";
|
|
302
352
|
this.debugEnabled = process.env.VICE_DEVICE_TEST_DEBUG === "1";
|
|
@@ -465,15 +515,16 @@ export class ViceBackend {
|
|
|
465
515
|
return;
|
|
466
516
|
}
|
|
467
517
|
ViceBackend.cleanupRegistered = true;
|
|
468
|
-
const cleanupSync =
|
|
469
|
-
for (const [, handle] of ViceBackend.supervisors) {
|
|
470
|
-
handle.stopSync();
|
|
471
|
-
}
|
|
472
|
-
ViceBackend.supervisors.clear();
|
|
473
|
-
};
|
|
518
|
+
const cleanupSync = stopManagedViceProcessesSync;
|
|
474
519
|
process.once("exit", cleanupSync);
|
|
475
520
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
476
521
|
process.once(signal, () => {
|
|
522
|
+
// When the MCP server installed its shutdown handler, it performs the
|
|
523
|
+
// async cleanup, calls stopManagedViceProcessesSync and exits itself;
|
|
524
|
+
// exactly one handler per signal may call process.exit.
|
|
525
|
+
if (signalDelegate) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
477
528
|
cleanupSync();
|
|
478
529
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
479
530
|
});
|
|
@@ -586,9 +637,12 @@ export class ViceBackend {
|
|
|
586
637
|
await this.loadPrgAndFinish(buffer, async () => { });
|
|
587
638
|
return { success: true };
|
|
588
639
|
}
|
|
589
|
-
async loadPrgFile(
|
|
640
|
+
async loadPrgFile(prgPath) {
|
|
641
|
+
const data = fs.readFileSync(resolveHostPath(prgPath, { purpose: "read" }));
|
|
642
|
+
return this.loadPrg(data);
|
|
643
|
+
}
|
|
590
644
|
async runPrgFile(prgPath) {
|
|
591
|
-
const data = fs.readFileSync(prgPath);
|
|
645
|
+
const data = fs.readFileSync(resolveHostPath(prgPath, { purpose: "read" }));
|
|
592
646
|
await this.injectPrg(data);
|
|
593
647
|
return { success: true };
|
|
594
648
|
}
|
|
@@ -785,6 +839,7 @@ export class ViceBackend {
|
|
|
785
839
|
if (!VICE_DISK_IMAGE_EXTENSIONS.has(path.extname(imagePath).toLowerCase())) {
|
|
786
840
|
throw new Error(`VICE can mount only disk images (${[...VICE_DISK_IMAGE_EXTENSIONS].join(", ")}): ${imagePath}`);
|
|
787
841
|
}
|
|
842
|
+
imagePath = resolveHostPath(imagePath, { purpose: "read", maxBytes: DISK_IMAGE_MAX_READ_BYTES });
|
|
788
843
|
if (!fs.existsSync(imagePath)) {
|
|
789
844
|
throw new Error(`Disk image not found on the VICE host: ${imagePath}`);
|
|
790
845
|
}
|
|
@@ -871,10 +926,7 @@ export class ViceBackend {
|
|
|
871
926
|
name: "VICE",
|
|
872
927
|
// Only resources that exist in VICE 3.10; checked against a running x64sc.
|
|
873
928
|
// VICE has no warp, drive-enable or attached-image resources.
|
|
874
|
-
items: [
|
|
875
|
-
"SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
|
|
876
|
-
"VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
|
|
877
|
-
],
|
|
929
|
+
items: [...VICE_CONFIG_INVENTORY],
|
|
878
930
|
},
|
|
879
931
|
],
|
|
880
932
|
};
|
|
@@ -892,12 +944,14 @@ export class ViceBackend {
|
|
|
892
944
|
return values;
|
|
893
945
|
});
|
|
894
946
|
}
|
|
947
|
+
assertAllowedViceResource(item);
|
|
895
948
|
return await this.withClient(async (client) => {
|
|
896
949
|
const res = await client.resourceGet(item);
|
|
897
950
|
return { category: _category, item, value: res.value, type: res.type };
|
|
898
951
|
});
|
|
899
952
|
}
|
|
900
953
|
async configSet(_category, item, value) {
|
|
954
|
+
assertAllowedViceResource(item);
|
|
901
955
|
let parsed = value;
|
|
902
956
|
await this.withClient(async (client) => {
|
|
903
957
|
const current = await client.resourceGet(item);
|
|
@@ -912,6 +966,7 @@ export class ViceBackend {
|
|
|
912
966
|
for (const [category, items] of Object.entries(payload)) {
|
|
913
967
|
for (const [item, value] of Object.entries(items)) {
|
|
914
968
|
try {
|
|
969
|
+
assertAllowedViceResource(item);
|
|
915
970
|
const current = await client.resourceGet(item);
|
|
916
971
|
const parsed = parseViceConfigValue(value, current.type, item);
|
|
917
972
|
await client.resourceSet(item, parsed);
|
|
@@ -950,6 +1005,10 @@ function parseViceConfigValue(value, type, item) {
|
|
|
950
1005
|
return parsed;
|
|
951
1006
|
}
|
|
952
1007
|
function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend`); err.code = "UNSUPPORTED"; return err; }
|
|
1008
|
+
/** Whole-device route suffix, or `/{category}:action` for one category (the generated client prefixes `/v1/configs`). */
|
|
1009
|
+
function configActionRoute(action, category) {
|
|
1010
|
+
return category === undefined ? action : `/${encodeURIComponent(category)}${action}`;
|
|
1011
|
+
}
|
|
953
1012
|
/** Ultimate file APIs treat slash as a path separator. Encode each name once,
|
|
954
1013
|
* preserving leading and nested separators for firmware routing. */
|
|
955
1014
|
function encodeDevicePath(value) {
|
|
@@ -1189,15 +1248,11 @@ export async function createFacade(logger, options) {
|
|
|
1189
1248
|
}
|
|
1190
1249
|
// No configuration
|
|
1191
1250
|
const probeBase = cfg.endpoints.c64u.baseUrl;
|
|
1192
|
-
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
|
|
1197
|
-
return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
|
|
1198
|
-
}
|
|
1251
|
+
if (await probeUltimateReachable(probeBase, 1500, cfg.endpoints.c64u.networkPassword)) {
|
|
1252
|
+
const backend = new C64uBackend(cfg.endpoints.c64u);
|
|
1253
|
+
logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
|
|
1254
|
+
return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
|
|
1199
1255
|
}
|
|
1200
|
-
catch { }
|
|
1201
1256
|
const backend = new ViceBackend(cfg.vice ?? {});
|
|
1202
1257
|
logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
|
|
1203
1258
|
const endpoint = backend.getEndpoint();
|
package/dist/diagnostics.js
CHANGED
|
@@ -36,7 +36,7 @@ export function writeDiagnosticEvent(event, details) {
|
|
|
36
36
|
details: sanitize(details),
|
|
37
37
|
};
|
|
38
38
|
try {
|
|
39
|
-
fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, "utf8");
|
|
39
|
+
fs.appendFileSync(current.info.filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
40
40
|
}
|
|
41
41
|
catch (error) {
|
|
42
42
|
safeStderrWrite(`[diagnostics] failed to append event ${event}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -250,13 +250,73 @@ function ensureState(component) {
|
|
|
250
250
|
}
|
|
251
251
|
function createSessionInfo(component) {
|
|
252
252
|
const directory = resolveDiagnosticsDirectory();
|
|
253
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
253
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
254
254
|
const startedAt = new Date().toISOString().replace(/[:.]/g, "-");
|
|
255
255
|
const safeComponent = component.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "runtime";
|
|
256
256
|
const sessionId = `${startedAt}-${process.pid}`;
|
|
257
257
|
const filePath = path.join(directory, `${sessionId}-${safeComponent}.ndjson`);
|
|
258
|
+
pruneDiagnosticsDirectory(directory, { keep: resolveDiagnosticsKeep(), currentFile: filePath });
|
|
258
259
|
return { component: safeComponent, directory, filePath, sessionId };
|
|
259
260
|
}
|
|
261
|
+
const DEFAULT_DIAGNOSTICS_KEEP = 200;
|
|
262
|
+
const DIAGNOSTICS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
263
|
+
const DIAGNOSTICS_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-[a-z0-9_-]+\.ndjson$/i;
|
|
264
|
+
/** Reset the cached session so a test can start a new one. Not used by the server. */
|
|
265
|
+
export function resetDiagnosticsStateForTesting() {
|
|
266
|
+
state = null;
|
|
267
|
+
}
|
|
268
|
+
function resolveDiagnosticsKeep() {
|
|
269
|
+
const raw = process.env.C64BRIDGE_DIAGNOSTICS_KEEP?.trim();
|
|
270
|
+
if (raw === undefined || raw === "") {
|
|
271
|
+
return DEFAULT_DIAGNOSTICS_KEEP;
|
|
272
|
+
}
|
|
273
|
+
if (!/^\d+$/.test(raw)) {
|
|
274
|
+
safeStderrWrite(`[diagnostics] ignoring invalid C64BRIDGE_DIAGNOSTICS_KEEP="${raw}"; using ${DEFAULT_DIAGNOSTICS_KEEP}\n`);
|
|
275
|
+
return DEFAULT_DIAGNOSTICS_KEEP;
|
|
276
|
+
}
|
|
277
|
+
return Number(raw);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Best-effort retention: delete regular diagnostics files (by naming pattern) beyond the newest `keep`
|
|
281
|
+
* or older than 30 days. Uses lstat so symlinks are never followed; never recurses.
|
|
282
|
+
*/
|
|
283
|
+
export function pruneDiagnosticsDirectory(directory, options) {
|
|
284
|
+
try {
|
|
285
|
+
const now = options.now ?? Date.now();
|
|
286
|
+
const candidates = [];
|
|
287
|
+
for (const name of fs.readdirSync(directory)) {
|
|
288
|
+
if (!DIAGNOSTICS_FILE_PATTERN.test(name))
|
|
289
|
+
continue;
|
|
290
|
+
const file = path.join(directory, name);
|
|
291
|
+
if (options.currentFile && path.resolve(file) === path.resolve(options.currentFile))
|
|
292
|
+
continue;
|
|
293
|
+
try {
|
|
294
|
+
const stat = fs.lstatSync(file);
|
|
295
|
+
if (stat.isFile())
|
|
296
|
+
candidates.push({ file, mtimeMs: stat.mtimeMs });
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// ignore entries that vanish or cannot be inspected
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.file < b.file ? 1 : -1));
|
|
303
|
+
// The current session's file counts toward the kept total.
|
|
304
|
+
const keepOthers = Math.max(0, options.keep - (options.currentFile ? 1 : 0));
|
|
305
|
+
candidates.forEach((entry, index) => {
|
|
306
|
+
if (index >= keepOthers || now - entry.mtimeMs > DIAGNOSTICS_MAX_AGE_MS) {
|
|
307
|
+
try {
|
|
308
|
+
fs.unlinkSync(entry.file);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// best effort
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// best effort: an unreadable directory must not stop diagnostics
|
|
318
|
+
}
|
|
319
|
+
}
|
|
260
320
|
function resolveDiagnosticsDirectory() {
|
|
261
321
|
const configured = process.env.C64BRIDGE_DIAGNOSTICS_DIR?.trim();
|
|
262
322
|
if (configured) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Confinement of host file paths that originate from tool arguments.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ToolValidationError } from "./tools/errors.js";
|
|
6
|
+
export const DEFAULT_MAX_READ_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const DISK_IMAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
export const ALLOWED_DIRS_ENV = "C64BRIDGE_ALLOWED_DIRS";
|
|
9
|
+
/** True when candidate equals root or lies inside it. Both must be absolute. */
|
|
10
|
+
export function isWithin(root, candidate) {
|
|
11
|
+
const rel = path.relative(root, candidate);
|
|
12
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
/** Real path of the deepest existing ancestor, with the nonexistent remainder appended. */
|
|
15
|
+
function realpathOfDeepest(target) {
|
|
16
|
+
const pending = [];
|
|
17
|
+
let current = target;
|
|
18
|
+
for (;;) {
|
|
19
|
+
try {
|
|
20
|
+
return path.join(fs.realpathSync(current), ...pending.reverse());
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const code = error.code;
|
|
24
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
25
|
+
throw error;
|
|
26
|
+
let isLink = false;
|
|
27
|
+
try {
|
|
28
|
+
isLink = fs.lstatSync(current).isSymbolicLink();
|
|
29
|
+
}
|
|
30
|
+
catch { /* does not exist */ }
|
|
31
|
+
// A dangling symlink would be followed by a later write, so its target cannot be vetted.
|
|
32
|
+
if (isLink)
|
|
33
|
+
throw new ToolValidationError(`Host path is a dangling symbolic link: ${current}`, { path: current });
|
|
34
|
+
const parent = path.dirname(current);
|
|
35
|
+
if (parent === current)
|
|
36
|
+
return path.join(current, ...pending.reverse());
|
|
37
|
+
pending.push(path.basename(current));
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function safeRealpath(p) {
|
|
43
|
+
try {
|
|
44
|
+
return realpathOfDeepest(path.resolve(p));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return path.resolve(p);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function homeDirectory() {
|
|
51
|
+
return process.env.HOME || os.homedir();
|
|
52
|
+
}
|
|
53
|
+
/** Allowed roots (real paths). Reads cwd, environment and home directory at call time. */
|
|
54
|
+
export function allowedHostRoots() {
|
|
55
|
+
const roots = [];
|
|
56
|
+
const cwd = safeRealpath(process.cwd());
|
|
57
|
+
const home = safeRealpath(homeDirectory());
|
|
58
|
+
if (cwd !== home && cwd !== path.parse(cwd).root)
|
|
59
|
+
roots.push(cwd);
|
|
60
|
+
roots.push(safeRealpath(path.join(homeDirectory(), ".c64bridge")));
|
|
61
|
+
roots.push(safeRealpath(os.tmpdir()));
|
|
62
|
+
const extra = process.env[ALLOWED_DIRS_ENV];
|
|
63
|
+
if (extra) {
|
|
64
|
+
for (const entry of extra.split(path.delimiter)) {
|
|
65
|
+
const trimmed = entry.trim();
|
|
66
|
+
if (trimmed && trimmed !== "*")
|
|
67
|
+
roots.push(safeRealpath(trimmed));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...new Set(roots)];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a file name that the server appends to an already resolved directory.
|
|
74
|
+
* The final path is checked again: a symbolic link planted at that name in a
|
|
75
|
+
* shared directory (for example the temp directory) must not redirect the write
|
|
76
|
+
* outside the allowed roots.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveHostChildPath(dir, ...names) {
|
|
79
|
+
return resolveHostPath(path.join(dir, ...names), { purpose: "write" });
|
|
80
|
+
}
|
|
81
|
+
/** Default output directory under the working directory, or under ~/.c64bridge when the working directory is not an allowed root. */
|
|
82
|
+
export function defaultHostOutputDir(...names) {
|
|
83
|
+
try {
|
|
84
|
+
return resolveHostPath(path.join(process.cwd(), ...names), { purpose: "write" });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!(error instanceof ToolValidationError))
|
|
88
|
+
throw error;
|
|
89
|
+
return resolveHostPath(path.join(homeDirectory(), ".c64bridge", ...names), { purpose: "write" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function confinementDisabled() {
|
|
93
|
+
const extra = process.env[ALLOWED_DIRS_ENV];
|
|
94
|
+
return !!extra && extra.trim() === "*";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a tool-supplied path to an absolute real path inside the allowed roots.
|
|
98
|
+
* Throws ToolValidationError for paths outside the roots, dangling symlinks and oversized reads.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveHostPath(input, options) {
|
|
101
|
+
if (typeof input !== "string" || input.length === 0 || input.includes("\0")) {
|
|
102
|
+
throw new ToolValidationError("Host path must be a non-empty string without NUL bytes.");
|
|
103
|
+
}
|
|
104
|
+
const resolved = path.resolve(input);
|
|
105
|
+
const disabled = !options.roots && confinementDisabled();
|
|
106
|
+
const real = disabled ? resolved : realpathOfDeepest(resolved);
|
|
107
|
+
if (!disabled) {
|
|
108
|
+
const roots = options.roots ? options.roots.map(safeRealpath) : allowedHostRoots();
|
|
109
|
+
if (!roots.some((root) => isWithin(root, real))) {
|
|
110
|
+
throw new ToolValidationError(`Host path is outside the allowed directories: ${resolved}. Allowed roots: ${roots.join(", ")}. `
|
|
111
|
+
+ `Add directories with the ${ALLOWED_DIRS_ENV} environment variable (entries separated by "${path.delimiter}"; "*" disables confinement).`, { path: resolved, details: { allowedRoots: roots, env: ALLOWED_DIRS_ENV } });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (options.purpose === "read") {
|
|
115
|
+
const limit = options.maxBytes ?? DEFAULT_MAX_READ_BYTES;
|
|
116
|
+
let size;
|
|
117
|
+
try {
|
|
118
|
+
size = fs.statSync(real).size;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
size = undefined;
|
|
122
|
+
}
|
|
123
|
+
if (size !== undefined && size > limit) {
|
|
124
|
+
throw new ToolValidationError(`Host file is too large to read: ${resolved} is ${size} bytes, limit is ${limit} bytes.`, { path: resolved });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return real;
|
|
128
|
+
}
|
|
@@ -13,7 +13,53 @@ function redactHeaders(headers) {
|
|
|
13
13
|
sensitive.test(key) ? "***" : value,
|
|
14
14
|
]));
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* First-in first-out gate that lets one operation run at a time. The Ultimate
|
|
18
|
+
* firmware serves REST requests on a single thread, so every request to one
|
|
19
|
+
* device goes through one queue. A failed operation releases the gate like a
|
|
20
|
+
* successful one, so the queue cannot deadlock.
|
|
21
|
+
*/
|
|
22
|
+
export class RequestQueue {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.tail = Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
/** The shared queue for one device base URL, so every client of that device (backends, fallback clients) waits in the same line. */
|
|
27
|
+
static forDevice(baseUrl) {
|
|
28
|
+
const key = baseUrl.replace(/\/+$/, "").toLowerCase();
|
|
29
|
+
let queue = RequestQueue.byDevice.get(key);
|
|
30
|
+
if (!queue) {
|
|
31
|
+
queue = new RequestQueue();
|
|
32
|
+
RequestQueue.byDevice.set(key, queue);
|
|
33
|
+
}
|
|
34
|
+
return queue;
|
|
35
|
+
}
|
|
36
|
+
/** Resolves when all earlier holders have released; call the returned function exactly once. */
|
|
37
|
+
async acquire() {
|
|
38
|
+
let release;
|
|
39
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
40
|
+
const previous = this.tail;
|
|
41
|
+
this.tail = previous.then(() => held);
|
|
42
|
+
await previous;
|
|
43
|
+
let released = false;
|
|
44
|
+
return () => {
|
|
45
|
+
if (!released) {
|
|
46
|
+
released = true;
|
|
47
|
+
release();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async run(operation) {
|
|
52
|
+
const release = await this.acquire();
|
|
53
|
+
try {
|
|
54
|
+
return await operation();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
RequestQueue.byDevice = new Map();
|
|
62
|
+
export function createLoggingHttpClient(config, logger = loggerFor("c64u"), queue) {
|
|
17
63
|
const http = new HttpClient(config);
|
|
18
64
|
registerSecret(readPasswordHeader(config?.headers));
|
|
19
65
|
http.instance.interceptors.request.use((request) => {
|
|
@@ -30,6 +76,18 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
|
|
|
30
76
|
request.__c64Meta = meta;
|
|
31
77
|
return request;
|
|
32
78
|
});
|
|
79
|
+
if (queue) {
|
|
80
|
+
// The generated client sends every request through `instance.request`, so
|
|
81
|
+
// the gate is taken around that call. Interceptors, request transforms and
|
|
82
|
+
// the adapter all run inside it: the start time is recorded after the wait,
|
|
83
|
+
// the request timeout (started by the adapter) does not include the wait,
|
|
84
|
+
// and the gate is released however the request settles, including a
|
|
85
|
+
// failure before the adapter is called (for example a body that cannot be
|
|
86
|
+
// serialised).
|
|
87
|
+
const instance = http.instance;
|
|
88
|
+
const sendRequest = instance.request.bind(instance);
|
|
89
|
+
instance.request = ((requestConfig) => queue.run(() => sendRequest(requestConfig)));
|
|
90
|
+
}
|
|
33
91
|
http.instance.interceptors.response.use((response) => {
|
|
34
92
|
handleResponse(response, logger);
|
|
35
93
|
return response;
|