@catalyst-cloud/cli 0.16.13 → 0.16.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.15
4
+
5
+ Command-line workflows can report stage progress with `catalyst stage start`, `renew`, `done`, and `fail`, and choose an eligible AI account and model for a local session with `catalyst seat checkout`. Retries reuse the same stage attempt. `catalyst write create --label` and `write label --add`/`--remove` now accept a label by name, and a refused write exits non-zero with a JSON error on stdout under `--json`.
6
+
7
+ ## 0.16.14
8
+
9
+ Setup uses the updated macOS folder checks and verifies the required release image before starting work. Re-run setup to update this computer.
10
+
3
11
  ## 0.16.13
4
12
 
5
13
  Setup on macOS now verifies private file access when Docker changes a folder’s displayed owner in either direction. Signed access verification remains required. Re-run setup to update this computer.
package/dist/args.js CHANGED
@@ -327,6 +327,13 @@ export const FLAG_TABLES = {
327
327
  },
328
328
  },
329
329
  accounts: {},
330
+ seat: {
331
+ size: { value: true, help: "model size: big or med" },
332
+ stage: { value: true, help: "sized relay stage; defaults from size" },
333
+ harness: { value: true, help: "comma-separated runnable harnesses" },
334
+ "session-id": { value: true, help: "stable id for this local session; generated when omitted" },
335
+ "key-env": { value: true, help: "read the bearer from this environment variable, never argv" },
336
+ },
330
337
  env: {
331
338
  root: {
332
339
  value: true,
@@ -483,6 +490,12 @@ export const FLAG_TABLES = {
483
490
  help: "with --class: at most this many tickets (the cloud caps it at 25)",
484
491
  },
485
492
  },
493
+ stage: {
494
+ artifact: { value: true, help: "done/fail: path to the phase artifact" },
495
+ evidence: { value: true, help: "done: JSON file with phase-specific receipt evidence" },
496
+ summary: { value: true, help: "done/fail: concise outcome summary" },
497
+ assertion: { value: true, help: "renew: work completed since the previous renewal" },
498
+ },
486
499
  };
