@edgehero/pi-dispatch 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,128 @@
1
+ /** Terminal launchers start only the approval helper. SSH and the sandbox wait for its empty Enter. */
2
+ import * as nodeFs from "node:fs";
3
+ import { spawn as nodeSpawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { join, delimiter, win32 } from "node:path";
6
+ import { quoteSandboxArg } from "./sandbox-hosts.mjs";
7
+
8
+ export function findTerminal(env, { fs = nodeFs } = {}) {
9
+ // env-internal PATH: search only absolute directories for the OS default terminal launcher, never for a sandbox command.
10
+ for (const bin of ["xdg-terminal-exec", "x-terminal-emulator"]) for (const dir of (env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(delimiter)) {
11
+ if (!dir.startsWith("/")) continue;
12
+ const path = join(dir, bin);
13
+ try { fs.accessSync(path, fs.constants.X_OK); return { bin: path, args: bin === "x-terminal-emulator" ? ["-e"] : [] }; } catch {}
14
+ }
15
+ return null;
16
+ }
17
+ export function windowsArgument(value) {
18
+ return `"${String(value).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`;
19
+ }
20
+ const psString = (value) => `'${String(value).replaceAll("'", "''")}'`;
21
+ // Redirected dispatcher handles do not create an interactive terminal. The new console owns fresh
22
+ // keyboard/screen buffers instead of inheriting the dispatcher's pipes or NUL input.
23
+ const WINDOWS_CONSOLE_SOURCE = `using System;
24
+ using System.ComponentModel;
25
+ using System.Runtime.InteropServices;
26
+ using System.Text;
27
+ public static class PiDispatchConsole {
28
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
29
+ private struct STARTUPINFO {
30
+ public uint cb;
31
+ public string lpReserved, lpDesktop, lpTitle;
32
+ public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
33
+ public ushort wShowWindow, cbReserved2;
34
+ public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
35
+ }
36
+ [StructLayout(LayoutKind.Sequential)]
37
+ private struct PROCESS_INFORMATION {
38
+ public IntPtr hProcess, hThread;
39
+ public uint dwProcessId, dwThreadId;
40
+ }
41
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
42
+ [return: MarshalAs(UnmanagedType.Bool)]
43
+ private static extern bool CreateProcessW(string application, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, IntPtr environment, string currentDirectory, ref STARTUPINFO startup, out PROCESS_INFORMATION process);
44
+ [DllImport("kernel32.dll", SetLastError = true)]
45
+ [return: MarshalAs(UnmanagedType.Bool)]
46
+ private static extern bool CloseHandle(IntPtr handle);
47
+ [DllImport("kernel32.dll", SetLastError = true)]
48
+ [return: MarshalAs(UnmanagedType.Bool)]
49
+ private static extern bool IsProcessInJob(IntPtr process, IntPtr job, [MarshalAs(UnmanagedType.Bool)] out bool result);
50
+ [DllImport("kernel32.dll", SetLastError = true)]
51
+ [return: MarshalAs(UnmanagedType.Bool)]
52
+ private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
53
+ [DllImport("kernel32.dll")]
54
+ private static extern IntPtr GetCurrentProcess();
55
+ public static string Start(string application, string commandLine) {
56
+ STARTUPINFO startup = new STARTUPINFO();
57
+ startup.cb = (uint)Marshal.SizeOf(typeof(STARTUPINFO));
58
+ startup.dwFlags = 0x00000001;
59
+ startup.wShowWindow = 1;
60
+ PROCESS_INFORMATION process;
61
+ if (!CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, 0x00000010, IntPtr.Zero, null, ref startup, out process)) throw new Win32Exception(Marshal.GetLastWin32Error());
62
+ try {
63
+ bool parentJob, childJob;
64
+ uint exitCode;
65
+ // The console already started. Optional observations must not turn that success into a refusal.
66
+ string parentState = IsProcessInJob(GetCurrentProcess(), IntPtr.Zero, out parentJob) ? parentJob.ToString() : "unknown:" + Marshal.GetLastWin32Error();
67
+ string childState = IsProcessInJob(process.hProcess, IntPtr.Zero, out childJob) ? childJob.ToString() : "unknown:" + Marshal.GetLastWin32Error();
68
+ string initialExit = GetExitCodeProcess(process.hProcess, out exitCode) ? exitCode.ToString() : "unknown:" + Marshal.GetLastWin32Error();
69
+ return "pid=" + process.dwProcessId + ";parentJob=" + parentState + ";childJob=" + childState + ";initialExit=" + initialExit;
70
+ } finally { CloseHandle(process.hThread); CloseHandle(process.hProcess); }
71
+ }
72
+ }`;
73
+ export function windowsPowerShellEnv(env) {
74
+ // env-internal PSModulePath: Windows PowerShell must derive its own module directories. A Node child of
75
+ // PowerShell 7 otherwise inherits incompatible modules ahead of the system's Start-Process implementation.
76
+ return Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== "psmodulepath"));
77
+ }
78
+ export function terminalLaunch({ platform, nodePath, cliPath, url, directory, env, fs = nodeFs, terminal = findTerminal(env, { fs }) }) {
79
+ const args = [cliPath, "opener", "prompt", url];
80
+ if (platform === "linux") {
81
+ if (!terminal) throw new Error("no default terminal launcher was found; install xdg-terminal-exec or configure x-terminal-emulator, then use Copy until it is installed");
82
+ return { bin: terminal.bin, args: [...terminal.args, nodePath, ...args] };
83
+ }
84
+ if (platform === "darwin") {
85
+ // Opening a .command file delegates to the account's registered terminal application. The file is private,
86
+ // contains only a fixed helper invocation and removes itself after that helper ends.
87
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
88
+ const path = join(directory, `prompt-${randomBytes(12).toString("hex")}.command`);
89
+ fs.writeFileSync(path, `#!/bin/sh\n${[nodePath, ...args].map(quoteSandboxArg).join(" ")}\nresult=$?\nrm -f -- "$0"\nexit "$result"\n`, { mode: 0o700, flag: "wx" });
90
+ return { bin: "/usr/bin/open", args: [path] };
91
+ }
92
+ if (platform === "win32") {
93
+ // env-internal SystemRoot: Windows' own system directory, not a variable forwarded to a container.
94
+ // ShellExecute expands percent templates; CreateProcessW gets the fixed literal argv directly and
95
+ // explicitly allocates a new console without inheriting the dispatcher's redirected standard handles.
96
+ const script = `$ErrorActionPreference = 'Stop'; Add-Type -TypeDefinition ${psString(WINDOWS_CONSOLE_SOURCE)}; [PiDispatchConsole]::Start(${psString(nodePath)}, ${psString([nodePath, ...args].map(windowsArgument).join(" "))})`;
97
+ // EncodedCommand preserves this fixed script through PowerShell 5's command-line parser, including the
98
+ // nested quotes in Arguments. The URI is still validated data in a quoted literal, never script input.
99
+ return { bin: win32.join(env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], env: windowsPowerShellEnv(env), waitForExit: true };
100
+ }
101
+ throw new Error("this operating system has no supported terminal opener; copy the sandbox command instead");
102
+ }
103
+ export function spawnTerminal(plan, { spawn = nodeSpawn, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
104
+ return new Promise((resolve, reject) => {
105
+ // Windows PowerShell can exit successfully without running its command under DETACHED_PROCESS.
106
+ // Await its finite dispatcher normally. libuv's job has SILENT_BREAKAWAY_OK, so the console that
107
+ // dispatcher creates is not owned by the URL handler's lifetime (Node 22.19, win/process.c).
108
+ const child = spawn(plan.bin, plan.args, { detached: !plan.waitForExit, stdio: plan.waitForExit ? ["ignore", "pipe", "pipe"] : "ignore", windowsHide: true, ...(plan.env ? { env: plan.env } : {}) });
109
+ let timer;
110
+ let stdout = "", stderr = "";
111
+ child.stdout?.on("data", (chunk) => { stdout = (stdout + chunk.toString("utf8")).slice(-4096); });
112
+ child.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-4096); });
113
+ const failed = (code) => Object.assign(new Error("Windows could not open an interactive console; check Windows PowerShell and use Copy until it is available"), { code, stdout, stderr });
114
+ child.once("error", (error) => { clearTimer(timer); reject(error); });
115
+ if (plan.waitForExit) {
116
+ // This dispatcher exits as soon as CreateProcessW returns. Waiting catches a refused launch without
117
+ // waiting for the person to press Enter, and the deadline bounds a blocked PowerShell/compiler.
118
+ timer = setTimer(() => { child.kill(); reject(failed()); }, 30_000);
119
+ child.once("close", (code) => {
120
+ clearTimer(timer);
121
+ // Exit zero alone does not establish that PowerShell executed the fixed dispatcher at all.
122
+ const receipt = stdout.trim().split(/\r?\n/).at(-1);
123
+ const created = /^pid=[1-9]\d*;parentJob=(?:True|False|unknown:-?\d+);childJob=(?:True|False|unknown:-?\d+);initialExit=(?:\d+|unknown:-?\d+)$/.test(receipt);
124
+ if (code === 0 && created) resolve({ stdout, stderr }); else reject(failed(code));
125
+ });
126
+ } else child.once("spawn", () => { child.unref(); resolve(); });
127
+ });
128
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The openshell venue's facts that need no run path (issue #683): the tested release and the judgement of the gateway's
3
+ * `status -o json`, shared by the adapter (worker/src/backend-openshell.mjs) and doctor (doctor-openshell.mjs). A leaf
4
+ * with no imports, so doctor reads them without loading the run path and pi with it.
5
+ */
6
+
7
+ /** The OpenShell release this adapter was measured on: doctor's floor and ceiling (issue #683), one place. */
8
+ export const OPENSHELL_TESTED_VERSION = "0.1.4";
9
+
10
+ /** Where that release is downloaded, for every line that tells an operator to install it (`up` installs nothing). */
11
+ export const OPENSHELL_RELEASE_URL = `https://github.com/NVIDIA/OpenShell/releases/tag/v${OPENSHELL_TESTED_VERSION}`;
12
+
13
+ /**
14
+ * The macOS install that gives `brew services start openshell` something to start (review round 1 of #715): NVIDIA's
15
+ * release asset `openshell.rb` carries the `service do` block; homebrew-core's `openshell` formula has none.
16
+ */
17
+ export const OPENSHELL_BREW_INSTALL = `brew install https://github.com/NVIDIA/OpenShell/releases/download/v${OPENSHELL_TESTED_VERSION}/openshell.rb`;
18
+
19
+ /**
20
+ * The local gateway as OpenShell 0.1.4 sets it up (issue #707): it listens on loopback at 17670, the deb ships it as the
21
+ * systemd USER unit `openshell-gateway.service`, and the conformance lane registers it with `openshell gateway add
22
+ * https://127.0.0.1:17670 --local --name openshell`. `up` offers that registration and `service install` orders the worker
23
+ * after that unit, so the three are spelled once.
24
+ */
25
+ export const OPENSHELL_LOCAL_GATEWAY_HOST = "127.0.0.1";
26
+ export const OPENSHELL_LOCAL_GATEWAY_PORT = 17670;
27
+ export const OPENSHELL_LOCAL_GATEWAY_URL = `https://${OPENSHELL_LOCAL_GATEWAY_HOST}:${OPENSHELL_LOCAL_GATEWAY_PORT}`;
28
+ export const OPENSHELL_GATEWAY_UNIT = "openshell-gateway.service";
29
+ export const OPENSHELL_DEFAULT_GATEWAY_NAME = "openshell";
30
+
31
+ /**
32
+ * Why the openshell venues are refused on Windows (issue #707): the pinned release's assets (checked 2026-10-19 with
33
+ * `gh api repos/NVIDIA/OpenShell/releases/tags/v0.1.4`) carry the CLI and the gateway for Linux (deb, rpm, snap, tarballs)
34
+ * and macOS (aarch64 tarballs, a Homebrew formula) and nothing for Windows, so there is no gateway on this host to set up.
35
+ */
36
+ export const OPENSHELL_NO_WINDOWS = `OpenShell ${OPENSHELL_TESTED_VERSION}, the release the openshell venue was measured on, ships no Windows build of its CLI or gateway (its release carries Linux and macOS ones only), so the openshell venue cannot run on this host. Run this deployment on Linux or macOS, or remove openshell from PI_BACKENDS`;
37
+
38
+ /**
39
+ * The gateway's answer to "where are you" (`openshell status -o json`), judged: `{ connected, local, server, version }`.
40
+ * `status` exits 0 even when the gateway is down (measured), so the JSON's own `status` decides `connected`. Local means
41
+ * a loopback host or a unix socket; anything else, or a server this cannot parse, is not local.
42
+ */
43
+ export function judgeGatewayStatus(doc) {
44
+ const status = typeof doc?.status === "string" ? doc.status : null;
45
+ const server = typeof doc?.server === "string" ? doc.server : null;
46
+ let local = false;
47
+ if (server !== null) {
48
+ if (server.startsWith("unix:")) local = true;
49
+ else {
50
+ try {
51
+ const host = new URL(server).hostname.replace(/^\[|\]$/g, "");
52
+ local = host === "127.0.0.1" || host === "::1" || host === "localhost" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
53
+ } catch {
54
+ local = false;
55
+ }
56
+ }
57
+ }
58
+ return { connected: status === "connected" || status === "connected_http", local, server, version: typeof doc?.version === "string" ? doc.version : null };
59
+ }
60
+
61
+ /**
62
+ * BIND MODE (issue #686 part 3, the `openshell-bind` venue): what the gateway must be configured with before a sandbox
63
+ * may carry the worker's binds, and how its refusals read. Here, in the leaf, because the adapter's preflight and doctor
64
+ * judge the same answer and must not disagree about it.
65
+ *
66
+ * Measured on OpenShell 0.1.4, Docker driver, 2026-10-19: a create carrying `--driver-config-json` is refused, in this
67
+ * ORDER, with the first sentence below on a default gateway, the second with only `allow_driver_config`, the third with
68
+ * both but resource admission on; each in 50 to 90 ms, creating nothing. `gateway info -o json` does NOT reveal these
69
+ * settings, so they can only be learned by asking a create (`bindProbeArgs`).
70
+ */
71
+ export const OPENSHELL_BIND_SETTINGS = Object.freeze(["allow_driver_config", "enable_bind_mounts", "resource_admission"]);
72
+
73
+ /** Each setting's refusal, as upstream words it (unwrapped first: the CLI breaks its error box across lines). */
74
+ const BIND_REFUSAL_PATTERNS = Object.freeze([
75
+ ["allow_driver_config", /caller driver config is disabled|must enable allow_driver_config/],
76
+ ["enable_bind_mounts", /bind mounts require enable_bind_mounts = true/],
77
+ ["resource_admission", /cannot be attached while resource admission is enabled/],
78
+ ]);
79
+
80
+ /** The one answer that proves the settings are on without anything being created: the probe's reserved target refused. */
81
+ const BIND_PROBE_ADMITTED = /mount target '\/sandbox' is reserved for the OpenShell workspace/;
82
+
83
+ /**
84
+ * A create's failure text judged: `{ setting }` for one of the three bind-settings refusals, `{ admitted: true }` for the
85
+ * probe's reserved-target refusal (the gateway read the binds, so its settings let them through), or `{}` for anything
86
+ * else. Only fixed patterns are matched; nothing the CLI said is kept.
87
+ */
88
+ export function judgeBindAnswer(text) {
89
+ const said = String(text ?? "").replace(/[│\s]+/g, " ");
90
+ for (const [setting, pattern] of BIND_REFUSAL_PATTERNS) if (pattern.test(said)) return { setting };
91
+ if (BIND_PROBE_ADMITTED.test(said)) return { admitted: true };
92
+ return {};
93
+ }
94
+
95
+ /** The engine's table, the three settings and the TOML header (measured: no `[openshell] version = 2`, no driver table). */
96
+ export function gatewayTomlFor(engine = "docker") {
97
+ return ["[openshell]", "version = 2", "", `[openshell.drivers.${engine}]`, "allow_driver_config = true", "enable_bind_mounts = true", "", `[openshell.drivers.${engine}.resource_admission]`, "enabled = false", ""].join("\n");
98
+ }
99
+
100
+ /** Where the gateway reads it, in upstream's words, for a fix text. */
101
+ export const GATEWAY_TOML_WHERE = "the gateway's gateway.toml ($XDG_CONFIG_HOME/openshell/gateway.toml, or the file --config or OPENSHELL_GATEWAY_CONFIG names)";
102
+
103
+ /** Upstream's own warning (docs/how-it-works/sandboxes/runtimes.mdx at v0.1.4), quoted verbatim wherever bind mode is offered. */
104
+ export const UPSTREAM_BIND_WARNING = "Bind mounts expose gateway-host files to the sandbox and can bypass workspace isolation and filesystem policy.";
105
+
106
+ /** The `--driver-config-json` document for a list of `{ source, target, readOnly }`, keyed by the driver's name. */
107
+ export function driverConfigFor(engine, mounts) {
108
+ return JSON.stringify({ [engine]: { mounts: mounts.map((m) => ({ type: "bind", source: m.source, target: m.target, read_only: m.readOnly === true, ...(m.selinuxLabel === "private" ? { selinux_label: "private" } : {}) })) } });
109
+ }
110
+
111
+ /**
112
+ * The probe create (`judgeBindAnswer` reads its failure): one read-only bind of an empty directory onto `/sandbox`, a
113
+ * target OpenShell reserves for the workspace. Measured: a gateway whose settings do not admit binds refuses it with the
114
+ * setting's own sentence and creates nothing; one whose settings admit them gets past all three checks and fails it at
115
+ * provisioning ("sandbox entered error phase while provisioning: ContainerCreateFailed: mount target '/sandbox' is
116
+ * reserved for the OpenShell workspace"), LEAVING the sandbox in phase `Error`. So every caller deletes it on every
117
+ * answer, and it is owner-labelled and named in the job namespace, so the boot reaper finds one a crash left.
118
+ */
119
+ export function bindProbeArgs({ name, image, source, engine = "docker", ownerLabel, owner, modeLabel, gateway = null }) {
120
+ return [...(typeof gateway === "string" && gateway !== "" ? ["--gateway", gateway] : []), "sandbox", "create", "--detach", "--name", name, "--from", image, "--label", `${ownerLabel}=${owner}`, "--label", `${modeLabel}=bind`, "--driver-config-json", driverConfigFor(engine, [{ source, target: "/sandbox", readOnly: true }]), "--no-auto-providers", "-o", "json"];
121
+ }
122
+
123
+ /**
124
+ * Issue #730: the Yama setting that keeps a job's tools from the memory of their ancestors, the job's key holders,
125
+ * which run readable on this venue (image/Dockerfile, `observableRunner`). OpenShell's workload seccomp denies
126
+ * process_vm_readv, ptrace and pidfd_getfd on every host; opening and reading /proc/<pid>/mem is not a syscall it
127
+ * filters, and Yama's ptrace_scope EXACTLY 1 is what refuses that to a descendant while still letting the broker read
128
+ * the workload (measured on the lane: EACCES to a tool at scope 1). At 0, the kernel default where nothing sets it, a
129
+ * same-uid tool reads a dumpable ancestor's memory (issue #545 measured exactly that on Fedora and Docker Desktop). At
130
+ * 2 or 3 the kernel admits a PTRACE_MODE_ATTACH read only from a process with CAP_SYS_PTRACE in the target's user namespace, which the
131
+ * broker is NOT, so its own process_vm_readv of the workload fails and every job would reserve and then fail its first
132
+ * connection: refused with a distinct reason, never run. Nothing in the sandbox can lower the sysctl (a host-wide
133
+ * setting; the sandbox holds no capability), so the host value is the sandbox's.
134
+ */
135
+ export const PTRACE_SCOPE_PATH = "/proc/sys/kernel/yama/ptrace_scope";
136
+
137
+ /**
138
+ * `{ ok: true, scope: 1 }` only when Yama is present and EXACTLY 1; otherwise `{ ok: false, reason, scope }`:
139
+ * `unprotected` (scope 0, or a read that failed/garbled/absent: refused, since the key would be exposed or nothing is
140
+ * known) or `too-strict` (scope 2 or 3: the broker cannot read the workload, so every job would reserve then fail).
141
+ * Strict: the file's text must be one decimal integer.
142
+ */
143
+ export function judgePtraceScope(read) {
144
+ let text;
145
+ try {
146
+ text = String(read());
147
+ } catch {
148
+ return { ok: false, reason: "unprotected", scope: null };
149
+ }
150
+ const trimmed = text.trim();
151
+ if (!/^[0-9]{1,3}$/.test(trimmed)) return { ok: false, reason: "unprotected", scope: null };
152
+ const scope = Number(trimmed);
153
+ if (scope === 1) return { ok: true, scope };
154
+ return { ok: false, reason: scope >= 2 ? "too-strict" : "unprotected", scope };
155
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The `openshell` venue's network policy, generated per job (issue #684, `CONST-VENUE-EGRESS-LAYER`'s second condition,
3
+ * `INT-EGRESS-POLICY-CONTRACT`'s "A venue's own egress layer"). The SAME sources the squid venues read, translated into
4
+ * OpenShell's `network_policies`, so the two venues admit the same hosts:
5
+ *
6
+ * - `egress-allowlist.conf` (`parseAllowlist`): a name admits that host on 443; a leading `.x.y` admits `x.y` and every
7
+ * name under it, which OpenShell spells as the name plus a `*.x.y` wildcard. squid opens an allowlisted name on 443
8
+ * only (every request is a CONNECT, `deploy/egress-proxy.conf`), so 443 only here too.
9
+ * - `model-endpoints.json`: exactly each endpoint's host and port. A model server on the gateway host is reached by
10
+ * OpenShell's trusted aliases (`host.openshell.internal`, `host.docker.internal`, `host.containers.internal`,
11
+ * measured); a loopback name is never authorizable there, as `to_host_local` refuses it on squid.
12
+ * - GitHub's web host, when the allowlist admits it, carries the git transport and nothing else writable: reads,
13
+ * `git-upload-pack` and LFS batch requests for any repository, and `git-receive-pack` for the job's OWN repository
14
+ * only, so a push to any other repository is refused by the layer, which squid, matching names, never could. ONLY
15
+ * GitHub: there the API lives on its own host (api.github.com), so narrowing github.com costs the CLIs nothing. On
16
+ * GitLab, Forgejo and Azure the API shares the git host (and a self-hosted one may sit under a URL path), so the
17
+ * same narrowing would refuse every merge request and comment the job makes; their host keeps `access: full`, as
18
+ * squid gives it, and the push rule is GitHub's alone, said so in `INT-EGRESS-POLICY-CONTRACT`.
19
+ *
20
+ * Every endpoint says `enforcement: enforce`: OpenShell's default is `audit`, which reports a violation and lets it
21
+ * through. Every endpoint says `protocol: rest`, so the layer reads each request (and can swap a credential placeholder
22
+ * into it, issue #684 part 2); a WebSocket or another protocol on 443 that squid tunnelled blindly is refused here,
23
+ * named in `INT-EGRESS-POLICY-CONTRACT`.
24
+ *
25
+ * THE BINARIES ARE NOT A CONTROL HERE, and saying so is the point. A rule's `binaries` match the connecting process or
26
+ * ANY of its ancestors (OpenShell's own documentation), and every process of a job descends from
27
+ * the runner's node (`/opt/pi-dispatch/runner-observable` on this venue since issue #730, `runner-node` before), which
28
+ * must reach the provider. So each rule names the image's declared network
29
+ * binaries (`dev.pi-dispatch.net-binaries`), the runner first, and that admits every tool the agent starts: the host
30
+ * list is the boundary, exactly as it is on squid. The runner's node is put first whatever an image's label says,
31
+ * since without it no provider call matches (an operator-built image's label may predate it).
32
+ *
33
+ * Pure: text in, a policy object out, and a refusal for what cannot be translated. Never reads a file.
34
+ */
35
+ import { createHash } from "node:crypto";
36
+ import { allowlistEntryFor } from "./egress-allowlist.mjs";
37
+
38
+ /** The binary the runner and pi run as, which every job process descends from: always the first binary of every rule. */
39
+ export const RUNNER_NODE = "/opt/pi-dispatch/runner-node";
40
+ /**
41
+ * Issue #730: the readable copy the job's key holders start from on the OpenShell venues (image/Dockerfile), so the
42
+ * binary every provider call there leaves through. Listed beside RUNNER_NODE on every rule for the same reason that one
43
+ * is: an image that predates the label still gets its runner named, and this venue refuses before spend an image that
44
+ * lacks the copy (`observableRunner`).
45
+ */
46
+ export const RUNNER_OBSERVABLE = "/opt/pi-dispatch/runner-observable";
47
+
48
+ /** A refusal this module returns instead of guessing: `{ refused, entry? }`. */
49
+ const refusal = (refused, extra = {}) => ({ refused, ...extra });
50
+
51
+ /** The DNS labels of a name. */
52
+ const labels = (name) => String(name).split(".").filter((l) => l !== "");
53
+
54
+ /** A hostname the policy may name: lowercase letters, digits, hyphens and dots, 253 at most. */
55
+ const HOST_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
56
+
57
+ /**
58
+ * The hosts one allowlist entry admits, as OpenShell names them, or a refusal. A leading-dot entry needs a wildcard of
59
+ * at least three labels in OpenShell (`*.x.y`), so `.x.y` is fine and `.y` (a top-level domain) is refused by name: it
60
+ * would admit a whole TLD on squid, and widening it here is not this file's to guess.
61
+ */
62
+ export function hostsOfEntry(entry) {
63
+ const e = String(entry).toLowerCase();
64
+ if (e.startsWith(".")) {
65
+ const base = e.slice(1);
66
+ // A line squid reads as a name that matches nothing (a trailing dot, a port, an address) admits nothing here
67
+ // either: skipped, never a refusal of every job, and named in the log by the caller.
68
+ if (!HOST_RE.test(base)) return { hosts: [], skipped: e };
69
+ if (labels(base).length < 2) return refusal("wildcard-too-wide", { entry: e });
70
+ return { hosts: [base, `*.${base}`] };
71
+ }
72
+ if (!HOST_RE.test(e)) return { hosts: [], skipped: e };
73
+ return { hosts: [e] };
74
+ }
75
+
76
+ /** The web host a job's forge serves git from, or null: GitHub's own, else the instance URL the deployment names. */
77
+ export function forgeGitHost(kind) {
78
+ return kind === "github" ? "github.com" : null;
79
+ }
80
+
81
+ /**
82
+ * The git-transport rules for the forge's web host: reads anywhere, a fetch from any repository, a push to `repo` only.
83
+ * `repo` is the job's `owner/name` (GitHub, Forgejo) or its GitLab project path; a path segment outside the name rules
84
+ * is refused rather than put into a glob, since a `*` or `**` in it would widen the push rule.
85
+ */
86
+ export function forgeGitRules(repo) {
87
+ // GitHub's own name rules: letters, digits, `_`, `.` and `-` (so `acme/.github` and `acme/_infra` are names), never
88
+ // a segment of `.` or `..`, never a glob character, which would widen the push rule.
89
+ if (typeof repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.split("/").some((seg) => seg === "." || seg === "..")) return refusal("repo-not-a-path");
90
+ const own = repo.replace(/\.git$/, "");
91
+ return {
92
+ rules: [
93
+ { allow: { method: "GET", path: "**" } },
94
+ { allow: { method: "HEAD", path: "**" } },
95
+ { allow: { method: "OPTIONS", path: "**" } },
96
+ { allow: { method: "POST", path: "/**/git-upload-pack" } },
97
+ // Git LFS asks what to fetch with a POST; the objects themselves come from GitHub's media hosts.
98
+ { allow: { method: "POST", path: "/**/info/lfs/objects/batch" } },
99
+ { allow: { method: "POST", path: `/${own}.git/git-receive-pack` } },
100
+ { allow: { method: "POST", path: `/${own}/git-receive-pack` } },
101
+ ],
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The rule key for a host and port: readable, and stable, with a short hash of the exact host so two hosts that
107
+ * spell alike once sanitised (`a-b.com` and `a.b.com`, `*.x.y` and `any.x.y`) never overwrite each other's rule.
108
+ */
109
+ function ruleKey(prefix, host, port) {
110
+ const tag = createHash("sha256").update(`${host}:${port}`).digest("hex").slice(0, 8);
111
+ return `${prefix}_${host.replace(/\*/g, "any").replace(/[^a-z0-9]+/g, "_").slice(0, 80)}_${port}_${tag}`;
112
+ }
113
+
114
+ /**
115
+ * `networkPolicyFor({ allowlist, endpoints, kind, repo, binaries, onSkipped })` -> `{ network_policies }` or
116
+ * `{ refused, entry? }`.
117
+ *
118
+ * allowlist the parsed `egress-allowlist.conf` entries (`parseAllowlist`)
119
+ * endpoints the job's `model-endpoints.json` entries (`{ host, port }`)
120
+ * kind, repo the job's forge and repository, for GitHub's push rule
121
+ * binaries the image's `dev.pi-dispatch.net-binaries` paths, after the runner's node, which is always first
122
+ * onSkipped told of every allowlist line and label entry left out, for the log
123
+ */
124
+ export function networkPolicyFor({ allowlist = [], endpoints = [], kind = null, repo = null, binaries = [], onSkipped = () => {} } = {}) {
125
+ // The runner's node first, then the image's declared paths that are absolute and glob-free; an entry that is not is
126
+ // skipped and named (a glob would widen the rule), never a refusal of every job for a label typo.
127
+ const declared = (Array.isArray(binaries) ? binaries : []).map((b) => String(b).trim()).filter((b) => b !== "");
128
+ for (const b of declared) if (!b.startsWith("/") || /[*?[\]{}]/.test(b)) onSkipped({ binary: b });
129
+ const bins = [...new Set([RUNNER_NODE, RUNNER_OBSERVABLE, ...declared.filter((b) => b.startsWith("/") && !/[*?[\]{}]/.test(b))])].map((path) => ({ path }));
130
+ const policies = {};
131
+ const add = (key, endpoint) => {
132
+ policies[key] = { name: key, endpoints: [{ ...endpoint, enforcement: "enforce" }], binaries: bins };
133
+ };
134
+
135
+ // The forge's web host, when the allowlist admits it: the git transport with the push rule, never `access: full`.
136
+ const gitHost = kind ? forgeGitHost(kind) : null;
137
+ let gitRules = null;
138
+ if (gitHost !== null && allowlistEntryFor(allowlist, gitHost) !== null) {
139
+ const r = forgeGitRules(repo);
140
+ if (r.refused) return r;
141
+ gitRules = r.rules;
142
+ add(ruleKey("forge", gitHost, 443), { host: gitHost, port: 443, protocol: "rest", rules: gitRules });
143
+ }
144
+
145
+ for (const entry of allowlist) {
146
+ const r = hostsOfEntry(entry);
147
+ if (r.refused) return r;
148
+ if (r.skipped) onSkipped({ entry: r.skipped });
149
+ for (const host of r.hosts) {
150
+ if (host === gitHost) continue; // its own rule above, narrower than `full`
151
+ add(ruleKey("allow", host, 443), { host, port: 443, protocol: "rest", access: "full" });
152
+ }
153
+ }
154
+
155
+ for (const ep of endpoints ?? []) {
156
+ const host = String(ep?.host ?? "").toLowerCase();
157
+ const port = Number(ep?.port);
158
+ if (!HOST_RE.test(host) || !Number.isSafeInteger(port) || port < 1 || port > 65535) return refusal("endpoint-not-a-host-port", { entry: `${ep?.host}:${ep?.port}` });
159
+ if (host === "localhost" || labels(host).length === 0) return refusal("endpoint-loopback", { entry: host });
160
+ add(ruleKey("endpoint", host, port), { host, port, protocol: "rest", access: "full" });
161
+ }
162
+ return { network_policies: policies };
163
+ }
package/src/outbox.mjs CHANGED
@@ -50,6 +50,8 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
50
50
  // Belt-and-suspenders: a github parent has no /outbox mount at all (the request channel does not
51
51
  // exist for it), but guard here too so a stray dir cannot chain off an untrusted issue author.
52
52
  if (job?.data?.kind !== "local") return { enqueued: 0, refused: 0 };
53
+ // A collector signature is not a human's permission to start a write-capable child.
54
+ if (job.data.watch) { refuse("watch-human-required", 0); return { enqueued, refused }; }
53
55
 
54
56
  const outboxDir = join(prepared.jobDir, "outbox");
55
57
  let names;
@@ -186,6 +188,7 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
186
188
  provider: job.data?.provider,
187
189
  model: job.data?.model,
188
190
  models: job.data?.models,
191
+ fallbackModels: job.data?.fallbackModels,
189
192
  // Issue #501's per-job dollar cap, by the same narrowing rule: a child that dropped it would run
190
193
  // uncapped. Passed through whenever the parent's data carries it; absent stays absent.
191
194
  maxCostUsd: job.data?.maxCostUsd,
@@ -211,6 +214,17 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
211
214
  // Less the names of the parent's MCP tools: the child connects no server (`mcp` below), its runner knows
212
215
  // none of them, and they would refuse it at start while removing nothing.
213
216
  excludeTools: withoutMcpExclusions(job.data?.excludeTools),
217
+ // Issue #800, both by #291's inverted direction: dropped, a child loads what its parent's trigger took away and
218
+ // keeps the tool. Whole, unlike the line above. A child connects no server, but it does load extensions, so a
219
+ // name here still removes something, and an unmatched one widens nothing: the runner stops only a job with no
220
+ // parent (`PI_EXCLUDE_TOOLS_STRICT`, run-container.mjs).
221
+ excludeExtensions: job.data?.excludeExtensions,
222
+ excludeExtensionTools: job.data?.excludeExtensionTools,
223
+ // `"packages": false`, by the same inverted direction, and the line those two left a hole beside: with only them, a trigger
224
+ // that withheld every staged package chained a child holding all of them, while one that named a package kept it out. `image`'s
225
+ // toolchain argument does not stand against it: a child's flow is its repository's, which is what the gate above reads, so a child
226
+ // without the packages is a child with what its parent had. Off `job.data`, so the request file cannot set it.
227
+ packages: job.data?.packages,
214
228
  // `secrets`/`secretsProfile` are deliberately ABSENT, and the two lines above are exactly why
215
229
  // this comment exists: their reasoning reads as though it should apply here too, and it must
216
230
  // not. An image and a skills directory are toolchain; a resolved credential is a capability.
@@ -22,6 +22,7 @@ export const COMPLETIONS_CATALOG_HOSTS = Object.freeze([
22
22
  "api.fireworks.ai",
23
23
  "api.groq.com",
24
24
  "api.individual.githubcopilot.com",
25
+ "api.kimi.com",
25
26
  "api.moonshot.ai",
26
27
  "api.moonshot.cn",
27
28
  "api.together.ai",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The canary's two facts on a run record (issue #796). Its own small module because `run-history.mjs` is imported almost everywhere, and must not bring npm or the stager with it.
3
+ */
4
+ import { EXACT_VERSION_RE, isPackageName } from "./package-names.mjs";
5
+
6
+ export const CANARY_LOADS = Object.freeze(["loaded", "failed"]);
7
+ export const CANARY_RECORD_LOADS = Object.freeze(["loaded", "failed", "not-reported"]);
8
+
9
+ /**
10
+ * `{ name, version }`, rebuilt, or `null`. The one reader of the job field and of the record's copy of it, and the one validator of
11
+ * a package's name and exact version wherever else they are read back (`loaded-record.mjs`, `host-loads.mjs`).
12
+ */
13
+ export function parsePackageCanary(raw) {
14
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
15
+ const { name, version } = raw;
16
+ if (!isPackageName(name)) return null;
17
+ if (typeof version !== "string" || version.length > 128 || !EXACT_VERSION_RE.test(version)) return null;
18
+ return { name, version };
19
+ }
20
+
21
+ /** The run record's field: the job's candidate, and what the runner said of it. A missing report is its own word, which every reader draws as failed. */
22
+ export function recordedPackageCanary(field, load) {
23
+ const canary = parsePackageCanary(field);
24
+ if (!canary) return null;
25
+ return { ...canary, load: CANARY_LOADS.includes(load) ? load : "not-reported" };
26
+ }