@henols/c64-re-tools 1.1.0 → 2.0.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -37
  3. package/distribution/package.ts +21 -0
  4. package/distribution/plugin.ts +122 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +76 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +110 -0
  8. package/skills/c64-assembler/scripts/compare.ts +84 -0
  9. package/skills/c64-basic/SKILL.md +66 -0
  10. package/skills/c64-basic/scripts/basic.ts +46 -0
  11. package/skills/c64-disk/SKILL.md +93 -0
  12. package/skills/c64-disk/scripts/containers.ts +124 -0
  13. package/skills/c64-disk/scripts/disk.ts +120 -0
  14. package/skills/c64-emulator/SKILL.md +134 -0
  15. package/skills/c64-knowledge/SKILL.md +192 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +613 -0
  17. package/skills/c64-memory-map/SKILL.md +79 -0
  18. package/skills/c64-memory-map/scripts/memmap.ts +92 -0
  19. package/skills/c64-provenance/SKILL.md +110 -0
  20. package/skills/c64-provenance/scripts/compare.ts +152 -0
  21. package/skills/c64-provenance/scripts/provenance.ts +334 -0
  22. package/skills/c64-provenance/scripts/runtime.ts +191 -0
  23. package/skills/c64-provenance/scripts/separate.ts +140 -0
  24. package/skills/c64-reverse-engineering/SKILL.md +144 -0
  25. package/skills/c64-source/SKILL.md +133 -0
  26. package/skills/c64-source/scripts/generate.ts +503 -0
  27. package/skills/c64-source/scripts/loader.ts +589 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +673 -0
  30. package/skills/c64-static-analysis/SKILL.md +120 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +293 -0
  32. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  33. package/skills/c64-static-analysis/scripts/findings.ts +75 -0
  34. package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
  35. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  36. package/skills/c64-static-analysis/scripts/pointers.ts +108 -0
  37. package/skills/c64-testing/SKILL.md +103 -0
  38. package/skills/c64-testing/references/scenario-format.md +116 -0
  39. package/skills/c64-testing/scripts/checklist.ts +51 -0
  40. package/skills/c64-testing/scripts/run.ts +189 -0
  41. package/skills/c64-testing/scripts/scenario.ts +493 -0
  42. package/skills/c64-testing/scripts/test.ts +65 -0
  43. package/skills/c64-unpacker/SKILL.md +135 -0
  44. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  45. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  46. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  47. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  48. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  49. package/src/c64.ts +411 -0
  50. package/src/cli/ap-sdk.ts +44 -0
  51. package/src/cli/cleanup.ts +194 -0
  52. package/src/cli/main.ts +125 -0
  53. package/src/cli/status.ts +61 -0
  54. package/src/host/main.ts +134 -0
  55. package/src/host/server.ts +392 -0
  56. package/src/host/tools/c1541.ts +284 -0
  57. package/src/host/tools/index.ts +35 -0
  58. package/src/host/tools/petcat.ts +77 -0
  59. package/src/host/tools/status.ts +47 -0
  60. package/src/host/tools/version.ts +41 -0
  61. package/src/host/vice/adapter.ts +804 -0
  62. package/src/host/vice/binary-monitor.ts +389 -0
  63. package/src/host/vice/chips.ts +118 -0
  64. package/src/host/vice/connect.ts +34 -0
  65. package/src/host/vice/process.ts +413 -0
  66. package/src/host/vice/screen.ts +184 -0
  67. package/src/host/vice/session.ts +1426 -0
  68. package/src/host/vice/text-monitor.ts +223 -0
  69. package/src/host-client/autostart.ts +55 -0
  70. package/src/host-client/connect.ts +241 -0
  71. package/src/host-client/coverage.ts +63 -0
  72. package/src/host-client/image.ts +63 -0
  73. package/src/host-client/steps.ts +170 -0
  74. package/src/host-client/tools.ts +89 -0
  75. package/src/host-client/transfer.ts +126 -0
  76. package/src/host-client/vice-session.ts +316 -0
  77. package/src/knowledge/database.ts +132 -0
  78. package/src/knowledge/expression.ts +209 -0
  79. package/src/knowledge/history.ts +169 -0
  80. package/src/knowledge/import.ts +392 -0
  81. package/src/knowledge/read.ts +392 -0
  82. package/src/knowledge/schema.ts +250 -0
  83. package/src/knowledge/write.ts +837 -0
  84. package/src/mcp/main.ts +134 -0
  85. package/src/mcp/output.ts +32 -0
  86. package/src/mcp/server.ts +316 -0
  87. package/src/mcp/tools/debug.ts +327 -0
  88. package/src/mcp/tools/execution.ts +117 -0
  89. package/src/mcp/tools/input.ts +72 -0
  90. package/src/mcp/tools/machine.ts +100 -0
  91. package/src/mcp/tools/media.ts +129 -0
  92. package/src/mcp/tools/memory.ts +250 -0
  93. package/src/mcp/tools/video.ts +271 -0
  94. package/src/native/acme.ts +116 -0
  95. package/src/native/discover.ts +179 -0
  96. package/src/native/dxa.ts +166 -0
  97. package/src/native/ghidra/analyze.ts +86 -0
  98. package/src/native/ghidra/index.ts +173 -0
  99. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  100. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  101. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  102. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  103. package/src/native/ghidra/scripts/C64Export.java +274 -0
  104. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  105. package/src/native/local.ts +34 -0
  106. package/src/native/processes.ts +350 -0
  107. package/src/native/run.ts +172 -0
  108. package/src/native/staging.ts +83 -0
  109. package/src/native/status.ts +113 -0
  110. package/src/native/types.ts +257 -0
  111. package/src/native/watchdog.ts +78 -0
  112. package/src/package.ts +21 -0
  113. package/src/platform.ts +441 -0
  114. package/src/project.ts +39 -0
  115. package/src/protocol/framing.ts +132 -0
  116. package/src/protocol/messages.ts +259 -0
  117. package/src/protocol/tools.ts +266 -0
  118. package/src/protocol/vice.ts +1348 -0
  119. package/src/script.ts +60 -0
  120. package/src/time.ts +15 -0
  121. package/src/trace.ts +194 -0
  122. package/THIRD-PARTY-NOTICES.md +0 -26
  123. package/bin/cli.mjs +0 -283
  124. package/skills/acme-build/SKILL.md +0 -260
  125. package/skills/acme-build/scripts/acme.mjs +0 -341
  126. package/skills/acme-build/template.a +0 -53
  127. package/skills/c64-disk-access/SKILL.md +0 -159
  128. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  129. package/skills/c64-memory-mapping/SKILL.md +0 -631
  130. package/skills/c64-memory-mapping/memmap.json +0 -8800
  131. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  132. package/skills/c64-petcat/SKILL.md +0 -87
  133. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  134. package/skills/c64-program-recon/SKILL.md +0 -706
  135. package/skills/c64-program-recon/references/control-flow.md +0 -180
  136. package/skills/c64-program-recon/references/graphics.md +0 -79
  137. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  138. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  139. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  140. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  141. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  142. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  143. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  144. package/skills/c64-provenance-diff/SKILL.md +0 -292
  145. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  146. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  147. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  148. package/skills/c64-ram-capture/SKILL.md +0 -474
  149. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  150. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  151. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  152. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  153. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  154. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  155. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  156. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  157. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  158. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  159. package/skills/c64-ram-capture/transients/README.md +0 -137
  160. package/skills/routine-queue-walker/SKILL.md +0 -394
  161. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  162. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,350 @@
