@rackbops/ac-agent 2.0.0-alpha.53 → 2.0.0-alpha.54

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 (3) hide show
  1. package/README.md +232 -7
  2. package/dist/ac-agent.mjs +961 -113
  3. package/package.json +1 -1
package/dist/ac-agent.mjs CHANGED
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
47
47
  }
48
48
 
49
49
  var name = "@rackbops/ac-agent";
50
- var version = "2.0.0-alpha.53";
50
+ var version = "2.0.0-alpha.54";
51
51
  var type = "module";
52
52
  var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
53
53
  var bin = {
@@ -224,6 +224,13 @@ function managerBinDirs(deps) {
224
224
  return dirs;
225
225
  }
226
226
 
227
+ var searchPath$1 = /*#__PURE__*/Object.freeze({
228
+ __proto__: null,
229
+ managerBinDirs: managerBinDirs,
230
+ regExePath: regExePath,
231
+ searchPath: searchPath
232
+ });
233
+
227
234
  const COMMAND_TIMEOUT_MS = 5_000;
228
235
  const CLASSIC_KEYS = [
229
236
  String.raw `HKCU\Software\Classes\claude`,
@@ -1811,6 +1818,21 @@ function tunnelPreflightEnvOverride(env) {
1811
1818
  requireAbsolute(raw, "AC_AGENT_TUNNEL_PREFLIGHT");
1812
1819
  return raw;
1813
1820
  }
1821
+ /** `AC_AGENT_TOOLCHAIN_UPGRADE` (#675): `1`/`true` or `0`/`false` (case-insensitive, surrounding space
1822
+ * ignored); blank or unset is `null` (the file decides); anything else throws, naming the value, like every
1823
+ * other `AC_AGENT_*` override that cannot be read: guessing at an opt-in that installs software would be
1824
+ * the wrong way to fail. */
1825
+ function toolchainUpgradeEnvOverride(env) {
1826
+ const raw = env?.AC_AGENT_TOOLCHAIN_UPGRADE;
1827
+ if (raw === undefined || raw.trim() === "")
1828
+ return null;
1829
+ const value = raw.trim().toLowerCase();
1830
+ if (value === "1" || value === "true")
1831
+ return true;
1832
+ if (value === "0" || value === "false")
1833
+ return false;
1834
+ throw new Error(`AC_AGENT_TOOLCHAIN_UPGRADE must be 1, true, 0 or false, got: ${raw}`);
1835
+ }
1814
1836
  /** A `tunnel_config` / `tunnel_preflight` value read from `agent.toml`: `null` for an absent key, a
1815
1837
  * non-string or a blank string, else the string as written (the verb refuses a relative one). */
1816
1838
  function tunnelPathFromFile(raw) {
@@ -1837,6 +1859,7 @@ function loadConfig(dir, deps) {
1837
1859
  const envPublishDir = publishDirEnvOverride(deps.env);
1838
1860
  const envTunnelConfig = tunnelConfigEnvOverride(deps.env);
1839
1861
  const envTunnelPreflight = tunnelPreflightEnvOverride(deps.env);
1862
+ const envToolchainUpgrade = toolchainUpgradeEnvOverride(deps.env);
1840
1863
  const defaultReposRoot = () => path.join(deps.homedir(), "repos");
1841
1864
  const defaultClaudeProjectsDir = () => path.join(deps.homedir(), ".claude", "projects");
1842
1865
  const defaultPublish = () => defaultPublishDir(deps.platform, deps.env);
@@ -1852,6 +1875,7 @@ function loadConfig(dir, deps) {
1852
1875
  publishDir: envPublishDir ?? defaultPublish(),
1853
1876
  tunnelConfig: envTunnelConfig,
1854
1877
  tunnelPreflight: envTunnelPreflight,
1878
+ toolchainUpgrade: envToolchainUpgrade ?? false,
1855
1879
  },
1856
1880
  path: configPath,
1857
1881
  source: "defaults",
@@ -1871,6 +1895,7 @@ function loadConfig(dir, deps) {
1871
1895
  publishDir: envPublishDir ?? (typeof raw.publish_dir === "string" ? raw.publish_dir : defaultPublish()),
1872
1896
  tunnelConfig: envTunnelConfig ?? tunnelPathFromFile(raw.tunnel_config),
1873
1897
  tunnelPreflight: envTunnelPreflight ?? tunnelPathFromFile(raw.tunnel_preflight),
1898
+ toolchainUpgrade: envToolchainUpgrade ?? raw.toolchain_upgrade === true,
1874
1899
  },
1875
1900
  path: configPath,
1876
1901
  source: "file",
@@ -1894,6 +1919,9 @@ function writeConfig(dir, config, deps) {
1894
1919
  toml.tunnel_config = config.tunnelConfig;
1895
1920
  if (config.tunnelPreflight !== null)
1896
1921
  toml.tunnel_preflight = config.tunnelPreflight;
1922
+ // The opt-in (#675) is written only when it is on: absent means `false`, which is what a fresh file says.
1923
+ if (config.toolchainUpgrade)
1924
+ toml.toolchain_upgrade = true;
1897
1925
  deps.writeFile(configPath, stringify$1(toml));
1898
1926
  return configPath;
1899
1927
  }
@@ -22298,6 +22326,7 @@ async function status(deps, now = () => new Date()) {
22298
22326
  reposRoot: loaded.config.reposRoot,
22299
22327
  reposCount: listRepos(loaded.config.reposRoot, deps.repoFs).repos.length,
22300
22328
  publishDir: loaded.config.publishDir,
22329
+ toolchainUpgrade: loaded.config.toolchainUpgrade,
22301
22330
  grants,
22302
22331
  virtualized: virtualization.virtualized,
22303
22332
  lanAddress: deps.ips[0] ?? null,
@@ -23090,6 +23119,36 @@ function healthResultFrom(ok, body) {
23090
23119
  const version = typeof body === "object" && body !== null ? body.version : undefined;
23091
23120
  return typeof version === "string" ? { ok, version } : { ok };
23092
23121
  }
23122
+ /** What `registerToolchainUpgradeVerb` is given (#675), apart from the process-level pieces `realServe` has
23123
+ * to build itself. Here, and exported, so the wiring that matters (gate 2: the verb is on only when THIS
23124
+ * machine's loaded `agent.toml` says so) is a function with a test, not a line in `realServe` that nothing
23125
+ * but a real run reaches. */
23126
+ function toolchainUpgradeDeps(args) {
23127
+ return {
23128
+ enabled: () => args.loaded.config.toolchainUpgrade,
23129
+ platform: args.platform,
23130
+ which: args.which,
23131
+ searchPath: args.searchPath,
23132
+ run: args.run,
23133
+ jobs: args.jobs,
23134
+ username: () => {
23135
+ try {
23136
+ return args.userInfo().username;
23137
+ }
23138
+ catch {
23139
+ return "<user>";
23140
+ }
23141
+ },
23142
+ cwd: args.homedir,
23143
+ logEvent: args.logEvent,
23144
+ };
23145
+ }
23146
+ /** How many jobs `POST /v1/update` must refuse to restart the agent under: every kind that is running, plus
23147
+ * an upgrade that has passed its cap check but not yet started its job (#675; its pre-spawn waits can take
23148
+ * tens of seconds, during which no job exists). */
23149
+ function runningJobCount(jobs, upgrade) {
23150
+ return () => jobs.runningCount() + upgrade.pendingStarts();
23151
+ }
23093
23152
  async function buildServiceDeps(overrides = {}) {
23094
23153
  const os = await import('node:os');
23095
23154
  const fs = await import('node:fs');
@@ -23217,6 +23276,8 @@ async function realServe(overrides = {}) {
23217
23276
  const { realPublishFsIo } = await Promise.resolve().then(function () { return publish$1; });
23218
23277
  const { registerPublishVerb } = await Promise.resolve().then(function () { return publish; });
23219
23278
  const { registerTunnelVerb, realTunnelFs } = await Promise.resolve().then(function () { return tunnel; });
23279
+ const { registerToolchainUpgradeVerb } = await Promise.resolve().then(function () { return toolchainUpgrade; });
23280
+ const { searchPath } = await Promise.resolve().then(function () { return searchPath$1; });
23220
23281
  // `--config-dir`/`--state-dir` (E4 finding 13, #241) are what `install` bakes into the registered
23221
23282
  // task/unit, so a `serve` launched that way never falls back to re-resolving from its own
23222
23283
  // environment (a Task Scheduler process / systemd --user unit does not see the installing
@@ -23425,6 +23486,20 @@ async function realServe(overrides = {}) {
23425
23486
  which: which$1,
23426
23487
  });
23427
23488
  registerGhVerb(registry$3, { run, which: which$1, logEvent: (message) => log$1.write(message) });
23489
+ // The toolchain upgrade (#675): installs software on THIS machine, so it needs the console's grant AND
23490
+ // this machine's own `toolchain_upgrade = true` (read from the config loaded at start-up). Registered
23491
+ // here, not beside the tunnel verb, because it shares the job registry created just above.
23492
+ const toolchainUpgrade$1 = registerToolchainUpgradeVerb(registry$3, toolchainUpgradeDeps({
23493
+ loaded,
23494
+ platform: process.platform,
23495
+ which: which$1,
23496
+ searchPath: async () => (await searchPath({ platform: process.platform, env: process.env, run })).path,
23497
+ run,
23498
+ jobs,
23499
+ userInfo: () => os.userInfo(),
23500
+ homedir: () => os.homedir(),
23501
+ logEvent: (message) => log$1.write(message),
23502
+ }));
23428
23503
  const readGrantsResult = readGrants(dirs.stateDir, readFile);
23429
23504
  if (readGrantsResult.kind === "malformed") {
23430
23505
  log$1.write(`grants.json unreadable (${readGrantsResult.error}), starting with no grants`);
@@ -23483,7 +23558,7 @@ async function realServe(overrides = {}) {
23483
23558
  env: process.env,
23484
23559
  }),
23485
23560
  selfUpdateSupported: selfUpdateSupported(process.platform),
23486
- runningJobs: () => jobs.runningCount(),
23561
+ runningJobs: runningJobCount(jobs, toolchainUpgrade$1),
23487
23562
  now: () => new Date(),
23488
23563
  logEvent: (message) => log$1.write(message),
23489
23564
  });
@@ -64427,6 +64502,39 @@ var lock = /*#__PURE__*/Object.freeze({
64427
64502
  /** `agent.log`, rotated to `agent.log.1` past 1 MiB on open -- checked once at `serve` startup,
64428
64503
  * not on every write, so a long-running agent's own line-by-line appends stay cheap. */
64429
64504
  const MAX_SIZE_BYTES = 1024 * 1024;
64505
+ /** The characters that end a line, rewrite one, or reorder one: every C0 and C1 control character and DEL
64506
+ * (`\n`, `\r`, an ANSI escape that moves the cursor up and erases), U+2028 and U+2029 (the line and
64507
+ * paragraph separators that Python's `splitlines` and most editors break on), and the bidirectional
64508
+ * formatting controls U+202A-202E and U+2066-2069 (which reorder what a terminal shows). */
64509
+ function isLineBreaking(code) {
64510
+ return (code <= 0x1f ||
64511
+ (code >= 0x7f && code <= 0x9f) ||
64512
+ code === 0x2028 ||
64513
+ code === 0x2029 ||
64514
+ (code >= 0x202a && code <= 0x202e) ||
64515
+ (code >= 0x2066 && code <= 0x2069));
64516
+ }
64517
+ /** One log entry is one line, whatever it was built from (#675): a request line carries the request's
64518
+ * own path, which an unauthenticated caller chooses and which arrives percent-decoded, so `%0A` would
64519
+ * otherwise start a line of the caller's own wording (a forged audit line) and an escape sequence would
64520
+ * erase real ones on the screen of whoever reads the file. Each such character is written as `\xNN`, or
64521
+ * `\uNNNN` above U+00FF. */
64522
+ function oneLine(line) {
64523
+ let out = "";
64524
+ for (const ch of line) {
64525
+ const code = ch.codePointAt(0) ?? 0;
64526
+ if (!isLineBreaking(code)) {
64527
+ out += ch;
64528
+ }
64529
+ else if (code <= 0xff) {
64530
+ out += `\\x${code.toString(16).padStart(2, "0")}`;
64531
+ }
64532
+ else {
64533
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
64534
+ }
64535
+ }
64536
+ return out;
64537
+ }
64430
64538
  function openLog(stateDir, deps) {
64431
64539
  const logPath = path.join(stateDir, "agent.log");
64432
64540
  const rotatedPath = path.join(stateDir, "agent.log.1");
@@ -64435,7 +64543,7 @@ function openLog(stateDir, deps) {
64435
64543
  deps.rename(logPath, rotatedPath);
64436
64544
  }
64437
64545
  return {
64438
- write: (line) => deps.appendFile(logPath, `${line}\n`),
64546
+ write: (line) => deps.appendFile(logPath, `${oneLine(line)}\n`),
64439
64547
  };
64440
64548
  }
64441
64549
 
@@ -64762,6 +64870,100 @@ const streamSSE = (c, cb, onError) => {
64762
64870
  return c.newResponse(stream.responseReadable);
64763
64871
  };
64764
64872
 
64873
+ /**
64874
+ * The agent's job-events SSE response, shared by every verb that exposes a job stream (#49 49b for the
64875
+ * claude jobs; #675 extracted it so `GET /v1/toolchain/upgrade/:jobId/events` is the same algorithm, not
64876
+ * a copy). The route is the host's own (`packages/host/src/tasks/route.ts:14-90`) with `exitCode` added to
64877
+ * the `done` frame -- `line` and `done` are the exact event names the host emits and the shell's
64878
+ * `useTasks.ts` listens for (E4 §5 decision 12).
64879
+ *
64880
+ * `kind` is the capability boundary: a verb serves only its own kind's jobs, so a job of another kind is
64881
+ * `404 no such job`, exactly like an unknown id. A plugin holding `agent.exec.claude` therefore never
64882
+ * reads an upgrade job's log, and one holding `agent.toolchain.upgrade` never reads a claude job's.
64883
+ */
64884
+ async function jobEventsResponse(jobs, id, c, kind) {
64885
+ const initial = jobs.get(id);
64886
+ if (!initial || initial.view.kind !== kind)
64887
+ return c.json({ ok: false, error: "no such job" }, 404);
64888
+ // streamSSE sets Cache-Control: no-cache itself -- only X-Accel-Buffering (disables a
64889
+ // fronting proxy's response buffering, so lines aren't held back) needs setting explicitly.
64890
+ c.header("X-Accel-Buffering", "no");
64891
+ return streamSSE(c, async (stream) => {
64892
+ if (initial.view.status !== "running") {
64893
+ for (let i = 0; i < initial.lines.length; i++) {
64894
+ if (stream.aborted)
64895
+ return;
64896
+ await stream.writeSSE({
64897
+ event: "line",
64898
+ data: JSON.stringify(initial.lines[i]),
64899
+ id: String(initial.linesOffset + i),
64900
+ });
64901
+ }
64902
+ await stream.writeSSE({
64903
+ event: "done",
64904
+ data: JSON.stringify({
64905
+ status: initial.view.status,
64906
+ exitCode: initial.view.exitCode ?? null,
64907
+ }),
64908
+ });
64909
+ return;
64910
+ }
64911
+ // Subscribe BEFORE replaying, synchronously with the `initial` snapshot above -- see
64912
+ // packages/host/src/tasks/route.ts:41-48 for why this ordering is what closes the race
64913
+ // window between a line being logged and it landing in neither the snapshot nor the
64914
+ // subscription.
64915
+ let replaying = true;
64916
+ const queued = [];
64917
+ let resolveDone;
64918
+ const donePromise = new Promise((resolve) => {
64919
+ resolveDone = resolve;
64920
+ });
64921
+ const unsubscribe = jobs.subscribe(id, {
64922
+ line: (line, lineId) => {
64923
+ if (replaying)
64924
+ queued.push({ line, id: lineId });
64925
+ else
64926
+ void stream.writeSSE({
64927
+ event: "line",
64928
+ data: JSON.stringify(line),
64929
+ id: String(lineId),
64930
+ });
64931
+ },
64932
+ done: (status, exitCode) => resolveDone({ status, exitCode }),
64933
+ });
64934
+ stream.onAbort(unsubscribe);
64935
+ for (let i = 0; i < initial.lines.length; i++) {
64936
+ if (stream.aborted)
64937
+ return;
64938
+ await stream.writeSSE({
64939
+ event: "line",
64940
+ data: JSON.stringify(initial.lines[i]),
64941
+ id: String(initial.linesOffset + i),
64942
+ });
64943
+ }
64944
+ for (const q of queued) {
64945
+ if (stream.aborted)
64946
+ return;
64947
+ await stream.writeSSE({ event: "line", data: JSON.stringify(q.line), id: String(q.id) });
64948
+ }
64949
+ replaying = false;
64950
+ const final = await donePromise;
64951
+ unsubscribe();
64952
+ await stream.writeSSE({
64953
+ event: "done",
64954
+ data: JSON.stringify({ status: final.status, exitCode: final.exitCode }),
64955
+ });
64956
+ });
64957
+ }
64958
+
64959
+ /** How long a terminated child is given on POSIX before `SIGKILL` follows the polite `SIGTERM`. */
64960
+ const KILL_GRACE_MS = 10_000;
64961
+ /** How long after a limit fires the job waits for the child to close before it is finished regardless
64962
+ * (the grace period above, and five seconds more). */
64963
+ const LIMIT_SETTLE_MS = KILL_GRACE_MS + 5_000;
64964
+ /** How much a forced finish discards from a pipe it could not close before it closes it (see `forceFinish`):
64965
+ * generous for any process that merely logs, a small fraction of what a flood writes in a second. */
64966
+ const DRAIN_BUDGET_BYTES = 64 * 1024 * 1024;
64765
64967
  function spawnJob(argv, options, handle, spawnFn = spawn) {
64766
64968
  const [cmd, ...args] = argv;
64767
64969
  handle.log(`${options.header} in ${options.cwd}`);
@@ -64774,25 +64976,152 @@ function spawnJob(argv, options, handle, spawnFn = spawn) {
64774
64976
  });
64775
64977
  let stdoutBuf = "";
64776
64978
  let stderrBuf = "";
64979
+ let settled = false;
64980
+ let limitHit = false;
64981
+ let exited = false;
64982
+ let exitCode = null;
64983
+ let loggedChars = 0;
64984
+ let graceTimer;
64985
+ let timeoutTimer;
64986
+ let settleTimer;
64987
+ let exitTimer;
64988
+ /** The one place a limit fires: tell the caller, say so in the log, and kill the child. A child that has
64989
+ * already exited is not killed (its pid may belong to another process by now, and `taskkill /t /f` would
64990
+ * take that one and everything it started): the limit is recorded, the rest of the output dropped, and
64991
+ * the job finishes when the output closes or `exitSettleMs` runs out. */
64992
+ function hitLimit(limit, message) {
64993
+ if (limitHit || settled)
64994
+ return;
64995
+ limitHit = true;
64996
+ options.onLimit?.(limit);
64997
+ handle.log(message);
64998
+ if (exited)
64999
+ return;
65000
+ killChild(child, spawnFn);
65001
+ if (process.platform !== "win32") {
65002
+ // SIGTERM asks politely; a child that ignores it (an installer mid-transaction) is killed outright.
65003
+ graceTimer = setTimeout(() => {
65004
+ if (!settled && !exited)
65005
+ child.kill("SIGKILL");
65006
+ }, KILL_GRACE_MS);
65007
+ graceTimer.unref?.();
65008
+ }
65009
+ settleTimer = setTimeout(forceFinish, options.limitSettleMs ?? LIMIT_SETTLE_MS);
65010
+ settleTimer.unref?.();
65011
+ }
65012
+ /** The child never closed (a descendant, out of the agent's reach, still holds its output pipes): finish
65013
+ * the job anyway so it cannot stay `running` and hold its slot forever. A child that had exited keeps
65014
+ * its real exit code (a killed one that exited is the same: its code is whatever the kill left, `null`
65015
+ * for a signal); one that was killed and never even exited finishes `failed` with none.
65016
+ *
65017
+ * The streams are DRAINED from here on rather than destroyed: closing the read end of a pipe another
65018
+ * process still holds makes that process's next write raise SIGPIPE, which by default ends it, and these
65019
+ * are the processes the agent must not disturb (a root-owned `apt-get` in the middle of a dpkg
65020
+ * transaction, a service a maintainer script started). What they write is discarded. The drain has a
65021
+ * budget (`drainBudgetBytes`): a descendant that FLOODS the pipe costs the agent most of a core for as
65022
+ * long as it runs, so past the budget the streams are destroyed after all, which ends only that one. */
65023
+ function forceFinish() {
65024
+ if (settled)
65025
+ return;
65026
+ settled = true;
65027
+ stopTimers();
65028
+ const budget = options.drainBudgetBytes ?? DRAIN_BUDGET_BYTES;
65029
+ let drained = 0;
65030
+ for (const stream of [child.stdout, child.stderr]) {
65031
+ stream?.removeAllListeners("data");
65032
+ stream?.on("data", (chunk) => {
65033
+ drained += chunk.length;
65034
+ if (drained > budget) {
65035
+ child.stdout?.destroy();
65036
+ child.stderr?.destroy();
65037
+ }
65038
+ });
65039
+ stream?.resume();
65040
+ }
65041
+ if (exited) {
65042
+ if (stdoutBuf !== "")
65043
+ emit(stdoutBuf);
65044
+ if (stderrBuf !== "")
65045
+ emit(stderrBuf);
65046
+ handle.log("[the command exited, but its output never closed; processes it started may still be running]");
65047
+ handle.finish(exitCode === 0 && !limitHit ? "done" : "failed", exitCode);
65048
+ return;
65049
+ }
65050
+ handle.log("[killed, but its output never closed; processes it started may still be running]");
65051
+ handle.finish("failed", null);
65052
+ }
65053
+ /** Every line the child prints goes through here, so the output cap counts exactly what is logged. */
65054
+ function emit(line) {
65055
+ if (limitHit)
65056
+ return; // after a limit fires, the rest of the output is dropped, not logged
65057
+ loggedChars += line.length + 1;
65058
+ if (options.maxOutputChars !== undefined && loggedChars > options.maxOutputChars) {
65059
+ hitLimit("output-cap", `[output capped at ${options.maxOutputChars} characters; killed]`);
65060
+ return;
65061
+ }
65062
+ handle.log(line);
65063
+ }
64777
65064
  function flush(buf, chunk) {
64778
65065
  const combined = buf + chunk;
64779
65066
  const lines = combined.split("\n");
64780
65067
  const remainder = lines.pop() ?? "";
64781
65068
  for (const line of lines)
64782
- handle.log(line);
65069
+ emit(line);
64783
65070
  return remainder;
64784
65071
  }
65072
+ /** The cap also counts what is held back waiting for a newline: a child that prints without ever ending
65073
+ * a line (a progress bar redrawn with `\r`, or a runaway) must not grow the agent's memory unboundedly
65074
+ * just because `emit` has not seen a line yet. */
65075
+ function checkHeldOutput() {
65076
+ const cap = options.maxOutputChars;
65077
+ if (cap !== undefined && loggedChars + stdoutBuf.length + stderrBuf.length > cap) {
65078
+ hitLimit("output-cap", `[output capped at ${cap} characters; killed]`);
65079
+ }
65080
+ }
64785
65081
  child.stdout?.on("data", (chunk) => {
65082
+ if (limitHit)
65083
+ return;
64786
65084
  stdoutBuf = flush(stdoutBuf, chunk.toString());
65085
+ checkHeldOutput();
64787
65086
  });
64788
65087
  child.stderr?.on("data", (chunk) => {
65088
+ if (limitHit)
65089
+ return;
64789
65090
  stderrBuf = flush(stderrBuf, chunk.toString());
65091
+ checkHeldOutput();
65092
+ });
65093
+ if (options.timeoutMs !== undefined) {
65094
+ const ms = options.timeoutMs;
65095
+ timeoutTimer = setTimeout(() => hitLimit("timeout", `[timed out after ${ms} ms; killed]`), ms);
65096
+ timeoutTimer.unref?.();
65097
+ }
65098
+ function stopTimers() {
65099
+ if (timeoutTimer !== undefined)
65100
+ clearTimeout(timeoutTimer);
65101
+ if (graceTimer !== undefined)
65102
+ clearTimeout(graceTimer);
65103
+ if (settleTimer !== undefined)
65104
+ clearTimeout(settleTimer);
65105
+ }
65106
+ child.on("exit", (code) => {
65107
+ if (settled)
65108
+ return;
65109
+ exited = true;
65110
+ exitCode = code;
65111
+ // The command is over: nothing is left to time out, and a late kill is unsafe (pid reuse). The grace
65112
+ // timer, if one is pending, checks `exited` itself; `forceFinish` returns once `settled`.
65113
+ if (timeoutTimer !== undefined)
65114
+ clearTimeout(timeoutTimer);
65115
+ if (options.exitSettleMs !== undefined) {
65116
+ exitTimer = setTimeout(forceFinish, options.exitSettleMs);
65117
+ exitTimer.unref?.();
65118
+ }
64790
65119
  });
64791
- let settled = false;
64792
65120
  child.on("error", (err) => {
64793
65121
  if (settled)
64794
65122
  return;
64795
65123
  settled = true;
65124
+ stopTimers();
64796
65125
  handle.log(`[spawn failed] ${err.message}`);
64797
65126
  handle.finish("failed", null);
64798
65127
  });
@@ -64800,22 +65129,47 @@ function spawnJob(argv, options, handle, spawnFn = spawn) {
64800
65129
  if (settled)
64801
65130
  return;
64802
65131
  settled = true;
65132
+ stopTimers();
64803
65133
  // A trailing partial line (no final newline) is flushed here rather than dropped.
64804
65134
  if (stdoutBuf !== "")
64805
- handle.log(stdoutBuf);
65135
+ emit(stdoutBuf);
64806
65136
  if (stderrBuf !== "")
64807
- handle.log(stderrBuf);
64808
- handle.finish(code === 0 ? "done" : "failed", code);
65137
+ emit(stderrBuf);
65138
+ handle.finish(code === 0 && !limitHit ? "done" : "failed", code);
64809
65139
  });
64810
65140
  }
65141
+ /** Kill a child and, on win32, everything it started: `taskkill /pid <pid> /t /f` (the shell is never
65142
+ * involved; a failure to launch it is swallowed because `child.kill()` below is the fallback). */
65143
+ function killChild(child, spawnFn) {
65144
+ if (process.platform === "win32" && child.pid !== undefined) {
65145
+ try {
65146
+ // By absolute path (as `regExePath` does for reg.exe): a bare name is searched for in the working
65147
+ // directory before PATH.
65148
+ const taskkill = path.win32.join(process.env.SystemRoot ?? process.env.windir ?? String.raw `C:\Windows`, "System32", "taskkill.exe");
65149
+ const killer = spawnFn(taskkill, ["/pid", String(child.pid), "/t", "/f"], {
65150
+ shell: false,
65151
+ windowsHide: true,
65152
+ stdio: "ignore",
65153
+ });
65154
+ killer.on("error", () => { });
65155
+ }
65156
+ catch {
65157
+ // fall through to the plain kill
65158
+ }
65159
+ }
65160
+ child.kill();
65161
+ }
64811
65162
 
64812
65163
  /**
64813
65164
  * `POST /v1/exec/claude`, `GET /v1/jobs`, `GET /v1/jobs/:id`, `POST /v1/jobs/clear-finished`,
64814
- * `GET /v1/jobs/:id/events` (#49, 49b, items 3-5). The SSE route is the host's own algorithm
64815
- * (`packages/host/src/tasks/route.ts:14-90`) with `exitCode` added to the `done` frame -- `line`
64816
- * and `done` are the exact event names the host emits and the shell's `useTasks.ts` listens for
64817
- * (E4 §5 decision 12), so a browser `EventSource` deliberately isn't the client here (the token
64818
- * travels as a header; the host's pinned client is what actually calls this).
65165
+ * `GET /v1/jobs/:id/events` (#49, 49b, items 3-5). The SSE route is `jobs/events.ts`'s (the host's own
65166
+ * algorithm, `packages/host/src/tasks/route.ts:14-90`, with `exitCode` added to the `done` frame; #675
65167
+ * extracted it so the upgrade verb shares it), so a browser `EventSource` deliberately isn't the client
65168
+ * here (the token travels as a header; the host's pinned client is what actually calls this).
65169
+ *
65170
+ * Every route here serves the `claude` kind only (#675): the registry also holds `toolchain-upgrade` jobs,
65171
+ * which `agent.exec.claude` must not list, read, stream or clear, and which must not use up one of the
65172
+ * claude jobs' four slots (`runningCount("claude")`).
64819
65173
  */
64820
65174
  const MAX_PROMPT_LENGTH = 8192;
64821
65175
  const PERMISSION_MODES = ["plan", "acceptEdits", "bypassPermissions"];
@@ -64894,7 +65248,7 @@ function registerClaudeVerbs(registry, deps) {
64894
65248
  : "claude not found on PATH";
64895
65249
  return c.json({ ok: false, error }, 502);
64896
65250
  }
64897
- if (deps.jobs.runningCount() >= MAX_RUNNING_CLAUDE_JOBS) {
65251
+ if (deps.jobs.runningCount("claude") >= MAX_RUNNING_CLAUDE_JOBS) {
64898
65252
  return c.json({ ok: false, error: "too many running jobs" }, 429);
64899
65253
  }
64900
65254
  const argv = [
@@ -64925,7 +65279,7 @@ function registerClaudeVerbs(registry, deps) {
64925
65279
  capability: "agent.exec.claude",
64926
65280
  method: "GET",
64927
65281
  path: "/jobs",
64928
- handler: async (c) => c.json({ ok: true, jobs: deps.jobs.list() }),
65282
+ handler: async (c) => c.json({ ok: true, jobs: deps.jobs.list().filter((job) => job.kind === "claude") }),
64929
65283
  });
64930
65284
  registry.register({
64931
65285
  capability: "agent.exec.claude",
@@ -64933,8 +65287,10 @@ function registerClaudeVerbs(registry, deps) {
64933
65287
  path: "/jobs/:id",
64934
65288
  handler: async (c) => {
64935
65289
  const entry = deps.jobs.get(c.req.param("id") ?? "");
64936
- if (!entry)
65290
+ // Another kind's job (#675) is "no such job" here, exactly like an unknown id.
65291
+ if (entry?.view.kind !== "claude") {
64937
65292
  return c.json({ ok: false, error: "no such job" }, 404);
65293
+ }
64938
65294
  return c.json({ ok: true, job: entry.view });
64939
65295
  },
64940
65296
  });
@@ -64942,87 +65298,13 @@ function registerClaudeVerbs(registry, deps) {
64942
65298
  capability: "agent.exec.claude",
64943
65299
  method: "POST",
64944
65300
  path: "/jobs/clear-finished",
64945
- handler: async (c) => c.json({ ok: true, cleared: deps.jobs.clearFinished() }),
65301
+ handler: async (c) => c.json({ ok: true, cleared: deps.jobs.clearFinished("claude") }),
64946
65302
  });
64947
65303
  registry.register({
64948
65304
  capability: "agent.exec.claude",
64949
65305
  method: "GET",
64950
65306
  path: "/jobs/:id/events",
64951
- handler: async (c) => {
64952
- const id = c.req.param("id") ?? "";
64953
- const initial = deps.jobs.get(id);
64954
- if (!initial)
64955
- return c.json({ ok: false, error: "no such job" }, 404);
64956
- // streamSSE sets Cache-Control: no-cache itself -- only X-Accel-Buffering (disables a
64957
- // fronting proxy's response buffering, so lines aren't held back) needs setting explicitly.
64958
- c.header("X-Accel-Buffering", "no");
64959
- return streamSSE(c, async (stream) => {
64960
- if (initial.view.status !== "running") {
64961
- for (let i = 0; i < initial.lines.length; i++) {
64962
- if (stream.aborted)
64963
- return;
64964
- await stream.writeSSE({
64965
- event: "line",
64966
- data: JSON.stringify(initial.lines[i]),
64967
- id: String(initial.linesOffset + i),
64968
- });
64969
- }
64970
- await stream.writeSSE({
64971
- event: "done",
64972
- data: JSON.stringify({
64973
- status: initial.view.status,
64974
- exitCode: initial.view.exitCode ?? null,
64975
- }),
64976
- });
64977
- return;
64978
- }
64979
- // Subscribe BEFORE replaying, synchronously with the `initial` snapshot above -- see
64980
- // packages/host/src/tasks/route.ts:41-48 for why this ordering is what closes the race
64981
- // window between a line being logged and it landing in neither the snapshot nor the
64982
- // subscription.
64983
- let replaying = true;
64984
- const queued = [];
64985
- let resolveDone;
64986
- const donePromise = new Promise((resolve) => {
64987
- resolveDone = resolve;
64988
- });
64989
- const unsubscribe = deps.jobs.subscribe(id, {
64990
- line: (line, lineId) => {
64991
- if (replaying)
64992
- queued.push({ line, id: lineId });
64993
- else
64994
- void stream.writeSSE({
64995
- event: "line",
64996
- data: JSON.stringify(line),
64997
- id: String(lineId),
64998
- });
64999
- },
65000
- done: (status, exitCode) => resolveDone({ status, exitCode }),
65001
- });
65002
- stream.onAbort(unsubscribe);
65003
- for (let i = 0; i < initial.lines.length; i++) {
65004
- if (stream.aborted)
65005
- return;
65006
- await stream.writeSSE({
65007
- event: "line",
65008
- data: JSON.stringify(initial.lines[i]),
65009
- id: String(initial.linesOffset + i),
65010
- });
65011
- }
65012
- for (const q of queued) {
65013
- if (stream.aborted)
65014
- return;
65015
- await stream.writeSSE({ event: "line", data: JSON.stringify(q.line), id: String(q.id) });
65016
- }
65017
- replaying = false;
65018
- const final = await donePromise;
65019
- unsubscribe();
65020
- await stream.writeSSE({
65021
- event: "done",
65022
- data: JSON.stringify({ status: final.status, exitCode: final.exitCode }),
65023
- });
65024
- });
65025
- },
65307
+ handler: async (c) => jobEventsResponse(deps.jobs, c.req.param("id") ?? "", c, "claude"),
65026
65308
  });
65027
65309
  }
65028
65310
 
@@ -65374,15 +65656,18 @@ function createJobRegistry(opts = {}) {
65374
65656
  // it forward to find the oldest finished entry.
65375
65657
  const entries = new Map();
65376
65658
  let seq = 0;
65377
- function evictOldestFinishedIfOverCap() {
65659
+ /** The cap is per kind (#675): a plugin holding only `agent.exec.claude` that runs a few hundred
65660
+ * trivial jobs must not evict a finished upgrade job (and its outcome) the console has yet to read. */
65661
+ function evictOldestFinishedIfOverCap(kind) {
65378
65662
  let finishedCount = 0;
65379
- for (const e of entries.values())
65380
- if (e.view.status !== "running")
65663
+ for (const e of entries.values()) {
65664
+ if (e.view.status !== "running" && e.view.kind === kind)
65381
65665
  finishedCount++;
65666
+ }
65382
65667
  if (finishedCount <= maxFinished)
65383
65668
  return;
65384
65669
  for (const [id, e] of entries) {
65385
- if (e.view.status !== "running") {
65670
+ if (e.view.status !== "running" && e.view.kind === kind) {
65386
65671
  entries.delete(id);
65387
65672
  return;
65388
65673
  }
@@ -65437,7 +65722,7 @@ function createJobRegistry(opts = {}) {
65437
65722
  }
65438
65723
  }
65439
65724
  entry.subscribers.clear();
65440
- evictOldestFinishedIfOverCap();
65725
+ evictOldestFinishedIfOverCap(entry.view.kind);
65441
65726
  },
65442
65727
  };
65443
65728
  }
@@ -65465,17 +65750,18 @@ function createJobRegistry(opts = {}) {
65465
65750
  entry.subscribers.add(on);
65466
65751
  return () => entry.subscribers.delete(on);
65467
65752
  },
65468
- runningCount: () => {
65753
+ runningCount: (kind) => {
65469
65754
  let count = 0;
65470
- for (const e of entries.values())
65471
- if (e.view.status === "running")
65755
+ for (const e of entries.values()) {
65756
+ if (e.view.status === "running" && (kind === undefined || e.view.kind === kind))
65472
65757
  count++;
65758
+ }
65473
65759
  return count;
65474
65760
  },
65475
- clearFinished: () => {
65761
+ clearFinished: (kind) => {
65476
65762
  let cleared = 0;
65477
65763
  for (const [id, e] of entries) {
65478
- if (e.view.status !== "running") {
65764
+ if (e.view.status !== "running" && (kind === undefined || e.view.kind === kind)) {
65479
65765
  entries.delete(id);
65480
65766
  cleared++;
65481
65767
  }
@@ -66608,7 +66894,7 @@ function ensureScript(fs, stateDir) {
66608
66894
  function sanitizeMachine(hostname) {
66609
66895
  return hostname.replace(/[^A-Za-z0-9._-]/g, "-");
66610
66896
  }
66611
- function lastNonEmptyLine$1(text) {
66897
+ function lastNonEmptyLine$2(text) {
66612
66898
  const lines = text
66613
66899
  .split("\n")
66614
66900
  .map((line) => line.replace(/\r$/, ""))
@@ -66688,7 +66974,7 @@ async function runToolchainInventory(deps, signal) {
66688
66974
  throw new Error("scan aborted");
66689
66975
  }
66690
66976
  if (result.code !== 0) {
66691
- const detail = lastNonEmptyLine$1(result.stderr) || lastNonEmptyLine$1(result.stdout);
66977
+ const detail = lastNonEmptyLine$2(result.stderr) || lastNonEmptyLine$2(result.stdout);
66692
66978
  throw new Error(`Get-ToolchainInventory.ps1 exited ${result.code}: ${detail}`);
66693
66979
  }
66694
66980
  const resultKind = classifyResult(result.stdout);
@@ -66932,7 +67218,7 @@ const PREREQ_TABLE = [
66932
67218
  },
66933
67219
  ];
66934
67220
 
66935
- const VERSION_TIMEOUT_MS = 15_000;
67221
+ const VERSION_TIMEOUT_MS$1 = 15_000;
66936
67222
  function firstNonEmptyLine(text) {
66937
67223
  const lines = text
66938
67224
  .split(/\r?\n/)
@@ -66940,7 +67226,7 @@ function firstNonEmptyLine(text) {
66940
67226
  .filter((l) => l !== "");
66941
67227
  return lines[0] ?? "";
66942
67228
  }
66943
- function lastNonEmptyLine(...texts) {
67229
+ function lastNonEmptyLine$1(...texts) {
66944
67230
  const lines = texts
66945
67231
  .join("\n")
66946
67232
  .split(/\r?\n/)
@@ -66969,7 +67255,7 @@ async function verdictForFound(entry, deps, refreshedEnv, foundPath, onPath) {
66969
67255
  return { ...base, status: "ok", path: foundPath, version: "", detail: "" };
66970
67256
  }
66971
67257
  const versionResult = await deps.run(foundPath, ["--version"], {
66972
- timeoutMs: VERSION_TIMEOUT_MS,
67258
+ timeoutMs: VERSION_TIMEOUT_MS$1,
66973
67259
  env: refreshedEnv,
66974
67260
  });
66975
67261
  if (versionResult.timedOut) {
@@ -66987,17 +67273,17 @@ async function verdictForFound(entry, deps, refreshedEnv, foundPath, onPath) {
66987
67273
  status: "broken",
66988
67274
  path: foundPath,
66989
67275
  version: "",
66990
- detail: lastNonEmptyLine(versionResult.stdout, versionResult.stderr),
67276
+ detail: lastNonEmptyLine$1(versionResult.stdout, versionResult.stderr),
66991
67277
  };
66992
67278
  }
66993
67279
  const version = firstNonEmptyLine(versionResult.stdout);
66994
67280
  if (entry.cmd === "gh") {
66995
67281
  const authResult = await deps.run(foundPath, ["auth", "status"], {
66996
- timeoutMs: VERSION_TIMEOUT_MS,
67282
+ timeoutMs: VERSION_TIMEOUT_MS$1,
66997
67283
  env: refreshedEnv,
66998
67284
  });
66999
67285
  if (authResult.code !== 0) {
67000
- const stderrLine = lastNonEmptyLine(authResult.stderr);
67286
+ const stderrLine = lastNonEmptyLine$1(authResult.stderr);
67001
67287
  return {
67002
67288
  ...base,
67003
67289
  status: "unauthenticated",
@@ -67739,7 +68025,7 @@ const MAX_TOTAL_DECODED_BYTES = 48 * 1024 * 1024;
67739
68025
  // body-too-large 413 first, never reaching (and never able to report) the more specific decoded-total
67740
68026
  // 400 below. +2 MB of headroom comfortably covers realistic JSON structure for up to MAX_FILES
67741
68027
  // entries (tens of KB in practice) so both checks are independently reachable.
67742
- const MAX_REQUEST_BODY_BYTES = 66 * 1024 * 1024;
68028
+ const MAX_REQUEST_BODY_BYTES$1 = 66 * 1024 * 1024;
67743
68029
  const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
67744
68030
  /** `Buffer.from(s, "base64")` is lenient -- it silently drops characters outside the base64 alphabet
67745
68031
  * rather than throwing, so "does it decode" needs its own check, not a try/catch around the decode. */
@@ -67796,7 +68082,7 @@ function registerPublishVerb(registry, deps) {
67796
68082
  path: "/publish/mirror",
67797
68083
  middleware: [
67798
68084
  bodyLimit({
67799
- maxSize: MAX_REQUEST_BODY_BYTES,
68085
+ maxSize: MAX_REQUEST_BODY_BYTES$1,
67800
68086
  onError: (c) => c.json({ ok: false, error: "request body too large" }, 413),
67801
68087
  }),
67802
68088
  ],
@@ -75256,6 +75542,568 @@ var tunnel = /*#__PURE__*/Object.freeze({
75256
75542
  registerTunnelVerb: registerTunnelVerb
75257
75543
  });
75258
75544
 
75545
+ /**
75546
+ * `POST /v1/toolchain/upgrade`, `GET /v1/toolchain/upgrade/:jobId`, `GET /v1/toolchain/upgrade/:jobId/events`
75547
+ * (#675, PR 1 of 2), capability `agent.toolchain.upgrade`: the agent installs software on the machine it
75548
+ * runs on, so this is the one verb whose request is a security surface. The design (roshne's decisions on
75549
+ * #675, 2026-10-05):
75550
+ *
75551
+ * - TWO gates, both required. The console's grant (the registry's guard, in front of every handler here) and
75552
+ * the machine owner's own opt-in, `toolchain_upgrade = true` in this machine's `agent.toml`
75553
+ * (`deps.enabled`, the config the agent loaded at start), the first middleware after the grant guard, so
75554
+ * a machine that has not opted in does no work at all for a request: not even its body is limited or read.
75555
+ * The POST's body is then capped at `MAX_REQUEST_BODY_BYTES` before it is read.
75556
+ * - FIXED COMMAND SHAPES, never free text. `commandFor` is the only place an upgrade argv is built, from
75557
+ * three literal shapes and one validated id. Every field is checked against a grammar before anything
75558
+ * spawns; a request that does not match is a `400` naming the field. A valid id can still name a package
75559
+ * the operator did not mean (the console decides which one), which is the accepted worst case. The one
75560
+ * other program the request can name is `cmd`, and only to read its version: it must be one of the tools
75561
+ * the agent's own preflight already runs `--version` on (`VERSION_READABLE_CMDS`), never an executable of
75562
+ * the request's choosing.
75563
+ * - NO ELEVATION in the agent: it never tries to elevate itself. Linux needs a sudoers rule for the agent's
75564
+ * user (`needs-sudoers`). On Windows the agent is a limited logon task: winget answers `0x8A150019` when
75565
+ * the command itself needs administrator rights (`needs-elevation`, with the command to paste into an
75566
+ * elevated window). An installer that elevates itself through UAC may instead put a prompt on the user's
75567
+ * desktop, which the agent cannot answer (not verified here; see the README).
75568
+ * - One upgrade at a time, as a job with streamed output (apt and winget outlast the host client's 30 s
75569
+ * body budget), a 15-minute timeout and a 1 MiB output cap, both enforced by `spawnJob`.
75570
+ * - AUDIT: two log lines per run. One when the job starts (tool, manager, id, `cmd`), so a run the agent does
75571
+ * not survive still leaves a record; one when it ends (tool, manager, id, before, after, outcome, exit
75572
+ * code, `cmd`). The console-supplied `tool` is printed in quotes, and its grammar has no quote.
75573
+ */
75574
+ const UPGRADE_CAPABILITY = "agent.toolchain.upgrade";
75575
+ const KIND = "toolchain-upgrade";
75576
+ /** 15 minutes: a large winget installer is the slow case; past this it is hung, and is killed. */
75577
+ const UPGRADE_TIMEOUT_MS = 15 * 60_000;
75578
+ /** 1 MiB of logged output: a runaway installer must not fill the agent's memory with log lines. */
75579
+ const UPGRADE_MAX_OUTPUT_CHARS = 1_048_576;
75580
+ const VERSION_TIMEOUT_MS = 20_000;
75581
+ /** After the upgrade command has exited, how long its output may stay open (a descendant it left running
75582
+ * can hold the pipes) before the job is finished with the command's real exit code anyway. */
75583
+ const EXIT_SETTLE_MS = 5_000;
75584
+ /** The most bytes of request body the POST reads: the largest valid body is a few hundred, so a granted
75585
+ * console cannot make the agent buffer an unbounded one. */
75586
+ const MAX_REQUEST_BODY_BYTES = 4096;
75587
+ const MAX_CONCURRENT_UPGRADES = 1;
75588
+ /** Finished jobs' outcomes kept for the status route (the job registry itself keeps 200 finished jobs). */
75589
+ const MAX_REMEMBERED_OUTCOMES = 200;
75590
+ const MAX_REASON_CHARS = 300;
75591
+ const NOT_OPTED_IN_ERROR = "toolchain upgrades are not enabled on this machine (agent.toml toolchain_upgrade)";
75592
+ const MANAGERS = ["winget", "apt", "native"];
75593
+ // The tool key lands in a job label and, in quotes, in the audit lines, so it is words and a little
75594
+ // punctuation: no quote, ":", "(", ")", ">" or ",", the characters that would let one run's line imitate
75595
+ // another's outcome. Spaces stay (the standard's keys have them: "GitHub CLI").
75596
+ const TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,63}$/;
75597
+ /** The commands whose `--version` this verb may run: the agent's own preflight table, minus the one entry
75598
+ * the preflight itself never executes (a Chromium-family `--version` can open a window, E4 §5 decision
75599
+ * 19). The Environment panel sends the `cmd` of a preflight row, so these are the only values it ever
75600
+ * sends; anything else is a request to run a program of the console's choosing. */
75601
+ const VERSION_READABLE_CMDS = PREREQ_TABLE.map((entry) => entry.cmd).filter((cmd) => cmd !== "chromium");
75602
+ // winget: a `Publisher.Name` id (`Git.Git`, `Microsoft.PowerShell`); must start alphanumeric, so it can
75603
+ // never be read as an option, and must contain a ".".
75604
+ const WINGET_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/;
75605
+ // apt: the Debian package-name grammar (2+ characters, lowercase letters, digits, "+", "-", ".").
75606
+ const APT_PACKAGE_PATTERN = /^[a-z0-9][a-z0-9+.-]{1,127}$/;
75607
+ const NATIVE_ID = "claude";
75608
+ function isManager(value) {
75609
+ return typeof value === "string" && MANAGERS.includes(value);
75610
+ }
75611
+ function refuse$1(error) {
75612
+ return { ok: false, error };
75613
+ }
75614
+ /** The pure half of the request check: every refusal names its field, and no process is started on a
75615
+ * refusal. `platform` decides which managers exist on this machine. A field is read only when it is the
75616
+ * body's OWN property, so a body cannot reach an inherited one. */
75617
+ function validateUpgradeRequest(body, platform) {
75618
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
75619
+ return refuse$1("invalid JSON body");
75620
+ }
75621
+ const field = (name) => Object.hasOwn(body, name) ? body[name] : undefined;
75622
+ const tool = field("tool");
75623
+ if (typeof tool !== "string" || tool === "")
75624
+ return refuse$1("tool: must be a non-empty string");
75625
+ if (!TOOL_PATTERN.test(tool)) {
75626
+ return refuse$1("tool: must be 1 to 64 letters, digits, spaces or . _ + - (starting with a letter or digit)");
75627
+ }
75628
+ const cmd = field("cmd");
75629
+ if (typeof cmd !== "string" || cmd === "")
75630
+ return refuse$1("cmd: must be a non-empty string");
75631
+ // An allowlist, not a grammar: only the preflight's own executed tools pass, so no name, path or
75632
+ // separator that is not one of them can reach `which`.
75633
+ if (!VERSION_READABLE_CMDS.includes(cmd)) {
75634
+ return refuse$1(`cmd: not a tool this agent reads the version of (${VERSION_READABLE_CMDS.join(", ")})`);
75635
+ }
75636
+ const manager = field("manager");
75637
+ if (!isManager(manager))
75638
+ return refuse$1(`manager: must be one of ${MANAGERS.join(", ")}`);
75639
+ if (manager === "winget" && platform !== "win32") {
75640
+ return refuse$1("manager: winget is only available on win32 machines");
75641
+ }
75642
+ if (manager === "apt" && platform !== "linux") {
75643
+ return refuse$1("manager: apt is only available on linux machines");
75644
+ }
75645
+ const packageId = field("packageId");
75646
+ if (typeof packageId !== "string" || packageId === "") {
75647
+ return refuse$1("packageId: must be a non-empty string");
75648
+ }
75649
+ if (manager === "winget") {
75650
+ if (!WINGET_ID_PATTERN.test(packageId)) {
75651
+ return refuse$1("packageId: not a winget package id (letters, digits and . + _ -, starting with a letter or digit)");
75652
+ }
75653
+ if (!packageId.includes(".")) {
75654
+ return refuse$1('packageId: a winget package id has the form Publisher.Name (it needs a ".")');
75655
+ }
75656
+ }
75657
+ else if (manager === "apt") {
75658
+ if (!APT_PACKAGE_PATTERN.test(packageId)) {
75659
+ return refuse$1("packageId: not a Debian package name (lowercase letters, digits, + - ., 2 to 128 characters)");
75660
+ }
75661
+ // apt-get reads a trailing "-" on a package argument as "remove this package".
75662
+ if (packageId.endsWith("-")) {
75663
+ return refuse$1('packageId: a trailing "-" would make apt-get remove the package');
75664
+ }
75665
+ }
75666
+ else {
75667
+ if (packageId !== NATIVE_ID)
75668
+ return refuse$1(`packageId: the native installer only knows "${NATIVE_ID}"`);
75669
+ if (cmd !== NATIVE_ID)
75670
+ return refuse$1(`cmd: a native upgrade runs the "${NATIVE_ID}" binary`);
75671
+ }
75672
+ return { ok: true, value: { tool, cmd, manager, packageId } };
75673
+ }
75674
+ // ---- the command ---------------------------------------------------------------------------------
75675
+ /** The three literal command shapes (the only argv this verb ever builds). `executablePath` is the
75676
+ * resolved `winget`, `sudo` or `claude`; `packageId` was validated by `validateUpgradeRequest`. */
75677
+ function commandFor(manager, packageId, executablePath) {
75678
+ switch (manager) {
75679
+ case "winget":
75680
+ return [
75681
+ executablePath,
75682
+ "upgrade",
75683
+ "--id",
75684
+ packageId,
75685
+ "--exact",
75686
+ "--silent",
75687
+ "--accept-package-agreements",
75688
+ "--accept-source-agreements",
75689
+ "--disable-interactivity",
75690
+ ];
75691
+ case "apt":
75692
+ // --no-remove (apt-get(8): "If any packages are to be removed apt-get immediately aborts"): with -y
75693
+ // alone a package whose new version Conflicts with or Breaks another installed package removes that
75694
+ // one without asking. After `-y`, so the sudoers rule's trailing `*` still matches.
75695
+ return [
75696
+ executablePath,
75697
+ "-n",
75698
+ "apt-get",
75699
+ "install",
75700
+ "--only-upgrade",
75701
+ "-y",
75702
+ "--no-remove",
75703
+ packageId,
75704
+ ];
75705
+ case "native":
75706
+ return [executablePath, "update"];
75707
+ }
75708
+ }
75709
+ /** The command as an operator would type it: the executable by name, not by the path this agent found. */
75710
+ function typedCommand(manager, packageId) {
75711
+ const [exe, ...args] = commandFor(manager, packageId, "winget");
75712
+ return [exe, ...args].join(" ");
75713
+ }
75714
+ // winget's documented return codes (microsoft/winget-cli `doc/windows/package-manager/winget/returnCodes.md`,
75715
+ // generated by `winget error`), pinned by number. Node reports a Windows exit code as unsigned; `>>> 0`
75716
+ // below makes a signed one compare the same.
75717
+ const WINGET_COMMAND_REQUIRES_ADMIN = 0x8a150019;
75718
+ const WINGET_UPDATE_NOT_APPLICABLE = 0x8a15002b;
75719
+ const WINGET_NO_APPLICATIONS_FOUND = 0x8a150014;
75720
+ // sudo's own wording when `-n` cannot proceed for want of a rule: sudo 1.9 (sudoers' logging.c, sudo(8)),
75721
+ // then sudo-rs, the default `sudo` of recent Ubuntu (strings read from the 0.2.13 binary on Ubuntu 26.04: "interactive
75722
+ // authentication is required", "I'm sorry <user>. I'm afraid I can't do that", "Sorry, user <u> may not
75723
+ // run <command> on <host>", "... is not allowed to execute '<command>' as <u> on <host>").
75724
+ const SUDO_REFUSALS = [
75725
+ /sudo: a password is required/i,
75726
+ /is not in the sudoers file/i,
75727
+ /is not allowed to execute/i,
75728
+ /is not allowed to run sudo on/i,
75729
+ /interactive authentication is required/i,
75730
+ /I'm afraid I can't do that/i,
75731
+ // "Sorry, user <u> may not run sudo on <host>" (sudo 1.9) and "... may not run <command> on <host>" (sudo-rs)
75732
+ /user \S+ may not run .+ on /i,
75733
+ ];
75734
+ function sudoersRule(username) {
75735
+ return `${username} ALL=(root) NOPASSWD: /usr/bin/apt-get install --only-upgrade -y *`;
75736
+ }
75737
+ /** Printable ASCII only, and bounded: this text ends up in a job log, a status body and an audit line. */
75738
+ function printable(text, max) {
75739
+ return text.replace(/[^\x20-\x7e]/g, "?").slice(0, max);
75740
+ }
75741
+ function lastNonEmptyLine(text) {
75742
+ const lines = text
75743
+ .split(/\r?\n/)
75744
+ .map((line) => line.trim())
75745
+ .filter((line) => line !== "");
75746
+ return printable(lines.at(-1) ?? "", MAX_REASON_CHARS);
75747
+ }
75748
+ function classifyOutcome(input) {
75749
+ if (input.limit === "timeout") {
75750
+ return {
75751
+ status: "timed-out",
75752
+ reason: `killed after ${UPGRADE_TIMEOUT_MS / 60_000} minutes; the package may be half-installed, check it before retrying`,
75753
+ };
75754
+ }
75755
+ if (input.limit === "output-cap") {
75756
+ return {
75757
+ status: "failed",
75758
+ reason: `output passed ${UPGRADE_MAX_OUTPUT_CHARS} characters; the rest was dropped`,
75759
+ };
75760
+ }
75761
+ if (input.exitCode === null) {
75762
+ return {
75763
+ status: "failed",
75764
+ reason: lastNonEmptyLine(input.output) ||
75765
+ "the command ended without an exit code (killed by a signal, or it could not be started)",
75766
+ };
75767
+ }
75768
+ const code = input.exitCode >>> 0;
75769
+ if (input.manager === "winget") {
75770
+ if (code === WINGET_COMMAND_REQUIRES_ADMIN) {
75771
+ return {
75772
+ status: "needs-elevation",
75773
+ reason: `this package needs administrator rights; run in an elevated terminal: ${typedCommand("winget", input.packageId)}`,
75774
+ };
75775
+ }
75776
+ if (code === WINGET_UPDATE_NOT_APPLICABLE) {
75777
+ return {
75778
+ status: "already-current",
75779
+ reason: "winget reports no applicable update (no newer version this machine can install)",
75780
+ };
75781
+ }
75782
+ if (code === WINGET_NO_APPLICATIONS_FOUND) {
75783
+ return { status: "failed", reason: "winget found no installed package with that id" };
75784
+ }
75785
+ }
75786
+ if (code !== 0) {
75787
+ if (input.manager === "apt" && SUDO_REFUSALS.some((pattern) => pattern.test(input.output))) {
75788
+ return {
75789
+ status: "needs-sudoers",
75790
+ reason: `sudo refused: add a sudoers rule for this agent's user: ${sudoersRule(input.username)} (that rule lets any process of that user become root without a password; see the agent README, Operator setup)`,
75791
+ };
75792
+ }
75793
+ return { status: "failed", reason: lastNonEmptyLine(input.output) || `exit ${code}` };
75794
+ }
75795
+ // Exit 0: what changed decides it.
75796
+ if (input.after === "missing") {
75797
+ return {
75798
+ status: "failed",
75799
+ reason: "the command succeeded but the tool's version cannot be read afterwards",
75800
+ };
75801
+ }
75802
+ const aptUpgraded = input.manager === "apt" && Number(/(\d+) upgraded,/.exec(input.output)?.[1] ?? "0") > 0;
75803
+ if (input.after !== input.before || aptUpgraded)
75804
+ return { status: "upgraded" };
75805
+ return { status: "already-current" };
75806
+ }
75807
+ /** The lines `spawnJob` writes to the job log about the command rather than from it, from a forced finish
75808
+ * (the `[timed out` and `[output capped` ones never matter here: a limit decides the outcome before the
75809
+ * captured text is read). `[spawn failed] <message>` is not among them on purpose: with no output at all,
75810
+ * it IS the reason the run failed. */
75811
+ const SPAWN_DIAGNOSTIC = /^\[(?:killed, but its output never closed|the command exited, but its output never closed)/;
75812
+ // dpkg-query's own format syntax (a status column, a tab, the package name): not a template literal.
75813
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: ${...} is dpkg-query's format syntax
75814
+ const DPKG_SHOWFORMAT = "--showformat=${db:Status-Abbrev}\t${Package}\n";
75815
+ function notFoundError(name, found) {
75816
+ if (!found.found && found.reason === "shim") {
75817
+ return `${name} is a .cmd shim; install the native binary`;
75818
+ }
75819
+ return `${name} not found on PATH`;
75820
+ }
75821
+ /** The most `<exe> --version` output a version read collects (`RunOptions.maxOutputLength`). */
75822
+ const VERSION_MAX_OUTPUT = 65_536;
75823
+ const VERSION_DEADLINE_MS = VERSION_TIMEOUT_MS + 5_000;
75824
+ /** First non-empty line of `<exe> --version`, or `missing` when it cannot be run, exits non-zero, prints
75825
+ * nothing, or has not answered by `deadlineMs` (the read's own timeout kills the child, but its `close`
75826
+ * event waits for every holder of the pipes, which a child's own descendant could hold open). */
75827
+ async function readVersion(run, exe, env, deadlineMs) {
75828
+ let deadline;
75829
+ try {
75830
+ const result = await Promise.race([
75831
+ run(exe, ["--version"], {
75832
+ timeoutMs: VERSION_TIMEOUT_MS,
75833
+ env,
75834
+ maxOutputLength: VERSION_MAX_OUTPUT,
75835
+ }),
75836
+ new Promise((resolve) => {
75837
+ deadline = setTimeout(() => resolve(null), deadlineMs);
75838
+ }),
75839
+ ]);
75840
+ if (result === null || result.timedOut || result.code !== 0)
75841
+ return "missing";
75842
+ const first = result.stdout
75843
+ .split(/\r?\n/)
75844
+ .map((line) => line.trim())
75845
+ .find((line) => line !== "");
75846
+ return first === undefined ? "missing" : printable(first, 200);
75847
+ }
75848
+ catch {
75849
+ return "missing";
75850
+ }
75851
+ finally {
75852
+ if (deadline !== undefined)
75853
+ clearTimeout(deadline);
75854
+ }
75855
+ }
75856
+ function registerToolchainUpgradeVerb(registry, deps) {
75857
+ const spawnJob$1 = deps.spawnJob ?? spawnJob;
75858
+ const versionDeadlineMs = deps.versionDeadlineMs ?? VERSION_DEADLINE_MS;
75859
+ /** A log line is a record, never a dependency: a logger that throws (a full disk) must not fail a
75860
+ * request that has already created a job, nor reject the unawaited `finalize`. */
75861
+ const logEvent = (message) => {
75862
+ try {
75863
+ deps.logEvent(message);
75864
+ }
75865
+ catch {
75866
+ // nothing else to do with it
75867
+ }
75868
+ };
75869
+ const outcomes = new Map();
75870
+ // Upgrades between the cap check and `jobs.start`: the awaits in between (PATH refresh, version read)
75871
+ // would otherwise let two concurrent requests both see "none running".
75872
+ let starting = 0;
75873
+ function remember(jobId, outcome) {
75874
+ outcomes.set(jobId, outcome);
75875
+ if (outcomes.size > MAX_REMEMBERED_OUTCOMES) {
75876
+ const oldest = outcomes.keys().next().value;
75877
+ if (oldest !== undefined)
75878
+ outcomes.delete(oldest);
75879
+ }
75880
+ }
75881
+ // Gate 2: the machine owner's opt-in. A middleware, so it runs right after the grant guard and before
75882
+ // anything reads (or even size-limits) the body.
75883
+ const optInGate = async (c, next) => {
75884
+ if (!deps.enabled()) {
75885
+ return c.json({ ok: false, error: NOT_OPTED_IN_ERROR, reason: "not-opted-in" }, 403);
75886
+ }
75887
+ await next();
75888
+ };
75889
+ registry.register({
75890
+ capability: UPGRADE_CAPABILITY,
75891
+ method: "POST",
75892
+ path: "/toolchain/upgrade",
75893
+ middleware: [
75894
+ optInGate,
75895
+ bodyLimit({
75896
+ maxSize: MAX_REQUEST_BODY_BYTES,
75897
+ onError: (c) => c.json({ ok: false, error: "request body too large" }, 413),
75898
+ }),
75899
+ ],
75900
+ handler: async (c) => {
75901
+ let body;
75902
+ try {
75903
+ body = await c.req.json();
75904
+ }
75905
+ catch {
75906
+ return c.json({ ok: false, error: "invalid JSON body" }, 400);
75907
+ }
75908
+ const validated = validateUpgradeRequest(body, deps.platform);
75909
+ if (!validated.ok)
75910
+ return c.json({ ok: false, error: validated.error }, 400);
75911
+ const request = validated.value;
75912
+ if (deps.jobs.runningCount(KIND) + starting >= MAX_CONCURRENT_UPGRADES) {
75913
+ return c.json({ ok: false, error: "an upgrade is already running on this machine" }, 429);
75914
+ }
75915
+ starting++;
75916
+ try {
75917
+ const env = { PATH: await deps.searchPath() };
75918
+ // The program that performs the upgrade: winget, sudo (for apt-get) or the native claude.
75919
+ const executableName = request.manager === "winget" ? "winget" : request.manager === "apt" ? "sudo" : "claude";
75920
+ const executable = deps.which(executableName, env);
75921
+ if (!executable.found) {
75922
+ return c.json({ ok: false, error: notFoundError(executableName, executable) }, 502);
75923
+ }
75924
+ // The tool whose version is read before and after.
75925
+ const target = deps.which(request.cmd, env);
75926
+ if (!target.found) {
75927
+ return c.json({ ok: false, error: notFoundError(request.cmd, target) }, 502);
75928
+ }
75929
+ const before = await readVersion(deps.run, target.path, env, versionDeadlineMs);
75930
+ // apt-get falls back to treating a package argument that matches nothing as a regular expression
75931
+ // (apt-get(8): "a POSIX regular expression ... applied to all package names"), which with
75932
+ // --only-upgrade would upgrade every installed package it matches. So the name must be an
75933
+ // installed package, exactly: dpkg-query takes the name literally (the grammar has no wildcard).
75934
+ // Asked LAST, straight before the spawn: the version read above can take up to 25 s, and a package
75935
+ // purged in that window would otherwise reach apt-get as the pattern it falls back to.
75936
+ if (request.manager === "apt") {
75937
+ const dpkgQuery = deps.which("dpkg-query", env);
75938
+ if (!dpkgQuery.found) {
75939
+ return c.json({ ok: false, error: notFoundError("dpkg-query", dpkgQuery) }, 502);
75940
+ }
75941
+ const query = await deps.run(dpkgQuery.path, ["--show", DPKG_SHOWFORMAT, "--", request.packageId], { timeoutMs: VERSION_TIMEOUT_MS, env, maxOutputLength: VERSION_MAX_OUTPUT });
75942
+ const installed = query.code === 0 &&
75943
+ query.stdout.split("\n").some((line) => {
75944
+ const [status, name] = line.replace(/\r$/, "").split("\t");
75945
+ // dpkg's status column is desired-state, current-state, error flag: "ii " is installed, "hi "
75946
+ // is installed and held. The current state ("i") is what makes it installed.
75947
+ return status?.[1] === "i" && name === request.packageId;
75948
+ });
75949
+ if (!installed) {
75950
+ return c.json({ ok: false, error: `packageId: ${request.packageId} is not an installed package` }, 400);
75951
+ }
75952
+ }
75953
+ const argv = commandFor(request.manager, request.packageId, executable.path);
75954
+ const handle = deps.jobs.start({
75955
+ kind: KIND,
75956
+ label: `${request.tool} via ${request.manager} ${request.packageId}`,
75957
+ repo: request.manager,
75958
+ permissionMode: "",
75959
+ });
75960
+ // A line at the START as well as the end: a run the agent does not survive (a restart, a crash)
75961
+ // otherwise leaves no record of what it was upgrading.
75962
+ logEvent(`toolchain upgrade started: ${JSON.stringify(request.tool)} via ${request.manager} ${request.packageId} (version of ${request.cmd})`);
75963
+ // What the child printed, for the outcome: the last lines only, and never the job's own header.
75964
+ const captured = [];
75965
+ let sawHeader = false;
75966
+ let limit;
75967
+ const finalize = async (exitCode) => {
75968
+ let outcome;
75969
+ try {
75970
+ const afterEnv = { PATH: await deps.searchPath() };
75971
+ const afterTarget = deps.which(request.cmd, afterEnv);
75972
+ const after = afterTarget.found
75973
+ ? await readVersion(deps.run, afterTarget.path, afterEnv, versionDeadlineMs)
75974
+ : "missing";
75975
+ const verdict = classifyOutcome({
75976
+ manager: request.manager,
75977
+ packageId: request.packageId,
75978
+ exitCode,
75979
+ output: captured.join("\n"),
75980
+ before,
75981
+ after,
75982
+ limit,
75983
+ username: deps.username(),
75984
+ });
75985
+ outcome = { ...verdict, before, after };
75986
+ }
75987
+ catch (err) {
75988
+ const message = printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS);
75989
+ outcome = { status: "failed", reason: message, before, after: "missing" };
75990
+ }
75991
+ const ok = outcome.status === "upgraded" || outcome.status === "already-current";
75992
+ try {
75993
+ // The outcome and the audit line first, then the job-log trailer: a job log that fails to take
75994
+ // a line (the only one of these that can throw) must not cost the console its outcome or the
75995
+ // operator the audit line.
75996
+ remember(handle.id, outcome);
75997
+ // `cmd` is named because the verb does not tie it to the package id: the versions are of `cmd`.
75998
+ logEvent(`toolchain upgrade: ${JSON.stringify(request.tool)} via ${request.manager} ${request.packageId}: ${before} -> ${outcome.after} (${outcome.status}, exit ${exitCode ?? "none"}, version of ${request.cmd})`);
75999
+ handle.log(`after: ${outcome.after}`);
76000
+ handle.log(`result: ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ""}`);
76001
+ }
76002
+ catch {
76003
+ // A logging failure must neither stop the job finishing nor become an unhandled rejection
76004
+ // (`finalize` runs unawaited, and an unhandled rejection ends the agent process).
76005
+ }
76006
+ finally {
76007
+ // Always: a job that never finishes would hold the one upgrade slot for good.
76008
+ handle.finish(ok ? "done" : "failed", exitCode);
76009
+ }
76010
+ };
76011
+ // The child's output flows to the job; `finish` is held back until the outcome (and its trailer
76012
+ // lines) is known, then called once.
76013
+ const spawnHandle = {
76014
+ log: (line) => {
76015
+ handle.log(line);
76016
+ if (!sawHeader) {
76017
+ sawHeader = true;
76018
+ return;
76019
+ }
76020
+ // spawnJob's own diagnostics are not the command's words: the reason a failed run reports is
76021
+ // the last thing the COMMAND said, which a forced finish's note must not replace.
76022
+ if (SPAWN_DIAGNOSTIC.test(line))
76023
+ return;
76024
+ captured.push(line);
76025
+ if (captured.length > 400)
76026
+ captured.shift();
76027
+ },
76028
+ finish: (_status, exitCode) => {
76029
+ void finalize(exitCode);
76030
+ },
76031
+ };
76032
+ try {
76033
+ spawnJob$1(argv, {
76034
+ cwd: deps.cwd(),
76035
+ env: {
76036
+ NO_COLOR: "1",
76037
+ ...(request.manager === "apt"
76038
+ ? { DEBIAN_FRONTEND: "noninteractive", LC_ALL: "C" }
76039
+ : {}),
76040
+ },
76041
+ header: `$ ${argv.join(" ")}`,
76042
+ timeoutMs: UPGRADE_TIMEOUT_MS,
76043
+ maxOutputChars: UPGRADE_MAX_OUTPUT_CHARS,
76044
+ exitSettleMs: EXIT_SETTLE_MS,
76045
+ onLimit: (which) => {
76046
+ limit = which;
76047
+ },
76048
+ }, spawnHandle);
76049
+ }
76050
+ catch (err) {
76051
+ // A launch that throws before any child exists must not leave a job "running" for good.
76052
+ handle.log(`[spawn failed] ${printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS)}`);
76053
+ handle.finish("failed", null);
76054
+ throw err;
76055
+ }
76056
+ // spawnJob logs the header synchronously; the child's own output arrives later.
76057
+ handle.log(`before: ${before}`);
76058
+ return c.json({ ok: true, jobId: handle.id }, 202);
76059
+ }
76060
+ catch (err) {
76061
+ logEvent(`toolchain upgrade: request failed: ${printable(err instanceof Error ? err.message : String(err), MAX_REASON_CHARS)}`);
76062
+ return c.json({ ok: false, error: "the upgrade could not be started" }, 500);
76063
+ }
76064
+ finally {
76065
+ starting--;
76066
+ }
76067
+ },
76068
+ });
76069
+ registry.register({
76070
+ capability: UPGRADE_CAPABILITY,
76071
+ method: "GET",
76072
+ path: "/toolchain/upgrade/:jobId",
76073
+ handler: async (c) => {
76074
+ const jobId = c.req.param("jobId") ?? "";
76075
+ const entry = deps.jobs.get(jobId);
76076
+ // Another kind's job is "no such job" here, exactly like an unknown id.
76077
+ if (!entry || entry.view.kind !== KIND)
76078
+ return c.json({ ok: false, error: "no such job" }, 404);
76079
+ const outcome = outcomes.get(jobId);
76080
+ return c.json({ ok: true, job: entry.view, ...(outcome !== undefined ? { outcome } : {}) });
76081
+ },
76082
+ });
76083
+ registry.register({
76084
+ capability: UPGRADE_CAPABILITY,
76085
+ method: "GET",
76086
+ path: "/toolchain/upgrade/:jobId/events",
76087
+ handler: async (c) => jobEventsResponse(deps.jobs, c.req.param("jobId") ?? "", c, KIND),
76088
+ });
76089
+ return { pendingStarts: () => starting };
76090
+ }
76091
+
76092
+ var toolchainUpgrade = /*#__PURE__*/Object.freeze({
76093
+ __proto__: null,
76094
+ MAX_REQUEST_BODY_BYTES: MAX_REQUEST_BODY_BYTES,
76095
+ NOT_OPTED_IN_ERROR: NOT_OPTED_IN_ERROR,
76096
+ UPGRADE_CAPABILITY: UPGRADE_CAPABILITY,
76097
+ UPGRADE_MAX_OUTPUT_CHARS: UPGRADE_MAX_OUTPUT_CHARS,
76098
+ UPGRADE_TIMEOUT_MS: UPGRADE_TIMEOUT_MS,
76099
+ VERSION_READABLE_CMDS: VERSION_READABLE_CMDS,
76100
+ classifyOutcome: classifyOutcome,
76101
+ commandFor: commandFor,
76102
+ registerToolchainUpgradeVerb: registerToolchainUpgradeVerb,
76103
+ sudoersRule: sudoersRule,
76104
+ validateUpgradeRequest: validateUpgradeRequest
76105
+ });
76106
+
75259
76107
  /**
75260
76108
  * #659 PR 2: where an update comes from and what is allowed. The agent installs only an exact
75261
76109
  * published version of `@rackbops/ac-agent` from the public npm registry -- never a URL, a tarball
@@ -75688,4 +76536,4 @@ var updater = /*#__PURE__*/Object.freeze({
75688
76536
  selfUpdateSupported: selfUpdateSupported
75689
76537
  });
75690
76538
 
75691
- export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists };
76539
+ export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists, runningJobCount, toolchainUpgradeDeps };