@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 +8 -0
- package/dist/args.js +15 -0
- package/dist/cli.js +8 -0
- package/dist/onboard-next.js +25 -3
- package/dist/onboard-runner-custody.js +48 -11
- package/dist/onboard-runner.js +97 -17
- package/dist/onboard.js +1 -0
- package/dist/seat.js +45 -0
- package/dist/stage.js +824 -0
- package/dist/transport.js +13 -2
- package/dist/write.js +48 -6
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/writing-to-linear.md +2 -2
- package/skills/catalyst-onboard/SKILL.md +7 -6
- package/skills/catalyst-onboard/references/connecting-this-machine.md +1 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/scripts/snapshot.mjs +128 -34
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":
|
package/dist/onboard-next.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
278
|
-
|
|
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,
|
|
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:
|
|
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
|
}
|
package/dist/onboard-runner.js
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
*
|
|
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:
|
|
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:
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1097
|
-
|
|
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
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
|
+
}
|