1
+ import { spawn, spawnSync, type ChildProcess, type StdioOptions } from "node:child_process";
2
+ import type { Writable } from "node:stream";
3
+ import { mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+
11
+ /** How long a process group gets between SIGTERM and SIGKILL. */
12
+ export const STOP_GRACE_MS = 5_000;
13
+ /** How long to wait for the group to vanish after SIGKILL before giving up. */
14
+ const KILL_WAIT_MS = 2_000;
15
+ const POLL_MS = 25;
16
+
17
+ export interface SpawnOptions {
18
+ cwd?: string;
19
+ env?: NodeJS.ProcessEnv | undefined;
20
+ stdio?: StdioOptions;
21
+ }
22
+
23
+ export interface ExitStatus {
24
+ code: number | null;
25
+ signal: NodeJS.Signals | null;
26
+ /** The error code, for example ENOENT, when the program did not start. */
27
+ spawnError?: string;
28
+ }
29
+
30
+ /** A child started in its own process group. Stopping it stops every descendant in that group. */
31
+ export interface SupervisedProcess {
32
+ readonly pid: number;
33
+ readonly child: ChildProcess;
34
+ /** Settles when the group leader exits, for any reason. */
35
+ readonly exited: Promise<ExitStatus>;
36
+ /** Terminates the whole process group: SIGTERM, then SIGKILL after the grace period. */
37
+ stop(): Promise<void>;
38
+ }
39
+
40
+ /**
41
+ * A process's state letter and process group on Linux, from /proc; undefined
42
+ * when it is gone. A zombie (Z) or a dead process (X) has exited: only the
43
+ * wait of its parent is missing. In a container whose PID 1 never reaps (for
44
+ * example `tail -f /dev/null`), an orphan stays a zombie for ever.
45
+ */
46
+ function linuxProcess(pid: number | string): { state: string; group: number } | undefined {
47
+ try {
48
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
49
+ const [state, , group] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
50
+ return state === undefined ? undefined : { state, group: Number(group) };
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ const exitedState = (state: string) => state === "Z" || state === "X";
57
+
58
+ /** True while a process runs, also one this process may not signal (EPERM). A zombie does not run. */
59
+ export function isAlive(pid: number): boolean {
60
+ try {
61
+ process.kill(pid, 0);
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
64
+ }
65
+ if (process.platform !== "linux") return true;
66
+ const found = linuxProcess(pid);
67
+ return found !== undefined && !exitedState(found.state);
68
+ }
69
+
70
+ /** Linux: true while a member of the process group runs; zombies do not count. */
71
+ function groupRuns(pgid: number): boolean {
72
+ for (const entry of readdirSync("/proc")) {
73
+ if (!/^\d+$/.test(entry)) continue;
74
+ const found = linuxProcess(entry);
75
+ if (found !== undefined && found.group === pgid && !exitedState(found.state)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Quotes one argument as the Microsoft C runtime and Java read it:
82
+ * backslashes before a quote and at the end are doubled, and an inner quote
83
+ * is escaped. A path that ends in a backslash then cannot swallow the
84
+ * arguments after it.
85
+ */
86
+ export function windowsQuote(arg: string): string {
87
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
88
+ }
89
+
90
+ /**
91
+ * On Windows a .bat or .cmd file (Ghidra's analyzeHeadless.bat, for example)
92
+ * starts only through cmd.exe: Node refuses it without a shell (EINVAL). /s
93
+ * keeps the quoted command line as it is. Undefined for any other program.
94
+ */
95
+ export function batchInvocation(argv: readonly string[], comSpec = "cmd.exe"): { command: string; args: string[] } | undefined {
96
+ if (!/\.(bat|cmd)$/i.test(argv[0] ?? "")) return undefined;
97
+ const quote = windowsQuote;
98
+ return { command: comSpec, args: ["/d", "/s", "/c", `"${argv.map(quote).join(" ")}"`] };
99
+ }
100
+
101
+ /** How long taskkill may block: it runs synchronously, so without a limit it could stop this whole process. */
102
+ const TASKKILL_LIMIT_MS = 5_000;
103
+
104
+ /**
105
+ * Ends a process and every descendant on Windows, which has no process
106
+ * groups. taskkill finds the descendants through the process, so it must run
107
+ * while the process still runs. Returns undefined when taskkill ended the tree
108
+ * or found no such process, else why it failed.
109
+ */
110
+ export function killTree(pid: number): string | undefined {
111
+ const result = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: TASKKILL_LIMIT_MS });
112
+ if (result.error !== undefined) return `taskkill did not run (${(result.error as NodeJS.ErrnoException).code ?? result.error.message})`;
113
+ // taskkill exits with 128 when no process has this pid: the process has ended.
114
+ if (result.status === 0 || result.status === 128) return undefined;
115
+ return `taskkill ended with exit code ${result.status ?? result.signal}`;
116
+ }
117
+
118
+ /** True when Node has seen the child's exit. */
119
+ const hasExited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null;
120
+
121
+ /** Sends a signal to a process group (POSIX). Returns false when the group does not exist. */
122
+ export function signalGroup(pgid: number, signal: NodeJS.Signals | 0): boolean {
123
+ try {
124
+ process.kill(-pgid, signal);
125
+ return true;
126
+ } catch (error) {
127
+ const code = (error as NodeJS.ErrnoException).code;
128
+ // macOS answers EPERM for a group whose members are all zombies, which is a stopped group too.
129
+ if (code === "ESRCH" || (code === "EPERM" && process.platform === "darwin")) return false;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ /** True while a process group has a running member (POSIX); a group of zombies has ended. */
135
+ export function isGroupRunning(pgid: number): boolean {
136
+ return signalGroup(pgid, 0) && (process.platform !== "linux" || groupRuns(pgid));
137
+ }
138
+
139
+ async function waitForGroupGone(pgid: number, timeoutMs: number): Promise<boolean> {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (isGroupRunning(pgid)) {
142
+ if (Date.now() >= deadline) return false;
143
+ await sleep(POLL_MS);
144
+ }
145
+ return true;
146
+ }
147
+
148
+ /** What the watchdog must clean up if the Host Runtime dies: process groups and paths. */
149
+ export interface Registry {
150
+ groups: number[];
151
+ paths: string[];
152
+ }
153
+
154
+ /** The watchdog's program, next to this module. A new URL literal, so an installed skill gets it too. */
155
+ const WATCHDOG = fileURLToPath(new URL("./watchdog.ts", import.meta.url));
156
+
157
+ /**
158
+ * The one child-process supervisor of a process: the Host Runtime, or a
159
+ * skill script that runs its own tools. Every emulator and native tool
160
+ * is started here, with an argv array and never a shell string,
161
+ * as the leader of its own process group. It also owns scratch paths, so
162
+ * that they go away with the processes that used them.
163
+ */
164
+ export class ProcessSupervisor {
165
+ readonly #children = new Set<SupervisedProcess>();
166
+ readonly #paths = new Set<string>();
167
+ readonly #graceMs: number;
168
+ #exitGuardInstalled = false;
169
+ /** The registry file that the watchdog reads, once a watchdog runs. */
170
+ #registry: string | undefined;
171
+ #watchdogPid: number | undefined;
172
+ /** The writing end of the watchdog's pipe. Kept open until this process ends: its end tells the watchdog that this process is gone. */
173
+ #watchdogPipe: Writable | undefined;
174
+
175
+ constructor(options: { graceMs?: number } = {}) {
176
+ this.#graceMs = options.graceMs ?? STOP_GRACE_MS;
177
+ }
178
+
179
+ /** Number of process groups that have not been stopped yet. */
180
+ get size(): number {
181
+ return this.#children.size;
182
+ }
183
+
184
+ /** The process id of the watchdog, once a watchdog runs. */
185
+ get watchdogPid(): number | undefined {
186
+ return this.#watchdogPid;
187
+ }
188
+
189
+ /** Starts argv[0] with argv[1..] as the leader of a new process group. */
190
+ spawn(argv: readonly string[], options: SpawnOptions = {}): SupervisedProcess {
191
+ const program = argv[0];
192
+ if (program === undefined) throw new TypeError("argv must name a command");
193
+ const batch = process.platform === "win32" ? batchInvocation(argv, process.env.ComSpec) : undefined;
194
+ const { command, args } = batch ?? { command: program, args: argv.slice(1) };
195
+ let child: ChildProcess;
196
+ try {
197
+ child = spawn(command, args, {
198
+ cwd: options.cwd,
199
+ env: options.env,
200
+ stdio: options.stdio ?? "ignore",
201
+ detached: process.platform !== "win32",
202
+ windowsHide: true,
203
+ ...(batch === undefined ? {} : { windowsVerbatimArguments: true }),
204
+ });
205
+ } catch (error) {
206
+ // Some failures throw at once instead of an "error" event: a file Windows cannot start
207
+ // (EFTYPE, for example a broken build) or a .bat without a shell (EINVAL).
208
+ const code = (error as NodeJS.ErrnoException).code ?? "an unknown error";
209
+ throw new WireFailure("installation-incomplete", `${program} is not a program that this system can start (${code}). Install it again.`);
210
+ }
211
+ const exited = new Promise<ExitStatus>((resolve) => {
212
+ child.once("exit", (code, signal) => resolve({ code, signal }));
213
+ // A failed spawn emits "error" and never "exit". A later "error" (a failed kill) changes nothing:
214
+ // the listener stays, so that such an error does not end this process.
215
+ child.on("error", (error: NodeJS.ErrnoException) => {
216
+ if (child.pid === undefined) resolve({ code: null, signal: null, spawnError: error.code ?? "an unknown error" });
217
+ });
218
+ });
219
+ if (child.pid === undefined) {
220
+ // Spawn failed (for example ENOENT); "error" follows asynchronously.
221
+ const failed: SupervisedProcess = { pid: -1, child, exited, stop: async () => {} };
222
+ return failed;
223
+ }
224
+
225
+ const pid = child.pid;
226
+ let stopping: Promise<void> | undefined;
227
+ const supervised: SupervisedProcess = {
228
+ pid,
229
+ child,
230
+ exited,
231
+ stop: () => {
232
+ stopping ??= this.#stopGroup(pid, child, exited).then(
233
+ () => {
234
+ this.#children.delete(supervised);
235
+ this.#writeRegistry();
236
+ },
237
+ (error: unknown) => {
238
+ // The group may still run: it stays registered for the exit guard and the watchdog, and a later stop tries again.
239
+ stopping = undefined;
240
+ throw error;
241
+ },
242
+ );
243
+ return stopping;
244
+ },
245
+ };
246
+ this.#children.add(supervised);
247
+ this.#writeRegistry();
248
+ return supervised;
249
+ }
250
+
251
+ /** Makes a scratch path part of what this supervisor cleans up. Returns the release for a normal removal. */
252
+ ownPath(path: string): () => void {
253
+ this.#paths.add(path);
254
+ this.#writeRegistry();
255
+ return () => {
256
+ this.#paths.delete(path);
257
+ this.#writeRegistry();
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Starts a watchdog process that cleans up if this process dies without
263
+ * running its exit guard, for example by SIGKILL: it stops every
264
+ * registered process group and removes every owned path. It ends by itself
265
+ * when this process exits normally. Its registry directory names the owner.
266
+ */
267
+ startWatchdog(owner: "host" | "skill-script"): void {
268
+ if (this.#registry !== undefined) return;
269
+ const directory = mkdtempSync(join(tmpdir(), `c64-re-tools-${owner}-watchdog-`));
270
+ this.#registry = join(directory, "registry.json");
271
+ this.#writeRegistry();
272
+ // The watchdog runs in the temporary directory, never in the caller's directory: Windows refuses
273
+ // to remove a directory that a running process uses. Its standard input is a pipe from this
274
+ // process, which the system closes when this process ends.
275
+ const watchdog = spawn(process.execPath, [...process.execArgv, WATCHDOG, String(process.pid), this.#registry], { cwd: tmpdir(), detached: true, stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
276
+ watchdog.unref();
277
+ // The pipe does not keep this process running, and an error on it does not end this process.
278
+ const pipe = watchdog.stdin as (Writable & { unref?: () => void }) | null;
279
+ pipe?.on("error", () => {});
280
+ pipe?.unref?.();
281
+ this.#watchdogPipe = pipe ?? undefined;
282
+ this.#watchdogPid = watchdog.pid;
283
+ this.installExitGuard();
284
+ }
285
+
286
+ #writeRegistry(): void {
287
+ if (this.#registry === undefined) return;
288
+ const registry: Registry = { groups: [...this.#children].map((child) => child.pid), paths: [...this.#paths] };
289
+ // Write and rename, so the watchdog never reads half a file.
290
+ writeFileSync(`${this.#registry}.next`, JSON.stringify(registry));
291
+ renameSync(`${this.#registry}.next`, this.#registry);
292
+ }
293
+
294
+ /** Stops every process group this supervisor started. */
295
+ async stopAll(): Promise<void> {
296
+ await Promise.all([...this.#children].map((child) => child.stop()));
297
+ }
298
+
299
+ /**
300
+ * SIGKILLs every remaining group and removes every owned path, synchronously.
301
+ * For process "exit", where nothing async runs. The watchdog then ends.
302
+ */
303
+ killAllSync(): void {
304
+ for (const supervised of this.#children) {
305
+ // On Windows the pid of an ended leader can belong to another process now.
306
+ if (process.platform === "win32") {
307
+ if (!hasExited(supervised.child)) killTree(supervised.pid);
308
+ } else signalGroup(supervised.pid, "SIGKILL");
309
+ }
310
+ this.#children.clear();
311
+ for (const path of this.#paths) rmSync(path, { recursive: true, force: true });
312
+ this.#paths.clear();
313
+ if (this.#registry !== undefined) rmSync(join(this.#registry, ".."), { recursive: true, force: true });
314
+ }
315
+
316
+ /** Makes any exit of this process, however it happens, SIGKILL the remaining groups. Idempotent. */
317
+ installExitGuard(): void {
318
+ if (this.#exitGuardInstalled) return;
319
+ this.#exitGuardInstalled = true;
320
+ process.on("exit", () => this.killAllSync());
321
+ }
322
+
323
+ async #stopGroup(pid: number, child: ChildProcess, exited: Promise<ExitStatus>): Promise<void> {
324
+ if (process.platform === "win32") {
325
+ // Windows has no SIGTERM for a console process. taskkill finds the descendants through the
326
+ // leader, so it runs first, and only while the leader runs: the pid of an ended leader can
327
+ // belong to another process. child.kill then ends the leader through its own handle: after
328
+ // taskkill alone, Node does not always report the leader's exit. The exit wait has a limit.
329
+ const failure = hasExited(child) ? undefined : killTree(pid);
330
+ try {
331
+ child.kill("SIGKILL");
332
+ } catch {
333
+ // The leader has ended already.
334
+ }
335
+ await Promise.race([exited, sleep(this.#graceMs)]);
336
+ if (!hasExited(child)) throw new Error(`process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
+ return;
338
+ }
339
+ // The group outlives its leader when descendants remain, so wait on the group, not the leader.
340
+ if (signalGroup(pid, "SIGTERM") && !(await waitForGroupGone(pid, this.#graceMs))) {
341
+ signalGroup(pid, "SIGKILL");
342
+ if (!(await waitForGroupGone(pid, KILL_WAIT_MS))) {
343
+ throw new Error(`process group ${pid} survived SIGKILL`);
344
+ }
345
+ }
346
+ // A group of zombies counts as gone before Node reaps the leader and reports its exit;
347
+ // stopped means that Node has seen that exit.
348
+ await Promise.race([exited, sleep(KILL_WAIT_MS)]);
349
+ }
350
+ }
@@ -0,0 +1,172 @@
1
+ // Bounded native-tool execution: argv only, no shell, a fixed
2
+ // timeout, capped output, and the whole process group stopped on timeout or abort.
3
+
4
+ import { realpathSync } from "node:fs";
5
+ import { sep } from "node:path";
6
+ import { StringDecoder } from "node:string_decoder";
7
+
8
+ import { WireFailure } from "../protocol/messages.ts";
9
+ import { elapsedMs, trace } from "../trace.ts";
10
+ import type { ProcessSupervisor } from "./processes.ts";
11
+
12
+ export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
13
+ export const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
14
+
15
+ /** What running a native tool needs from its caller: the supervisor that owns it, the request's abort signal, and optionally its environment and a log. */
16
+ export interface ToolContext {
17
+ supervisor: ProcessSupervisor;
18
+ signal: AbortSignal;
19
+ env?: NodeJS.ProcessEnv | undefined;
20
+ log?: ((line: string) => void) | undefined;
21
+ }
22
+
23
+ export interface ToolRun {
24
+ code: number | null;
25
+ signal: NodeJS.Signals | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ timedOut: boolean;
29
+ aborted: boolean;
30
+ /** True when output beyond the limit was dropped. */
31
+ truncated: boolean;
32
+ /** The error code, for example ENOENT, when the program did not start. */
33
+ spawnError?: string;
34
+ }
35
+
36
+ const TAIL_LINES = 20;
37
+ const TAIL_CHARS = 2_000;
38
+ const TRACE_TAIL_LINES = 40;
39
+
40
+ /** The last lines of a tool's output, without empty ones, for the trace. */
41
+ function lastLines(text: string): string {
42
+ return text
43
+ .split(/\r?\n/)
44
+ .filter((line) => line.trim() !== "")
45
+ .slice(-TRACE_TAIL_LINES)
46
+ .join("\n");
47
+ }
48
+
49
+ /**
50
+ * The last lines that a tool printed, for a refusal: empty lines and lines
51
+ * that do not match `only` are left out, and so is the workspace root in a
52
+ * path. At most 20 lines and 2000 characters. Empty when nothing is left.
53
+ */
54
+ export function outputTail(tool: string, run: Pick<ToolRun, "stdout" | "stderr">, root: string, only?: RegExp): string {
55
+ const roots = new Set([root]);
56
+ try {
57
+ roots.add(realpathSync(root));
58
+ } catch {
59
+ // The workspace is gone: only the root as given is left out.
60
+ }
61
+ const variants = [...roots].flatMap((path) => [path, path.split(sep).join("/")]).sort((a, b) => b.length - a.length);
62
+ const withoutRoot = (line: string) => variants.reduce((text, path) => text.split(`${path}${sep}`).join("").split(`${path}/`).join("").split(path).join("."), line);
63
+ const lines = `${run.stdout}\n${run.stderr}`
64
+ .split(/\r?\n/)
65
+ .map((line) => line.trim())
66
+ .filter((line) => line !== "" && (only === undefined || only.test(line)))
67
+ .slice(-TAIL_LINES)
68
+ .map(withoutRoot);
69
+ while (lines.length > 1 && lines.join("\n").length > TAIL_CHARS) lines.shift();
70
+ const first = lines[0];
71
+ if (first === undefined) return "";
72
+ lines[0] = first.slice(-TAIL_CHARS);
73
+ return `\nThe last output of ${tool}:\n ${lines.join("\n ")}`;
74
+ }
75
+
76
+ /** Spawn errors that mean the file is not a program that this system can start. */
77
+ const NOT_A_PROGRAM = new Set(["ENOENT", "EACCES", "ENOEXEC", "EFTYPE", "EINVAL", "EPERM", "ENOTDIR"]);
78
+
79
+ export async function runTool(options: {
80
+ argv: string[];
81
+ cwd: string;
82
+ supervisor: ProcessSupervisor;
83
+ signal: AbortSignal;
84
+ timeoutMs?: number;
85
+ outputLimit?: number;
86
+ env?: NodeJS.ProcessEnv | undefined;
87
+ }): Promise<ToolRun> {
88
+ const limit = options.outputLimit ?? DEFAULT_OUTPUT_LIMIT;
89
+ const started = performance.now();
90
+ const child = options.supervisor.spawn(options.argv, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
91
+ // "exit" can come before the output streams finish; "close" comes after both.
92
+ const closed = new Promise<void>((resolve) => {
93
+ if (child.pid < 0) resolve();
94
+ else child.child.once("close", () => resolve());
95
+ });
96
+ const output = { stdout: "", stderr: "", truncated: false };
97
+ // The limit counts bytes. A decoder per stream keeps a character whose bytes arrive in two chunks whole.
98
+ let bytes = 0;
99
+ const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
100
+ const collect = (stream: "stdout" | "stderr") => (chunk: Buffer) => {
101
+ const room = limit - bytes;
102
+ if (chunk.length > room) output.truncated = true;
103
+ if (room <= 0) return;
104
+ const kept = chunk.subarray(0, room);
105
+ bytes += kept.length;
106
+ output[stream] += decoders[stream].write(kept);
107
+ };
108
+ child.child.stdout?.on("data", collect("stdout"));
109
+ child.child.stderr?.on("data", collect("stderr"));
110
+
111
+ let timedOut = false;
112
+ let aborted = false;
113
+ // A failed stop here is not lost: the stop after the exit below tries again and rejects the run.
114
+ const stopQuietly = () => {
115
+ void child.stop().catch(() => {});
116
+ // A process outside the group can hold the output pipes open: the run does not wait for it.
117
+ child.child.stdout?.destroy();
118
+ child.child.stderr?.destroy();
119
+ };
120
+ const timer = setTimeout(() => {
121
+ timedOut = true;
122
+ stopQuietly();
123
+ }, options.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
124
+ const onAbort = () => {
125
+ aborted = true;
126
+ stopQuietly();
127
+ };
128
+ if (options.signal.aborted) onAbort();
129
+ else options.signal.addEventListener("abort", onAbort, { once: true });
130
+
131
+ try {
132
+ const status = await child.exited;
133
+ // A tool that exited may have left descendants in its group, and they can hold its output
134
+ // pipes open: stop them before the wait for the end of the output.
135
+ await child.stop();
136
+ await closed;
137
+ output.stdout += decoders.stdout.end();
138
+ output.stderr += decoders.stderr.end();
139
+ trace().event("tool.run", { argv: options.argv, cwd: options.cwd, pid: child.pid, ms: elapsedMs(started), ...status, timedOut, aborted, truncated: output.truncated, stdout: lastLines(output.stdout), stderr: lastLines(output.stderr) });
140
+ return { ...status, ...output, timedOut, aborted };
141
+ } finally {
142
+ clearTimeout(timer);
143
+ options.signal.removeEventListener("abort", onAbort);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Runs one task of a named tool and turns a cancelled or timed-out run, a
149
+ * program that did not start and a stop by a signal into a WireFailure. With
150
+ * `truncated`, output beyond the limit is refused with that message too. Any
151
+ * exit code is returned to the caller. `root` (the default is `cwd`) is left
152
+ * out of the tool output that a refusal quotes.
153
+ */
154
+ export async function runToolOrFail(
155
+ tool: string,
156
+ task: string,
157
+ options: { argv: string[]; cwd: string; timeoutMs: number; outputLimit?: number; truncated?: string; root?: string },
158
+ context: ToolContext,
159
+ ): Promise<ToolRun> {
160
+ const { truncated, root, ...runOptions } = options;
161
+ const run = await runTool({ ...runOptions, supervisor: context.supervisor, signal: context.signal, env: context.env });
162
+ if (run.aborted) throw new WireFailure("operation-failed", `${task} was cancelled.`);
163
+ if (run.timedOut) throw new WireFailure("operation-failed", `${tool} did not finish within ${options.timeoutMs / 1000} seconds.`);
164
+ if (run.spawnError !== undefined) {
165
+ if (NOT_A_PROGRAM.has(run.spawnError)) throw new WireFailure("installation-incomplete", `${tool} is not a program that this system can start (${run.spawnError}). Install it again.`);
166
+ throw new WireFailure("operation-failed", `${tool} did not start (${run.spawnError}).`);
167
+ }
168
+ // A tool that a signal stopped has crashed: that is not a result of the task.
169
+ if (run.signal !== null) throw new WireFailure("operation-failed", `${tool} stopped on the signal ${run.signal}.${outputTail(tool, run, root ?? options.cwd)}`);
170
+ if (run.truncated && truncated !== undefined) throw new WireFailure("operation-failed", truncated);
171
+ return run;
172
+ }
@@ -0,0 +1,83 @@
1
+ // Request-owned temporary workspaces. Everything staged lives
2
+ // outside the user's project and is removed when the request ends.
3
+
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+
8
+ import { WireFailure } from "../protocol/messages.ts";
9
+ import { isRelativePath, type SourceTree } from "../protocol/tools.ts";
10
+ import type { ProcessSupervisor } from "./processes.ts";
11
+
12
+ /** How often a removal tries again when a file is busy, for example one that Windows still holds open. */
13
+ const REMOVE_RETRIES = 5;
14
+
15
+ export class Workspace {
16
+ readonly root: string;
17
+ /** The owner's release, when a supervisor owns the workspace. */
18
+ readonly #release: (() => void) | undefined;
19
+
20
+ private constructor(root: string, release: (() => void) | undefined) {
21
+ this.root = root;
22
+ this.#release = release;
23
+ }
24
+
25
+ /**
26
+ * A fresh, empty workspace in the temporary directory. With a supervisor,
27
+ * the workspace is also removed if its process dies.
28
+ */
29
+ static create(owner?: Pick<ProcessSupervisor, "ownPath">, prefix = "c64-re-tools-request-"): Workspace {
30
+ const root = mkdtempSync(join(tmpdir(), prefix));
31
+ return new Workspace(root, owner?.ownPath(root));
32
+ }
33
+
34
+ /** The absolute path of a relative path inside the workspace; refuses any escape. */
35
+ path(path: string): string {
36
+ if (!isRelativePath(path)) throw new WireFailure("invalid-input", `${path} is not a relative path inside the workspace.`);
37
+ const full = resolve(this.root, path);
38
+ const fromRoot = relative(this.root, full);
39
+ if (fromRoot.startsWith(`..${sep}`) || fromRoot === ".." || isAbsolute(fromRoot)) {
40
+ throw new WireFailure("invalid-input", `${path} leaves the workspace.`);
41
+ }
42
+ return full;
43
+ }
44
+
45
+ /** Writes a transferred tree under `directory` as ordinary files. Returns the directory's absolute path. */
46
+ materialize(directory: string, files: SourceTree, contents: readonly Uint8Array[]): string {
47
+ const base = this.path(directory);
48
+ mkdirSync(base, { recursive: true });
49
+ files.forEach((file, index) => {
50
+ const content = contents[index];
51
+ if (content === undefined) throw new WireFailure("invalid-input", `The request gives no bytes for ${file.path}.`);
52
+ const target = this.path(`${directory}/${file.path}`);
53
+ mkdirSync(dirname(target), { recursive: true });
54
+ // wx: never write through a file or link that is already there.
55
+ writeFileSync(target, content, { flag: "wx" });
56
+ });
57
+ return base;
58
+ }
59
+
60
+ /** A directory inside the workspace, created empty. */
61
+ directory(path: string): string {
62
+ const full = this.path(path);
63
+ rmSync(full, { recursive: true, force: true });
64
+ mkdirSync(full, { recursive: true });
65
+ return full;
66
+ }
67
+
68
+ /**
69
+ * Removes the workspace. An owned workspace that cannot be removed stays
70
+ * owned, and its supervisor removes it when its process ends: the error is
71
+ * not thrown, so that it cannot replace the failure of the request that
72
+ * used the workspace. Without an owner the error is thrown.
73
+ */
74
+ remove(): void {
75
+ try {
76
+ rmSync(this.root, { recursive: true, force: true, maxRetries: REMOVE_RETRIES });
77
+ } catch (error) {
78
+ if (this.#release === undefined) throw error;
79
+ return;
80
+ }
81
+ this.#release?.();
82
+ }
83
+ }