487
500
  export const VERB_USAGE = {
488
501
  hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
@@ -507,6 +520,7 @@ export const VERB_USAGE = {
507
520
  ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
508
521
  ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
509
522
  accounts: "accounts [--json]",
523
+ seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
510
524
  mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
511
525
  team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
512
526
  env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write]> [--json] (THIS repository, offline — no login, no network)",
@@ -520,6 +534,7 @@ export const VERB_USAGE = {
520
534
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
521
535
  connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
522
536
  release: "release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] [--json] | release --class <failure-class> --team <K> --because <what changed> [--retry-unchanged] [--dry-run] [--limit N] [--json]",
537
+ stage: "stage start|renew|done|fail <ticket> <phase> [--artifact <path>] [--evidence <json>] [--summary <text>] [--assertion <work done>]",
523
538
  };
524
539
  export function parseArgs(argv) {
525
540
  const out = {
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cmdHosts } from "./hosts.js";
2
+ import { cmdSeat } from "./seat.js";
2
3
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
3
4
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
4
5
  import { createSetupRenderer, setupBanner, } from "./setup-render.js";
@@ -34,6 +35,7 @@ import { cmdWrite } from "./write.js";
34
35
  import { cmdAsk } from "./ask.js";
35
36
  import { cmdMcp } from "./mcp.js";
36
37
  import { cmdRelease } from "./release.js";
38
+ import { cmdStage } from "./stage.js";
37
39
  import { cmdEnvironment } from "./environment.js";
38
40
  import { cmdEnv } from "./env.js";
39
41
  import { cmdSecret } from "./secret.js";
@@ -80,6 +82,7 @@ export function usageText() {
80
82
  " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
81
83
  ` catalyst ${SETUP_USAGE}`,
82
84
  " catalyst status | notice | me | ready | accounts",
85
+ " catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
83
86
  " catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
84
87
  " catalyst mcp add|list|remove (vault references only)",
85
88
  " catalyst contract [--refresh] [--path <a.b.c>]",
@@ -99,6 +102,7 @@ export function usageText() {
99
102
  " catalyst team <list|check|map|adopt|migrate|checklist> ...",
100
103
  " catalyst capabilities [--json] (what this CLI can do, the role each verb needs, and whether this cloud serves it)",
101
104
  " catalyst project list [--json] (all workspace projects, including those with no stage mapping)",
105
+ " catalyst stage <start|renew|done|fail> <ticket> <phase> (a cloud-claimed local phase)",
102
106
  " catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
103
107
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
104
108
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
@@ -491,8 +495,12 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
491
495
  return await cmdMcp(args, ctx);
492
496
  case "accounts":
493
497
  return await cmdAccounts(args, ctx);
498
+ case "seat":
499
+ return await cmdSeat(args, ctx);
494
500
  case "release":
495
501
  return await cmdRelease(args, ctx);
502
+ case "stage":
503
+ return await cmdStage(args, ctx);
496
504
  case "environment":
497
505
  return await cmdEnvironment(args, ctx, deps.environment ?? {});
498
506
  case "env":
@@ -150,7 +150,8 @@ const REASONS = {
150
150
  runner_org_key_invalid: "The runner account key is invalid, belongs to another workspace, or lacks mirror read, write and feed permissions. Supply a scoped key for this workspace with CATALYST_RUNNER_ORG_KEY_FILE, then run catalyst onboard --runner.",
151
151
  runner_org_key_unavailable: "The runner account key could not be checked with Catalyst. Run catalyst onboard --runner to retry.",
152
152
  runner_org_key_write_failed: "Setup could not place the organization key on the runner. Check that Docker is running, then run catalyst onboard again.",
153
- runner_compose_failed: "Docker Compose could not start the runner. Run docker compose -p catalyst-host logs to see why, then run catalyst onboard again.",
153
+ runner_compose_failed: "Docker Compose could not start the runner project catalyst-host. Run docker compose -p catalyst-host logs to see why, then run catalyst onboard --runner again.",
154
+ runner_compose_paused: "The existing catalyst-host Compose project's containers are paused. Run docker compose -p catalyst-host unpause to resume them, then run catalyst onboard --runner again. Setup removed nothing.",
154
155
  runner_compose_not_running: "The runner's supervisor is not running. Run docker compose -p catalyst-host logs supervisor to see why, then run catalyst onboard again.",
155
156
  runner_enrollment_unverified: "The runner started but has not enrolled yet. Run docker compose -p catalyst-host logs supervisor to see why, or run catalyst onboard again in a minute.",
156
157
  runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
@@ -427,13 +428,15 @@ export function onboardReasonText(step, context = {}) {
427
428
  const commands = nativeEgressInstallCommands();
428
429
  return "Native Linux needs a root-managed session egress producer before the runner can take work. Its elevated installation creates the isolated session network and private-range and host-isolation nftables rules. Review and authorize this separately; setup has not run it. It requires systemd, python3, Docker and nftables. " + (commands ? "Install the verified bundled artifacts, then run the same setup command:\n" + commands : "Setup could not verify its bundled artifacts. Reinstall the CLI before authorizing installation.");
429
430
  }
430
- if (reason === "runner_images_unavailable") {
431
+ if (reason === "runner_images_unavailable" || reason === "runner_supervisor_image_unavailable") {
431
432
  const image = step.evidence?.image;
432
433
  const named = typeof image === "string" &&
433
434
  /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/.test(image)
434
435
  ? ` ${image}`
435
436
  : "";
436
- return `This machine does not have the Catalyst image${named}, and its pull did not complete. Check network access and run the same setup command to retry the anonymous pull.`;
437
+ return reason === "runner_supervisor_image_unavailable"
438
+ ? `Setup could not verify or download the supervisor image required by this CLI release${named}. It has not started new work. Check network access and run the same setup command again.`
439
+ : `This machine does not have the Catalyst image${named}, and its pull did not complete. Check network access and run the same setup command to retry the anonymous pull.`;
437
440
  }
438
441
  if (reason === "runner_org_key_missing") {
439
442
  const where = context.baseUrl
@@ -448,6 +451,25 @@ export function onboardReasonText(step, context = {}) {
448
451
  : "";
449
452
  return `The runner is enrolled but its readiness checks fail${checks}. Run docker compose -p catalyst-host logs supervisor for the remedy, then run catalyst onboard again.`;
450
453
  }
454
+ // CTC-5721: a Compose failure, or a paused older project, carries the evidence the adapter recorded.
455
+ if (reason === "runner_compose_paused") {
456
+ const services = typeof step.evidence?.services === "string" &&
457
+ /^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$/.test(step.evidence.services)
458
+ ? ` (${step.evidence.services.split(",").join(", ")})`
459
+ : "";
460
+ return `Setup found the existing catalyst-host Compose project's containers${services} paused and could not resume them. Run docker compose -p catalyst-host unpause to resume them, then run catalyst onboard --runner again. Setup removed nothing.`;
461
+ }
462
+ if (reason === "runner_compose_failed") {
463
+ const project = typeof step.evidence?.project === "string" &&
464
+ /^[a-z0-9][a-z0-9-]{0,63}$/.test(step.evidence.project)
465
+ ? step.evidence.project
466
+ : "catalyst-host";
467
+ const error = typeof step.evidence?.composeError === "string" &&
468
+ /^[\x20-\x7e]{1,500}$/.test(step.evidence.composeError)
469
+ ? step.evidence.composeError.replace(/\.+$/, "")
470
+ : undefined;
471
+ return `Docker Compose could not start the runner project ${project}${error ? `: ${error}` : ""}. Run docker compose -p ${project} logs to see why, then run catalyst onboard --runner again.`;
472
+ }
451
473
  if (reason === "runner_admission_operator")
452
474
  return `The admission policy for team ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} needs a Catalyst operator to check it. Setup changes only restricted admission and preserves existing policy fields. Ask Catalyst support to confirm a compatible policy, then run the same setup command.`;
453
475
  // CTC-4744: the cloud has not read the settings yet. Setup already waited a minute; this is
@@ -144,6 +144,39 @@ class Custody {
144
144
  }
145
145
  this.hold(value, false);
146
146
  }
147
+ /** Atomically re-records an already-held private file. The replacement is created inside the
148
+ * held directory and renamed over the target, then re-held, so every custody invariant
149
+ * (single-owner 0600, nlink 1, no symlinks) is re-verified on the new bytes. */
150
+ replace(value, bytes) {
151
+ const held = this.hold(value, false);
152
+ this.recheck();
153
+ const temporary = value + "." + randomBytes(16).toString("hex") + ".tmp";
154
+ let renamed = false;
155
+ try {
156
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT |
157
+ fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
158
+ try {
159
+ fs.writeFileSync(fd, bytes);
160
+ fs.fsyncSync(fd);
161
+ }
162
+ finally {
163
+ fs.closeSync(fd);
164
+ }
165
+ fs.renameSync(temporary, value);
166
+ renamed = true;
167
+ }
168
+ finally {
169
+ if (!renamed)
170
+ try {
171
+ fs.unlinkSync(temporary);
172
+ }
173
+ catch { /* The original error propagates. */ }
174
+ }
175
+ fs.closeSync(held.fd);
176
+ this.held.delete(value);
177
+ this.hold(value, false);
178
+ this.recheck();
179
+ }
147
180
  recheck() {
148
181
  for (const [value, held] of this.held) {
149
182
  const current = this.ports.stat(value, fs.fstatSync(held.fd, { bigint: true }));
@@ -244,7 +277,7 @@ function factory(ports) {
244
277
  const nodeHash = executable(executables.nodeExecutable, principal, roots);
245
278
  const dockerHash = executable(executables.dockerExecutable, principal, roots);
246
279
  let authority;
247
- let producerSupervisorImage = input.supervisorImage;
280
+ let enrolledSupervisorImage;
248
281
  if (existing) {
249
282
  custody.walk(state, false, 0o700, true);
250
283
  const runtime = record(parse(custody.read(join(state, "runtime.json"), 4096)), ["version", "nodeExecutable", "nodeExecutableSha256"]);
@@ -274,20 +307,19 @@ function factory(ports) {
274
307
  }
275
308
  if (key.asymmetricKeyType !== "ed25519" || createPublicKey(key).export({ type: "spki", format: "der" }).toString("base64") !== authority.publicKey)
276
309
  fail("private_key_authority_mismatch");
277
- // The enrolled producer attests its helper image. A runtime update does not re-enroll it.
278
- producerSupervisorImage = row.supervisorImage;
310
+ // The recorded helper image only authenticates the existing state. When it is not the
311
+ // current pin, the producer is re-attested on the current pin below, before the proof.
312
+ enrolledSupervisorImage = row.supervisorImage;
279
313
  }
280
314
  custody.recheck();
281
315
  alive();
282
316
  const engine = await ports.engineIdentity({ ...executables, principal,
283
- supervisorImage: input.supervisorImage, producerSupervisorImage,
317
+ supervisorImage: input.supervisorImage,
284
318
  runnerImage: input.runnerImage, deadlineMs: input.deadlineMs, signal });
285
319
  alive();
286
320
  if (!/^unix:\/\/\/[^,\0]+$/.test(engine.endpoint) || normalize(engine.endpoint.slice(7)) !== engine.endpoint.slice(7) ||
287
321
  !engine.cachedSupervisor || !engine.cachedRunner || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(engine.daemonId))
288
322
  fail("engine_endpoint_or_cache_invalid");
289
- if (engine.cachedProducerSupervisor !== true)
290
- fail("producer_helper_cache_unavailable");
291
323
  if (engine.runnerCustodyVersion !== "v1")
292
324
  fail("runner_custody_label_unqualified");
293
325
  if (authority && (authority.endpoint !== engine.endpoint || authority.daemonId !== engine.daemonId))
@@ -311,6 +343,15 @@ function factory(ports) {
311
343
  dockerExecutable: executables.dockerExecutable, dockerExecutableSha256: dockerHash,
312
344
  })));
313
345
  }
346
+ else if (enrolledSupervisorImage !== input.supervisorImage) {
347
+ // Re-attest the enrolled producer on the current pinned helper, exactly as first
348
+ // enrollment records it before proving it: same authority, key and executable pins,
349
+ // same repository, cache-only, and the signed bootstrap below runs on the new pin.
350
+ custody.replace(configPath, Buffer.from(canonicalDarwinThoughtsJson({
351
+ version: 1, authority, privateKeyFile, supervisorImage: input.supervisorImage,
352
+ dockerExecutable: executables.dockerExecutable, dockerExecutableSha256: dockerHash,
353
+ })));
354
+ }
314
355
  const artifacts = ports.artifacts();
315
356
  for (const [name, bytes] of [["producer.mjs", artifacts.producer], ["watchdog.mjs", artifacts.watchdog],
316
357
  ["watchdog-deadline.mjs", artifacts.deadline]]) {
@@ -344,7 +385,7 @@ function factory(ports) {
344
385
  await ports.service({ ...executables, uid: principal.uid, serviceLabel: LABEL, plistPath, deadlineMs: input.deadlineMs, signal });
345
386
  alive();
346
387
  const receipt = await ports.receiveBootstrap({ ...executables, authority, configPath,
347
- supervisorImage: producerSupervisorImage, deadlineMs: input.deadlineMs, signal });
388
+ supervisorImage: input.supervisorImage, deadlineMs: input.deadlineMs, signal });
348
389
  alive();
349
390
  if (ports.now() >= input.deadlineMs)
350
391
  fail("startup_proof_expired");
@@ -470,9 +511,6 @@ async function actualEngine(input) {
470
511
  const run = (args) => command(input.dockerExecutable, ["--config", config, "--host", selected, ...args], input.deadlineMs, 16384, input.signal);
471
512
  const daemon = await run(["info", "--format", "{{.ID}}"]);
472
513
  const supervisor = await run(["image", "inspect", "--format", "{{.Id}}", input.supervisorImage]);
473
- const producerSupervisorImage = input.producerSupervisorImage ?? input.supervisorImage;
474
- const producerSupervisor = producerSupervisorImage === input.supervisorImage ? supervisor :
475
- await run(["image", "inspect", "--format", "{{.Id}}", producerSupervisorImage]);
476
514
  const runner = await run(["image", "inspect", "--format", "{{json .}}", input.runnerImage]);
477
515
  if (daemon.code !== 0)
478
516
  fail("engine_identity_unavailable");
@@ -506,7 +544,6 @@ async function actualEngine(input) {
506
544
  }
507
545
  return { endpoint: selected, daemonId: daemon.stdout,
508
546
  cachedSupervisor: supervisor.code === 0 && /^sha256:[a-f0-9]{64}$/.test(supervisor.stdout),
509
- cachedProducerSupervisor: producerSupervisor.code === 0 && /^sha256:[a-f0-9]{64}$/.test(producerSupervisor.stdout),
510
547
  cachedRunner: runnerId !== undefined,
511
548
  ...(runnerCustodyVersion === undefined ? {} : { runnerCustodyVersion }) };
512
549
  }
@@ -20,13 +20,13 @@ import { onboardStateRoot, } from "./onboard.js";
20
20
  /** The Compose project name the vendored file declares. */
21
21
  export const RUNNER_PROJECT = "catalyst-host";
22
22
  export const RUNNER_SESSION_NETWORK = "catalyst-session-v1";
23
- /** Qualified public images from main 72e4e20f86: host run 38073557244 and
24
- * runner run 38073557245; the unchanged watchdog retains its earlier reviewed pin.
23
+ /** Supervisor from ee729b12 (Host images 38138827750); runner from
24
+ * 91a82214 (Runner image 38133282600). The watchdog retains its reviewed pin.
25
25
  * Explicit environment values override these pins; a saved runner image is preserved. */
26
26
  export const RUNNER_HOST_IMAGES = {
27
- supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:5aa2e7ab67d85c837a81e272ee089c8438ae1ba5b886b05354515be4058d15f6",
27
+ supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:3331ebc4204ae3975a4efb2c870c941f9c0f5ab435735e91dc9977941c91e9a8",
28
28
  watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:a5999dbf229ac432b83c2ed3fe43fa251366baceb73af2f57590890a6a758f0d",
29
- runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:dc83c11d357f1f5fa1ec55c669d02e9ff6f5ec9633a9bdc6fc15fbe7497f9655",
29
+ runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:9db2bf2c5580e2a1459fd076fa32c9af34456cc639ec3ab92b57b6ad98da67fb",
30
30
  };
31
31
  const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
32
32
  const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
@@ -91,6 +91,18 @@ const spawnDocker = (args, options) => new Promise((resolve, reject) => {
91
91
  child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
92
92
  child.stdin.end(options.input ?? "");
93
93
  });
94
+ /** Compose's own error lines, reduced to short printable text a person can act on. */
95
+ function composeErrorText(raw) {
96
+ if (!raw)
97
+ return undefined;
98
+ const lines = raw
99
+ .split(/\r?\n/)
100
+ .map((line) => line.replace(/[^\x20-\x7e]/g, "").trim())
101
+ .filter((line) => line !== "" && !line.startsWith("time="));
102
+ const errors = lines.filter((line) => /error|failed|cannot|conflict|paused|in use|refused|denied|invalid/i.test(line));
103
+ const text = (errors.length > 0 ? errors : lines).slice(0, 3).join(" | ").slice(0, 480);
104
+ return text !== "" ? text : undefined;
105
+ }
94
106
  /** The real engine, through the docker CLI. Compose reads `.env` only: the caller's CATALYST_ and
95
107
  * DOCKER_SOCKET_ variables are removed, because Compose prefers an exported value over the file. */
96
108
  export function dockerRunnerEngine(deps = {}) {
@@ -267,15 +279,53 @@ export function dockerRunnerEngine(deps = {}) {
267
279
  return "unverified";
268
280
  }
269
281
  }
270
- return { dir, hostName, baseUrl: normalizeBaseUrl(baseUrl) };
282
+ return { dir, hostName, baseUrl: normalizeBaseUrl(baseUrl), supervisorImage: config.Image };
271
283
  }
272
284
  catch {
273
285
  return "unverified";
274
286
  }
275
287
  },
288
+ async projectContainers(signal) {
289
+ const read = await docker(["ps", "--all",
290
+ "--filter", `label=com.docker.compose.project=${RUNNER_PROJECT}`,
291
+ "--format", '{{.Label "com.docker.compose.service"}}\t{{.State}}'], { signal });
292
+ if (read.code !== 0)
293
+ return null;
294
+ const rows = [];
295
+ for (const line of read.stdout.split("\n")) {
296
+ const [service, state] = line.trim().split("\t");
297
+ if (service && state &&
298
+ /^[a-z][a-z0-9_-]{0,63}$/.test(service) && /^[a-z]+$/.test(state))
299
+ rows.push({ service, state });
300
+ }
301
+ return rows;
302
+ },
303
+ async composeUnpause(dir, signal) {
304
+ const files = compose(dir);
305
+ if (!files)
306
+ return false;
307
+ return (await docker([...files, "unpause"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
308
+ },
276
309
  async imageArch(ref, signal) {
277
- const read = await docker(["image", "inspect", "--format", "{{.Architecture}}", ref], { signal });
278
- return read.code === 0 ? read.stdout.trim() || null : null;
310
+ if (!IMAGE_REF.test(ref))
311
+ return null;
312
+ const read = await docker(["image", "inspect", ref], { signal });
313
+ if (read.code !== 0)
314
+ return null;
315
+ try {
316
+ const rows = JSON.parse(read.stdout);
317
+ if (!Array.isArray(rows) || rows.length !== 1)
318
+ return null;
319
+ const row = object(rows[0]);
320
+ if (typeof row?.Id !== "string" || !/^sha256:[0-9a-f]{64}$/.test(row.Id) ||
321
+ row.Os !== "linux" || typeof row.Architecture !== "string" || !row.Architecture ||
322
+ !Array.isArray(row.RepoDigests) || !row.RepoDigests.includes(ref))
323
+ return null;
324
+ return row.Architecture;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
279
329
  },
280
330
  async pull(ref, signal) {
281
331
  const host = await endpoint(signal);
@@ -478,9 +528,11 @@ export function dockerRunnerEngine(deps = {}) {
478
528
  async composeUp(dir, signal) {
479
529
  const files = compose(dir);
480
530
  if (!files)
481
- return false;
482
- return ((await docker([...files, "up", "--detach"], { cwd: dir, signal, timeoutMs: 600_000 }))
483
- .code === 0);
531
+ return { ok: false };
532
+ const read = await docker([...files, "up", "--detach"], { cwd: dir, signal, timeoutMs: 600_000 });
533
+ return read.code === 0
534
+ ? { ok: true }
535
+ : { ok: false, error: composeErrorText(read.stderr || read.stdout) };
484
536
  },
485
537
  async composeRunning(dir, signal) {
486
538
  const files = compose(dir);
@@ -711,7 +763,8 @@ export function onboardRunnerAdapter(input = {}) {
711
763
  if (!info.vm && !(await engine.nativeEgressStatus?.(ctx.now().getTime(), signal)))
712
764
  return waiting("runner_native_egress_setup_required", selected);
713
765
  return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images,
714
- ...(installation && installation !== "missing" ? { installedName: installation.hostName } : {}) };
766
+ ...(installation && installation !== "missing" ? { installedName: installation.hostName,
767
+ installedSupervisorImage: installation.supervisorImage } : {}) };
715
768
  }
716
769
  async function desiredEnv(p, ctx, joinToken) {
717
770
  const saved = readEnvFile(join(p.dir, ".env")) ?? {};
@@ -852,8 +905,9 @@ export function onboardRunnerAdapter(input = {}) {
852
905
  catch {
853
906
  /* A missing or unreadable compose needs an explicitly authorized repair. */
854
907
  }
855
- const changed = !saved || !composeMatches || Object.entries(want).some(([key, value]) => saved[key] !== value);
856
- const hostEvidence = { ...selected, hostName: name };
908
+ const changed = !saved || !composeMatches || Object.entries(want).some(([key, value]) => saved[key] !== value) ||
909
+ (p.installedSupervisorImage !== undefined && p.installedSupervisorImage !== p.images.supervisor);
910
+ const hostEvidence = { ...selected, hostName: name, supervisorImage: p.images.supervisor };
857
911
  const pending = () => mayAct ? { state: "pending" } : waiting("runner_needs_runner_flag", hostEvidence);
858
912
  if (!running || changed)
859
913
  return afterAct ? waiting("runner_compose_not_running", hostEvidence) : pending();
@@ -923,6 +977,7 @@ export function onboardRunnerAdapter(input = {}) {
923
977
  return p;
924
978
  if (!mayAct)
925
979
  return waiting("runner_needs_runner_flag", selected);
980
+ let replacingContainers = false;
926
981
  try {
927
982
  mkdirSync(p.dir, { recursive: true, mode: 0o700 });
928
983
  if (lstatSync(p.dir).isSymbolicLink())
@@ -949,8 +1004,10 @@ export function onboardRunnerAdapter(input = {}) {
949
1004
  catch {
950
1005
  /* first run */
951
1006
  }
952
- if (!current?.equals(compose))
1007
+ if (!current?.equals(compose)) {
1008
+ replacingContainers = current !== null;
953
1009
  writeFileSync(join(p.dir, "compose.yaml"), compose, { mode: 0o644 });
1010
+ }
954
1011
  }
955
1012
  catch {
956
1013
  return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
@@ -983,7 +1040,9 @@ export function onboardRunnerAdapter(input = {}) {
983
1040
  };
984
1041
  const imagesRefused = await prepareRunnerImages(engine, Object.values(p.images), p.info.arch, signal);
985
1042
  if (imagesRefused)
986
- return waiting(imagesRefused.reason, { ...selected, ...(imagesRefused.image ? { image: imagesRefused.image } : {}) });
1043
+ return waiting(imagesRefused.reason === "runner_images_unavailable" && imagesRefused.image === p.images.supervisor
1044
+ ? "runner_supervisor_image_unavailable" : imagesRefused.reason, { ...selected, supervisorImage: p.images.supervisor,
1045
+ ...(imagesRefused.image ? { image: imagesRefused.image } : {}) });
987
1046
  if (p.info.vm) {
988
1047
  const capability = await engine.vmCapabilities(p.dir, p.images.supervisor, p.baseUrl, signal);
989
1048
  if (capability !== "ready")
@@ -1093,8 +1152,29 @@ export function onboardRunnerAdapter(input = {}) {
1093
1152
  return waiting("runner_admission_unverified", selected);
1094
1153
  }
1095
1154
  }
1096
- if (!(await engine.composeUp(p.dir, signal)))
1097
- return { state: "failed", reason: "runner_compose_failed", evidence: selected };
1155
+ // CTC-5721: an older catalyst-host project must never meet Compose as a surprise exit.
1156
+ // Its paused containers cannot be started by Compose: adopt them, or name the exact command.
1157
+ const containers = (await engine.projectContainers?.(signal)) ?? null;
1158
+ const paused = containers?.filter((row) => row.state === "paused") ?? [];
1159
+ const pausedNames = [...new Set(paused.map((row) => row.service))];
1160
+ if (paused.length > 0) {
1161
+ const resumed = (await engine.composeUnpause?.(p.dir, signal)) === true;
1162
+ if (!resumed)
1163
+ return waiting("runner_compose_paused", { ...selected, hostName: name,
1164
+ project: RUNNER_PROJECT, services: pausedNames.join(",") });
1165
+ input.message?.(`Resumed the paused containers (${pausedNames.join(", ")}) of the existing ${RUNNER_PROJECT} project; setup adopts it.`);
1166
+ }
1167
+ else if (containers && containers.length > 0 && replacingContainers) {
1168
+ input.message?.(`Replacing the existing ${RUNNER_PROJECT} project's containers (${[...new Set(containers.map((row) => row.service))].join(", ")}) with this release's Compose definitions.`);
1169
+ }
1170
+ else if (containers && containers.some((row) => row.state !== "running")) {
1171
+ const stopped = [...new Set(containers.filter((row) => row.state !== "running").map((row) => row.service))];
1172
+ input.message?.(`Starting the existing ${RUNNER_PROJECT} project's stopped containers (${stopped.join(", ")}); setup adopts it.`);
1173
+ }
1174
+ const up = await engine.composeUp(p.dir, signal);
1175
+ if (!up.ok)
1176
+ return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
1177
+ project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
1098
1178
  acted = true;
1099
1179
  return { state: "done" };
1100
1180
  },
package/dist/onboard.js CHANGED
@@ -300,6 +300,7 @@ function normalizeStep(value, fallbackAt) {
300
300
  "teamKey",
301
301
  "selected",
302
302
  "hostName",
303
+ "supervisorImage",
303
304
  "hostId",
304
305
  "capacity",
305
306
  "failing",
package/dist/seat.js ADDED
@@ -0,0 +1,45 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { flagString, positionals } from "./args.js";
3
+ import { DEFAULT_BASE_URL, requireConfig } from "./config.js";
4
+ import { UsageError } from "./errors.js";
5
+ import { ApiClient, apiClient } from "./transport.js";
6
+ const HARNESSES = new Set(["claude", "codex", "opencode"]);
7
+ /** The full response goes to stdout for a launcher to capture in an owner-only file. */
8
+ export async function cmdSeat(args, ctx) {
9
+ const [action, ...extra] = positionals(args);
10
+ if (action !== "checkout" || extra.length > 0)
11
+ throw new UsageError("seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]");
12
+ const size = flagString(args, "size");
13
+ const stage = flagString(args, "stage");
14
+ if (size !== undefined && size !== "big" && size !== "med")
15
+ throw new UsageError("--size must be big or med");
16
+ if (size === undefined && stage === undefined)
17
+ throw new UsageError("--size or --stage is required");
18
+ const harnesses = flagString(args, "harness")?.split(",");
19
+ if (!harnesses || harnesses.length === 0 || harnesses.some((h) => !HARNESSES.has(h)))
20
+ throw new UsageError("--harness must list claude, codex or opencode, separated by commas");
21
+ const sessionId = flagString(args, "session-id") ?? randomUUID();
22
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(sessionId))
23
+ throw new UsageError("--session-id must be one token of at most 128 letters, digits, dots, underscores or hyphens");
24
+ const keyEnv = flagString(args, "key-env");
25
+ if (keyEnv !== undefined && !/^[A-Z][A-Z0-9_]*$/.test(keyEnv))
26
+ throw new UsageError("--key-env must name an uppercase environment variable");
27
+ const key = keyEnv === undefined ? undefined : ctx.env[keyEnv];
28
+ if (keyEnv !== undefined && !key)
29
+ throw new UsageError(`--key-env ${keyEnv} is empty`);
30
+ const api = key === undefined
31
+ ? apiClient(requireConfig(ctx), ctx)
32
+ : new ApiClient(ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL, async () => key, ctx.fetch);
33
+ const response = await api.postJson("/api/v1/agent/credential-checkout", {
34
+ sessionId,
35
+ ...(size === undefined ? {} : { size }),
36
+ ...(stage === undefined ? {} : { stage }),
37
+ harnesses,
38
+ }, { accept: [409] });
39
+ if (response.status !== 200 || response.body.outcome !== "issued") {
40
+ ctx.stderr(`seat checkout refused: ${response.body.reason ?? "unexpected response"}`);
41
+ return 2;
42
+ }
43
+ ctx.stdout(JSON.stringify({ ...response.body, sessionId }));
44
+ return 0;
45
+ }