@catalyst-cloud/cli 0.16.33 → 0.16.34

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.
@@ -0,0 +1,222 @@
1
+ // codex-env.ts — `catalyst codex-env`: the OTEL_RESOURCE_ATTRIBUTES a Codex launch on this machine
2
+ // carries, so every Codex token row names the coding-account slot it ran as and the machine it ran on
3
+ // (CTC-6192 phase 2, CTC-6883). The slot comes from the local Codex login's `tokens.account_id`,
4
+ // hashed and matched against the `loginSha256` the workspace's coding accounts carry (CTC-6848). The
5
+ // account id, raw or hashed, never reaches the printed value: only the slot name does.
6
+ // ⛔ `--print` and `--apply` never wait on the network. They answer from the cached map, and a
7
+ // missing or stale map starts a detached `--refresh` that is never awaited.
8
+ import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { hostname as osHostname } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { flagBool, flagString } from "./args.js";
14
+ import { cliPath, codexAccountsCachePathFor, loadConfig, requireConfig } from "./config.js";
15
+ import { detachSelf } from "./detach.js";
16
+ import { CliError, UsageError } from "./errors.js";
17
+ import { needsNewerCloud } from "./execution.js";
18
+ import { apiClient } from "./transport.js";
19
+ /** How long a fetched account map answers before a launch starts a background refresh. */
20
+ export const CODEX_ACCOUNTS_TTL_SECONDS = 6 * 60 * 60;
21
+ /** The runner's wire key (CTC-2786): never a bare `account`, which the collector reads as a workspace. */
22
+ export const ACCOUNT_SLOT_ATTRIBUTE = "catalyst.account_slot";
23
+ export const HOST_NAME_ATTRIBUTE = "host.name";
24
+ /** A Codex login no cached coding account matches. Never a guess, never the account id. */
25
+ export const UNREGISTERED_SLOT = "unregistered";
26
+ /** No readable Codex login: the runner's `none:<reason>` convention. */
27
+ export const NO_LOGIN_SLOT = "none:no-login";
28
+ const LOGIN_HASH = /^[0-9a-f]{64}$/;
29
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
30
+ /** A command that could not start (no `launchctl` on PATH) has no status; it fails with its spawn error. */
31
+ export const defaultRun = (cmd, args) => {
32
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
33
+ return { status: r.status ?? 1, stderr: r.error ? r.error.message : r.stderr };
34
+ };
35
+ export function defaultCodexHome(ctx) {
36
+ const set = ctx.env.CODEX_HOME?.trim();
37
+ return set ? set : join(ctx.home, ".codex");
38
+ }
39
+ /** The local Codex login's account id: `tokens.account_id`, and no other field. A token is never read
40
+ * out, logged or printed. A parse error's message can quote the file, so it is dropped unread. */
41
+ export function readCodexAccountId(codexHome) {
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ const id = object(object(parsed)?.tokens)?.account_id;
50
+ return typeof id === "string" && id.length > 0 ? id : null;
51
+ }
52
+ /** The same hash the cloud stores (lowercase hex SHA-256 of the UTF-8 account id). */
53
+ export function loginSha256(accountId) {
54
+ return createHash("sha256").update(accountId, "utf8").digest("hex");
55
+ }
56
+ export function resolveSlot(accountId, cache) {
57
+ if (accountId === null)
58
+ return NO_LOGIN_SLOT;
59
+ const hash = loginSha256(accountId);
60
+ return cache?.accounts.find((a) => a.loginSha256 === hash)?.accountSlot ?? UNREGISTERED_SLOT;
61
+ }
62
+ /** The runner's value rule (catalyst-cloud apps/runner/src/executors/harness-otel-env.ts:82-96): a
63
+ * blank value is the literal `unknown`, and `,` and `=` are structure, so each becomes `_`. */
64
+ export function attrValue(value) {
65
+ const trimmed = value.trim();
66
+ return (trimmed.length > 0 ? trimmed : "unknown").replace(/[,=]/g, "_");
67
+ }
68
+ /** `os.hostname()` up to its first `.`, as `hostname -s` prints it. */
69
+ export function shortHostName(name) {
70
+ return name.split(".")[0];
71
+ }
72
+ /** Every pair already in `existing` is kept, in order and verbatim, except an earlier copy of a key
73
+ * set here and any `account` key (CTC-2786), which this never passes on. The slot and the host name
74
+ * are appended once each. */
75
+ export function mergeResourceAttributes(existing, set) {
76
+ const ours = [
77
+ [ACCOUNT_SLOT_ATTRIBUTE, attrValue(set.slot)],
78
+ [HOST_NAME_ATTRIBUTE, attrValue(set.hostName)],
79
+ ];
80
+ const dropped = new Set([...ours.map(([key]) => key), "account"]);
81
+ const kept = (existing ?? "").split(",").filter((pair) => {
82
+ if (pair.trim() === "")
83
+ return false;
84
+ const eq = pair.indexOf("=");
85
+ return !dropped.has((eq === -1 ? pair : pair.slice(0, eq)).trim());
86
+ });
87
+ return [...kept, ...ours.map(([key, value]) => `${key}=${value}`)].join(",");
88
+ }
89
+ /** Only the slot and its login hash survive into the cache; every other account field is left out. */
90
+ function codexAccounts(rows) {
91
+ return rows.flatMap((row) => {
92
+ const a = object(row);
93
+ return a && typeof a.accountSlot === "string" && typeof a.loginSha256 === "string" && LOGIN_HASH.test(a.loginSha256)
94
+ ? [{ accountSlot: a.accountSlot, loginSha256: a.loginSha256 }]
95
+ : [];
96
+ });
97
+ }
98
+ export function readCodexAccountsCache(home) {
99
+ try {
100
+ const parsed = object(JSON.parse(readFileSync(codexAccountsCachePathFor(home), "utf8")));
101
+ if (!parsed || typeof parsed.fetchedAt !== "string" || typeof parsed.workspace !== "string" || !Array.isArray(parsed.accounts))
102
+ return null;
103
+ return { fetchedAt: parsed.fetchedAt, workspace: parsed.workspace, accounts: codexAccounts(parsed.accounts) };
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function writeCodexAccountsCache(home, cache) {
110
+ const path = codexAccountsCachePathFor(home);
111
+ try {
112
+ mkdirSync(dirname(path), { recursive: true });
113
+ const tmp = `${path}.tmp`;
114
+ writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
115
+ renameSync(tmp, path);
116
+ }
117
+ catch (err) {
118
+ throw new CliError(`could not write ${path}: ${err instanceof Error ? err.message : String(err)}`, "codex-env-cache-write");
119
+ }
120
+ return path;
121
+ }
122
+ /** The workspace this machine is logged in to, or null. A broken config is the login verb's to report. */
123
+ function configuredWorkspace(ctx) {
124
+ try {
125
+ return loadConfig(ctx.home)?.account ?? null;
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ function cacheState(ctx, cache) {
132
+ if (cache === null)
133
+ return "missing";
134
+ const fetchedMs = Date.parse(cache.fetchedAt);
135
+ if (!Number.isFinite(fetchedMs))
136
+ return "stale";
137
+ const ageSeconds = Math.max(0, Math.floor((ctx.now().getTime() - fetchedMs) / 1000));
138
+ return ageSeconds < CODEX_ACCOUNTS_TTL_SECONDS ? "fresh" : "stale";
139
+ }
140
+ /** Spawned detached and let go: the launch never waits for it, and a failed spawn is not a failed launch. */
141
+ function startRefresh(ctx, deps) {
142
+ try {
143
+ (deps.detach ?? detachSelf)(deps.argv ?? [cliPath(), "codex-env", "--refresh"], ctx.env);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ /** The value for one Codex home, from the cache only. Logged out, there is no map and no refresh. */
151
+ export function codexLaunchEnv(ctx, codexHome, deps = {}) {
152
+ const workspace = configuredWorkspace(ctx);
153
+ const cached = workspace === null ? null : readCodexAccountsCache(ctx.home);
154
+ const cache = cached !== null && cached.workspace === workspace ? cached : null;
155
+ const state = cacheState(ctx, cache);
156
+ const refreshStarted = state !== "fresh" && workspace !== null && startRefresh(ctx, deps);
157
+ const slot = resolveSlot(readCodexAccountId(codexHome), cache);
158
+ const hostName = shortHostName((deps.hostname ?? osHostname)());
159
+ return {
160
+ value: mergeResourceAttributes(ctx.env.OTEL_RESOURCE_ATTRIBUTES, { slot, hostName }),
161
+ slot: attrValue(slot),
162
+ hostName: attrValue(hostName),
163
+ cache: state,
164
+ refreshStarted,
165
+ };
166
+ }
167
+ async function refreshCodexAccounts(args, ctx) {
168
+ const cfg = requireConfig(ctx);
169
+ const res = await apiClient(cfg, ctx).getJson("/api/v1/coding-accounts", {
170
+ accept: [404],
171
+ });
172
+ if (res.status === 404)
173
+ throw needsNewerCloud("codex-env --refresh", cfg);
174
+ const rows = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
175
+ const cache = { fetchedAt: ctx.now().toISOString(), workspace: cfg.account, accounts: codexAccounts(rows) };
176
+ const path = writeCodexAccountsCache(ctx.home, cache);
177
+ const n = cache.accounts.length;
178
+ if (args.json)
179
+ ctx.stdout(JSON.stringify({ path, fetchedAt: cache.fetchedAt, accounts: n }));
180
+ else
181
+ ctx.stdout(`Cached ${n} Codex coding account${n === 1 ? "" : "s"} for this workspace in ${path}. Launches read it for ${CODEX_ACCOUNTS_TTL_SECONDS / 3600} hours, then refresh it in the background.`);
182
+ return 0;
183
+ }
184
+ export async function cmdCodexEnv(args, ctx, deps = {}) {
185
+ const modes = ["print", "apply", "refresh"].filter((m) => flagBool(args, m));
186
+ if (modes.length !== 1)
187
+ throw new UsageError("codex-env needs exactly one of --print, --apply or --refresh");
188
+ const mode = modes[0];
189
+ const codexHome = flagString(args, "codex-home");
190
+ if (mode === "refresh") {
191
+ if (codexHome !== undefined)
192
+ throw new UsageError("--codex-home goes with --print; --refresh reads only the workspace");
193
+ return await refreshCodexAccounts(args, ctx);
194
+ }
195
+ if (mode === "apply") {
196
+ if (codexHome !== undefined)
197
+ throw new UsageError("--apply sets the environment for apps started from the Dock, which use the default Codex home; --codex-home goes with --print");
198
+ const platform = deps.platform ?? process.platform;
199
+ if (platform !== "darwin")
200
+ throw new CliError(`codex-env --apply sets the macOS login environment with launchctl, and this machine runs ${platform}. Export the output of catalyst codex-env --print instead.`, "codex-env-unsupported-platform");
201
+ }
202
+ const env = codexLaunchEnv(ctx, codexHome ?? defaultCodexHome(ctx), deps);
203
+ if (mode === "apply") {
204
+ const r = (deps.run ?? defaultRun)("launchctl", ["setenv", "OTEL_RESOURCE_ATTRIBUTES", env.value]);
205
+ if (r.status !== 0)
206
+ throw new CliError(`launchctl setenv failed (exit ${r.status}): ${r.stderr.trim()}`, "codex-env-launchctl");
207
+ }
208
+ if (args.json)
209
+ ctx.stdout(JSON.stringify({
210
+ OTEL_RESOURCE_ATTRIBUTES: env.value,
211
+ slot: env.slot,
212
+ hostName: env.hostName,
213
+ cache: env.cache,
214
+ refreshStarted: env.refreshStarted,
215
+ applied: mode === "apply",
216
+ }));
217
+ else if (mode === "print")
218
+ ctx.stdout(env.value);
219
+ else
220
+ ctx.stdout(`Apps started from now on carry OTEL_RESOURCE_ATTRIBUTES=${env.value}`);
221
+ return 0;
222
+ }
package/dist/config.js CHANGED
@@ -46,6 +46,10 @@ export function watchCursorPathFor(home) {
46
46
  export function discoveryCachePathFor(home) {
47
47
  return join(configDirFor(home), "auth-discovery.json");
48
48
  }
49
+ /** CTC-6883 — the coding-account slots and Codex login hashes `codex-env` answers from (src/codex-env.ts). */
50
+ export function codexAccountsCachePathFor(home) {
51
+ return join(configDirFor(home), "codex-accounts.json");
52
+ }
49
53
  export function defaultReplicaDbFor(home) {
50
54
  return join(configDirFor(home), "replica.db");
51
55
  }
@@ -19,11 +19,12 @@ function schedulerEnv() {
19
19
  ? { ...process.env, XDG_RUNTIME_DIR: runtime }
20
20
  : process.env;
21
21
  }
22
- const defaultRun = (cmd, args) => {
22
+ /** Shared with the Codex attribution agent (codex-attribution.ts), which runs launchctl the same way. */
23
+ export const defaultRun = (cmd, args) => {
23
24
  const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 15_000, env: schedulerEnv() });
24
25
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
25
26
  };
26
- const xml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
27
+ export const xml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
27
28
  /** systemd's own quoting for one ExecStart/Environment word. */
28
29
  const unitWord = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
29
30
  export function launchAgentPath(home) {
@@ -99,7 +100,8 @@ function ensureLingering(run, uid) {
99
100
  const linger = lingering(run, uid);
100
101
  return { linger, turnedOn: linger };
101
102
  }
102
- function writeFileAtomic(path, text) {
103
+ /** Shared with the Codex attribution agent's plist (codex-attribution.ts). */
104
+ export function writeFileAtomic(path, text) {
103
105
  mkdirSync(dirname(path), { recursive: true });
104
106
  const temp = `${path}.${process.pid}.tmp`;
105
107
  writeFileSync(temp, text, { mode: 0o644 });
@@ -1,7 +1,9 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { readManifest } from "./config.js";
3
4
  import { CliError } from "./errors.js";
4
5
  import { onboardStateRoot } from "./onboard.js";
6
+ import { darwinThoughtsInstallRefusal } from "./onboard-runner-custody.js";
5
7
  import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages, readEnvFile, runnerComposeArgs, spawnDocker, } from "./onboard-runner.js";
6
8
  // `catalyst runner update`: move this machine's self-hosted runner to the release Catalyst approved
7
9
  // (CTC-4646). The cloud names one approved runner, supervisor and watchdog by immutable digest with
@@ -225,12 +227,29 @@ async function running(engine, dir, images, signal) {
225
227
  return false;
226
228
  return engine.supervisorReadySinceStart(dir, signal);
227
229
  }
230
+ const REPORT_DETAIL = /[^a-z0-9_:,.-]/g;
231
+ /** The previous run's outcome as the release read reports it (CTC-6924), or undefined before the
232
+ * first run. Only the fixed outcome vocabulary leaves the machine: anything outside the cloud's
233
+ * detail characters becomes `_`, so an image reference cannot make the cloud drop the report. */
234
+ export function updateReport(state, cliVersion) {
235
+ if (state.last === null)
236
+ return undefined;
237
+ return JSON.stringify({
238
+ v: 1,
239
+ outcome: state.last.outcome,
240
+ detail: state.last.detail.toLowerCase().replace(REPORT_DETAIL, "_").slice(0, 160),
241
+ applied: state.generation,
242
+ offered: state.last.generation,
243
+ atMs: state.last.atMs,
244
+ cli: /^\d{1,4}\.\d{1,4}\.\d{1,6}$/.test(cliVersion) ? cliVersion : null,
245
+ });
246
+ }
228
247
  async function update(engine, options, now) {
229
248
  const { dir, signal } = options;
230
249
  const envPath = join(dir, ".env");
231
250
  const state = readReleaseState(dir);
232
251
  const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
233
- const read = await engine.desiredRelease(dir, signal);
252
+ const read = await engine.desiredRelease(dir, signal, options.check ? undefined : updateReport(state, readManifest().version));
234
253
  const decision = decideRelease(state.generation, read);
235
254
  if (decision.action === "retain")
236
255
  return decision.reason === "not_newer"
@@ -302,13 +321,22 @@ async function update(engine, options, now) {
302
321
  // Mac private thoughts storage records the supervisor image its helper runs. Every path that does
303
322
  // not end on the new release attests it back onto the images the machine keeps.
304
323
  const custodyInstalled = persistedCustody(dir, env) === "installed";
305
- const restoreCustody = async (images) => !custodyInstalled || images === null || (await engine.custody(dir, images, signal)) === env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY;
324
+ // Null when storage holds the installed authority on these images, else why not. Re-attestation
325
+ // keeps the installed authority; a different one would need setup, not an update.
326
+ const attestCustody = async (images) => {
327
+ const attested = await engine.custody(dir, images, signal);
328
+ if ("refused" in attested)
329
+ return attested.refused;
330
+ return attested.authority === env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY ? null : "authority_changed";
331
+ };
332
+ const restoreCustody = async (images) => !custodyInstalled || images === null ? null : attestCustody(images);
306
333
  if (custodyInstalled) {
307
- const authority = await engine.custody(dir, target, signal);
308
- // Re-attestation keeps the installed authority. A different one would need setup, not an update.
309
- if (authority === null || authority !== env.CATALYST_THOUGHTS_CUSTODY_AUTHORITY) {
310
- const restored = await restoreCustody(rollbackImages ?? current);
311
- return done(restored ? "retained" : "failed", restored ? "custody_reattest_failed" : "custody_restore_failed", { ...tagged, exitCode: restored ? 0 : 1 });
334
+ const refused = await attestCustody(target);
335
+ if (refused !== null) {
336
+ const restoreRefused = await restoreCustody(rollbackImages ?? current);
337
+ return restoreRefused === null
338
+ ? done("retained", `custody_reattest_failed:${refused}`, tagged)
339
+ : done("failed", `custody_restore_failed:${restoreRefused}`, { ...tagged, exitCode: 1 });
312
340
  }
313
341
  }
314
342
  // The boundary between phases: stop the supervisor so it admits nothing, then look again. A phase
@@ -344,9 +372,9 @@ async function update(engine, options, now) {
344
372
  if (rollbackText === null || rollbackImages === null)
345
373
  return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1 });
346
374
  writePrivateAtomic(envPath, rollbackText);
347
- const custodyBack = await restoreCustody(rollbackImages);
375
+ const custodyRefused = await restoreCustody(rollbackImages);
348
376
  const restored = (await engine.composeUp(dir, signal)) && (await waitReady(engine, dir, rollbackImages, options, now));
349
- return done(restored && custodyBack ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyBack ? "new_supervisor_not_ready" : "custody_restore_failed", { ...tagged, exitCode: 1 });
377
+ return done(restored && custodyRefused === null ? "rolled_back" : "failed", !restored ? "rollback_not_ready" : custodyRefused === null ? "new_supervisor_not_ready" : `custody_restore_failed:${custodyRefused}`, { ...tagged, exitCode: 1 });
350
378
  }
351
379
  async function waitReady(engine, dir, images, options, now) {
352
380
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -419,10 +447,12 @@ export const DESIRED_RELEASE_SCRIPT = `(async () => {
419
447
  if (!state || state.version !== 1 || typeof state.secret !== "string" || !e ||
420
448
  typeof e.tenant !== "string" || typeof e.hostId !== "string" || !base) return out({ kind: "no_credential" });
421
449
  const tenant = Buffer.from(e.tenant, "utf8").toString("base64url");
450
+ const report = (process.env.CATALYST_UPDATE_REPORT || "").length <= 512 ? process.env.CATALYST_UPDATE_REPORT : "";
422
451
  try {
423
452
  const response = await fetch(new URL(${JSON.stringify(DESIRED_RELEASE_PATH)}, base).href, {
424
453
  redirect: "error", signal: AbortSignal.timeout(15000),
425
- headers: { authorization: "Bearer ctc_host_" + tenant + "." + e.hostId + "." + state.secret, accept: "application/json" },
454
+ headers: { authorization: "Bearer ctc_host_" + tenant + "." + e.hostId + "." + state.secret, accept: "application/json",
455
+ ...(report ? { "x-catalyst-update-report": report } : {}) },
426
456
  });
427
457
  let body = null;
428
458
  try { body = await response.json(); } catch {}
@@ -447,11 +477,12 @@ export function dockerRunnerUpdateEngine(deps = {}) {
447
477
  }
448
478
  };
449
479
  return {
450
- async desiredRelease(dir, signal) {
480
+ async desiredRelease(dir, signal, report) {
451
481
  const compose = runnerComposeArgs(dir);
452
482
  if (!compose)
453
483
  return { kind: "no_credential" };
454
- const read = await docker([...compose, "run", "--rm", "-T", "--no-deps", "--entrypoint", "bun", "supervisor", "-e", DESIRED_RELEASE_SCRIPT], { cwd: dir, signal, timeoutMs: 120_000 });
484
+ const reportEnv = report ? ["-e", `CATALYST_UPDATE_REPORT=${report}`] : [];
485
+ const read = await docker([...compose, "run", "--rm", "-T", "--no-deps", ...reportEnv, "--entrypoint", "bun", "supervisor", "-e", DESIRED_RELEASE_SCRIPT], { cwd: dir, signal, timeoutMs: 120_000 });
455
486
  if (read.code !== 0)
456
487
  return { kind: "no_credential" };
457
488
  try {
@@ -484,13 +515,14 @@ export function dockerRunnerUpdateEngine(deps = {}) {
484
515
  },
485
516
  async custody(dir, images, signal) {
486
517
  if (!setup.nativeThoughtsCustody)
487
- return null;
518
+ return { refused: "capability_missing" };
488
519
  try {
489
520
  const result = await setup.nativeThoughtsCustody({ dir, supervisorImage: images.supervisor, runnerImage: images.runner }, signal);
490
- return result.envAuthority;
521
+ return { authority: result.envAuthority };
491
522
  }
492
- catch {
493
- return null;
523
+ catch (error) {
524
+ // Only the installer's fixed reasons are recorded; other exception text can carry credentials.
525
+ return { refused: darwinThoughtsInstallRefusal(error) ?? "unclassified" };
494
526
  }
495
527
  },
496
528
  async services(dir, signal) {
@@ -2,14 +2,15 @@
2
2
  // component is installed (with the version or image it runs), absent, or unknown with a reason;
3
3
  // unknown is never reported as current. Reading it starts no process, contacts no service, and a
4
4
  // machine with no runner installation never touches Docker.
5
- import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { exactVersion } from "./approved-release.js";
8
8
  import { loadConfig, packageRoot, readManifest, updatePackageName } from "./config.js";
9
9
  import { parseEnvText } from "./onboard-runner.js";
10
10
  import { replicaStatus, writerIsRunning } from "./replica.js";
11
11
  import { readReleaseState, RELEASE_COMPONENTS, RELEASE_ENV_KEYS, releaseReference, } from "./runner-update.js";
12
- import { installedBundleVersion, resolveSkillsDir, skillsSourceDir } from "./skills.js";
12
+ import { resolveSkillsDir } from "./skills.js";
13
+ import { inspectSkillInstallation } from "./update-skill-state.js";
13
14
  import { cloudPluginInventory } from "./update-plugin.js";
14
15
  export function imageInventory(dir) {
15
16
  const envPath = join(dir, ".env");
@@ -25,36 +26,20 @@ export function imageInventory(dir) {
25
26
  const references = Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, releaseReference(c, env[RELEASE_ENV_KEYS[c]])]));
26
27
  return { installed: true, dir, references, generation: readReleaseState(dir).generation };
27
28
  }
28
- function bundledSkillNames() {
29
- try {
30
- return readdirSync(skillsSourceDir(), { withFileTypes: true })
31
- .filter((e) => e.isDirectory() && existsSync(join(skillsSourceDir(), e.name, "SKILL.md")))
32
- .map((e) => e.name);
33
- }
34
- catch {
35
- return [];
36
- }
29
+ function packInventory(ctx, cfg, pack) {
30
+ const dir = resolveSkillsDir({}, ctx, cfg);
31
+ const evidence = inspectSkillInstallation(ctx.home, ctx.env, pack, dir);
32
+ if (!evidence.present)
33
+ return { state: "not_installed", dir, evidence };
34
+ if (evidence.version !== null && evidence.candidate)
35
+ return { state: "installed", version: evidence.version, dir, evidence };
36
+ return { state: "unknown", reason: evidence.reason, dir, evidence };
37
37
  }
38
- /** The Cloud pack as installed in the agent skills directory, read from each skill's provenance line. */
39
38
  export function cloudPackInventory(ctx, cfg) {
40
- const dir = resolveSkillsDir({}, ctx, cfg);
41
- const found = installedBundleVersion(dir, bundledSkillNames());
42
- if (found.version !== null) {
43
- const version = exactVersion(found.version);
44
- return version ? { state: "installed", version, dir } : { state: "unknown", reason: "version_unreadable", dir };
45
- }
46
- if (found.unstamped.length > 0)
47
- return { state: "unknown", reason: "version_unstamped", dir };
48
- return { state: "not_installed", dir };
39
+ return packInventory(ctx, cfg, "cloud");
49
40
  }
50
- /** The Dev pack ships from its own repository with no provenance stamp this CLI reads, so its
51
- * presence is known (its `research-codebase` skill, as the onboarding guide checks) but its version
52
- * is not. Unknown is never reported as current. */
53
41
  export function devPackInventory(ctx, cfg) {
54
- const dir = resolveSkillsDir({}, ctx, cfg);
55
- return existsSync(join(dir, "research-codebase", "SKILL.md"))
56
- ? { state: "unknown", reason: "version_unrecorded", dir }
57
- : { state: "not_installed", dir };
42
+ return packInventory(ctx, cfg, "dev");
58
43
  }
59
44
  export function replicaInventory(ctx, cfg) {
60
45
  if (!cfg)