@catalyst-cloud/cli 0.16.27 → 0.16.29

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,345 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statfsSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { packageRoot } from "./config.js";
5
+ // CTC-5929: a supervisor in a Docker VM cannot measure the Mac's disk, so it refuses all work
6
+ // (`host_disk_unmeasured: physical_host_disk_sample_required`) until a native publisher writes a
7
+ // fresh sample into the read-only `host-disk` bind. Setup installs catalyst-cloud's own publisher
8
+ // (vendor/self-host, byte for byte) as a LaunchAgent, and names a zero-slot disk reason instead of
9
+ // leaving it in the supervisor log. The admission rule itself stays the supervisor's.
10
+ export const HOST_DISK_LABEL = "dev.catalystcloud.host-disk";
11
+ /** The sample path inside the supervisor; compose binds the runner's `host-disk` directory here. */
12
+ export const HOST_DISK_MOUNT = "/var/lib/catalyst/host-disk";
13
+ export const HOST_DISK_SAMPLE_FILE = `${HOST_DISK_MOUNT}/space.json`;
14
+ /** catalyst-cloud apps/host-agent/src/supervisor/disk/host-space.ts and the compose default. */
15
+ const SAMPLE_MAX_AGE_MS = 30_000;
16
+ const SAMPLE_FUTURE_MS = 5_000;
17
+ const MAX_SAMPLE_BYTES = 4_096;
18
+ export const DEFAULT_HOST_FREE_FLOOR_GIB = 25;
19
+ const GIB = 1024 ** 3;
20
+ const gib = (bytes) => Math.floor((bytes / GIB) * 100) / 100;
21
+ function floorGiB(saved) {
22
+ const value = Number(saved?.CATALYST_HOST_FREE_FLOOR_GIB?.trim() || DEFAULT_HOST_FREE_FLOOR_GIB);
23
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_HOST_FREE_FLOOR_GIB;
24
+ }
25
+ /** The supervisor's sample rules: schema 1, at most 30 seconds old, never ahead of the clock. */
26
+ export function hostDiskSampleBytes(text, nowMs) {
27
+ let sample;
28
+ try {
29
+ if (Buffer.byteLength(text) > MAX_SAMPLE_BYTES)
30
+ return "host_disk_sample_invalid";
31
+ const value = JSON.parse(text);
32
+ if (typeof value !== "object" || value === null || Array.isArray(value))
33
+ return "host_disk_sample_invalid";
34
+ sample = value;
35
+ }
36
+ catch {
37
+ return "host_disk_sample_invalid";
38
+ }
39
+ const { observedAtMs, availableBytes, totalBytes, path } = sample;
40
+ if (sample.schemaVersion !== 1 || typeof path !== "string" || !path.startsWith("/") ||
41
+ !Number.isSafeInteger(observedAtMs) || observedAtMs <= 0 ||
42
+ !Number.isSafeInteger(availableBytes) || availableBytes < 0 ||
43
+ !Number.isSafeInteger(totalBytes) || totalBytes <= 0 ||
44
+ availableBytes > totalBytes)
45
+ return "host_disk_sample_invalid";
46
+ if (nowMs - observedAtMs > SAMPLE_MAX_AGE_MS || observedAtMs > nowMs + SAMPLE_FUTURE_MS)
47
+ return "host_disk_sample_stale";
48
+ return availableBytes;
49
+ }
50
+ /** Where the supervisor's sample file lives on this machine, or null when it is not in the bind. */
51
+ export function hostDiskSamplePath(dir, saved) {
52
+ const file = saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim();
53
+ if (!file || !file.startsWith(`${HOST_DISK_MOUNT}/`) || file.split("/").some(part => part === ".."))
54
+ return null;
55
+ const source = saved?.CATALYST_HOST_DISK_SAMPLE_DIR?.trim() || "./host-disk";
56
+ return join(isAbsolute(source) ? source : resolve(dir, source), file.slice(HOST_DISK_MOUNT.length + 1));
57
+ }
58
+ /** The same verdict the supervisor reaches, read from this machine. Never an admission decision. */
59
+ export function runnerDiskVerdict(input) {
60
+ const floor = floorGiB(input.saved);
61
+ const unmeasured = (detail) => ({ state: "unmeasured", detail, floorGiB: floor });
62
+ let bytes;
63
+ if (input.saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim()) {
64
+ const path = hostDiskSamplePath(input.dir, input.saved);
65
+ if (!path)
66
+ return unmeasured("host_disk_unreadable");
67
+ let text;
68
+ try {
69
+ text = (input.readSample ?? ((file) => readFileSync(file, "utf8")))(path);
70
+ }
71
+ catch {
72
+ return unmeasured("host_disk_sample_missing");
73
+ }
74
+ const read = hostDiskSampleBytes(text, input.nowMs);
75
+ if (typeof read === "string")
76
+ return unmeasured(read);
77
+ bytes = read;
78
+ }
79
+ else if (input.vm) {
80
+ return unmeasured("physical_host_disk_sample_required");
81
+ }
82
+ else {
83
+ try {
84
+ bytes = input.nativeAvailableBytes?.() ?? null;
85
+ }
86
+ catch {
87
+ bytes = null;
88
+ }
89
+ if (bytes === null || !Number.isSafeInteger(bytes) || bytes < 0)
90
+ return unmeasured("host_disk_unreadable");
91
+ }
92
+ return { state: bytes >= floor * GIB ? "measured" : "below_floor", availableGiB: gib(bytes), floorGiB: floor };
93
+ }
94
+ /** Docker's data root, where the supervisor's named state volume lives on native Linux. */
95
+ export function dockerRootAvailableBytes(env) {
96
+ const read = spawnSync("docker", ["info", "--format", "{{.DockerRootDir}}"], { encoding: "utf8", timeout: 10_000, env });
97
+ const root = read.status === 0 ? read.stdout.trim() : "";
98
+ if (!isAbsolute(root))
99
+ return null;
100
+ const space = statfsSync(root);
101
+ return space.bavail * space.bsize;
102
+ }
103
+ const defaultRun = (cmd, args, timeoutMs) => {
104
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
105
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
106
+ };
107
+ const within = (path, root) => {
108
+ const rel = relative(root, path);
109
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
110
+ };
111
+ /** Docker Desktop's disk image folder: the relocated one from its settings, else the default. */
112
+ function desktopData(deps, exists) {
113
+ const read = deps.readText ?? ((path) => readFileSync(path, "utf8"));
114
+ for (const name of ["settings-store.json", "settings.json"]) {
115
+ let settings;
116
+ try {
117
+ settings = JSON.parse(read(join(deps.home, "Library/Group Containers/group.com.docker", name)));
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const folder = settings.DataFolder ?? settings.dataFolder;
123
+ if (folder !== undefined)
124
+ return typeof folder === "string" && isAbsolute(folder) && exists(folder) ? folder : null;
125
+ }
126
+ const fallback = join(deps.home, "Library/Containers/com.docker.docker/Data/vms/0/data");
127
+ return exists(fallback) ? fallback : null;
128
+ }
129
+ /** The filesystem backing the ACTIVE engine's VM disk, from its socket; null when it cannot be
130
+ * verified, so setup asks for CATALYST_HOST_DISK_PATH instead of measuring the wrong disk. */
131
+ export function engineDiskPath(deps) {
132
+ const exists = deps.exists ?? existsSync;
133
+ if (deps.explicitPath !== undefined && deps.explicitPath.trim() !== "")
134
+ return isAbsolute(deps.explicitPath) && exists(deps.explicitPath) ? deps.explicitPath : null;
135
+ if (!deps.dockerHost?.startsWith("unix://"))
136
+ return null;
137
+ let socket;
138
+ try {
139
+ socket = (deps.realpath ?? realpathSync)(deps.dockerHost.slice("unix://".length));
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ const home = deps.home;
145
+ if (within(socket, join(home, ".docker")) || within(socket, join(home, "Library/Containers/com.docker.docker")))
146
+ return desktopData(deps, exists);
147
+ if (within(socket, join(home, ".orbstack"))) {
148
+ const data = join(home, "Library/Group Containers/HUAQ24HBR6.dev.orbstack/data");
149
+ return exists(data) ? data : null;
150
+ }
151
+ // Lima and Colima keep each instance's disk in the instance directory that holds its socket.
152
+ const lima = /^(.*)\/sock\/docker\.sock$/.exec(socket)?.[1];
153
+ if (lima && within(lima, join(home, ".lima")) && exists(lima))
154
+ return lima;
155
+ const colima = /^(.*)\/docker\.sock$/.exec(socket)?.[1];
156
+ if (colima && within(colima, join(home, ".colima"))) {
157
+ const instance = join(home, ".colima", "_lima", `colima${colima.endsWith(`${sep}default`) ? "" : `-${colima.split(sep).at(-1)}`}`);
158
+ return exists(instance) ? instance : null;
159
+ }
160
+ return null;
161
+ }
162
+ /** A stable interpreter. /usr/bin/python3 is a stub that opens an install dialog until the
163
+ * Command Line Tools exist, so it is used only after xcode-select confirms them. */
164
+ function python(run, exists) {
165
+ if (exists("/usr/bin/python3") && run("/usr/bin/xcode-select", ["-p"], 5_000).status === 0)
166
+ return "/usr/bin/python3";
167
+ return ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"].find(exists) ?? null;
168
+ }
169
+ const xml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
170
+ function plan(dir, deps) {
171
+ const run = deps.run ?? defaultRun;
172
+ const exists = deps.exists ?? existsSync;
173
+ const measured = engineDiskPath(deps);
174
+ if (!measured)
175
+ return "runner_host_disk_location_unverified";
176
+ const interpreter = python(run, exists);
177
+ if (!interpreter)
178
+ return "runner_host_disk_sampler_python_missing";
179
+ const vendor = join(packageRoot(), "vendor", "self-host");
180
+ const script = join(dir, "host-disk-sampler", "host-disk-sample.py");
181
+ const output = join(dir, "host-disk", "space.json");
182
+ const values = {
183
+ __PYTHON__: interpreter, __SCRIPT__: script, __PATH__: measured, __OUTPUT__: output,
184
+ };
185
+ const template = readFileSync(join(vendor, "launchd", `${HOST_DISK_LABEL}.plist`), "utf8");
186
+ const plistText = template.replace(/<string>(__[A-Z]+__)<\/string>/g, (whole, key) => key in values ? `<string>${xml(values[key])}</string>` : whole);
187
+ const program = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(template)?.[1] ?? "";
188
+ const command = [...program.matchAll(/<string>([^<]*)<\/string>/g)].map(m => values[m[1]] ?? m[1]);
189
+ const at = command.indexOf("--interval");
190
+ return {
191
+ script, output, plistText, command, interval: Number(command[at + 1]),
192
+ plist: join(deps.home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`),
193
+ scriptBytes: readFileSync(join(vendor, "host-disk-sample.py")),
194
+ };
195
+ }
196
+ const readOr = (path) => {
197
+ try {
198
+ return readFileSync(path);
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ };
204
+ /** The file a host-disk agent's plist publishes to (its `--output`), unescaped; null when it names none. */
205
+ export function hostDiskAgentOutput(text) {
206
+ const raw = /<string>--output<\/string>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
207
+ return raw === undefined ? null : raw.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
208
+ }
209
+ /** A plist with this label that publishes somewhere else belongs to its operator. */
210
+ function foreign(existing, output) {
211
+ return hostDiskAgentOutput(existing.toString("utf8")) !== output;
212
+ }
213
+ /** Where a same-label agent this setup does not own publishes, as the two settings that would
214
+ * point the runner at it: the bind directory and the in-container file name. */
215
+ export function foreignHostDiskSample(home) {
216
+ const text = readOr(join(home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`))?.toString("utf8");
217
+ const output = text && hostDiskAgentOutput(text);
218
+ if (!output || !isAbsolute(output) || !/^[^\x00-\x1f'"]+$/.test(output))
219
+ return {};
220
+ return { sampleDir: dirname(output), sampleFile: `${HOST_DISK_MOUNT}/${output.split("/").at(-1)}` };
221
+ }
222
+ /** The launchd domain holding the agent: gui when someone is logged in at the console, else user. */
223
+ function loadedIn(run, uid) {
224
+ for (const domain of [`gui/${uid}`, `user/${uid}`])
225
+ if (run("/bin/launchctl", ["print", `${domain}/${HOST_DISK_LABEL}`], 5_000).status === 0)
226
+ return domain;
227
+ return null;
228
+ }
229
+ function writeAtomic(path, bytes, mode) {
230
+ mkdirSync(dirname(path), { recursive: true, mode: 0o755 });
231
+ const temporary = `${path}.${process.pid}.tmp`;
232
+ writeFileSync(temporary, bytes, { mode });
233
+ chmodSync(temporary, mode);
234
+ renameSync(temporary, path);
235
+ }
236
+ function observedAt(output) {
237
+ try {
238
+ const value = JSON.parse(readOr(output)?.toString("utf8") ?? "");
239
+ const at = value?.observedAtMs;
240
+ return Number.isSafeInteger(at) ? at : null;
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ }
246
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
247
+ /** The positive cadence check: the agent itself publishes two new samples, one interval apart.
248
+ * Loaded is not running: launchd held every launch of an agent bootstrapped over SSH (CTC-5929). */
249
+ function repeats(p, deps) {
250
+ const sleep = deps.sleep ?? sleepSync;
251
+ const seen = new Set();
252
+ const before = observedAt(p.output);
253
+ const deadline = deps.now() + (2 * p.interval + 5) * 1_000;
254
+ while (deps.now() < deadline) {
255
+ const at = observedAt(p.output);
256
+ if (at !== null && at !== before && sampleFresh(p.output, deps.now()))
257
+ seen.add(at);
258
+ if (seen.size >= 2)
259
+ return true;
260
+ sleep(1_000);
261
+ }
262
+ return false;
263
+ }
264
+ function sampleFresh(output, nowMs) {
265
+ const text = readOr(output);
266
+ return text !== null && typeof hostDiskSampleBytes(text.toString("utf8"), nowMs) === "number";
267
+ }
268
+ export function hostDiskSamplerStatus(dir, deps) {
269
+ const p = plan(dir, deps);
270
+ if (typeof p === "string")
271
+ return p;
272
+ const plist = readOr(p.plist);
273
+ if (plist && !plist.equals(Buffer.from(p.plistText)) && foreign(plist, p.output))
274
+ return "runner_host_disk_sampler_conflict";
275
+ return plist?.equals(Buffer.from(p.plistText)) && readOr(p.script)?.equals(p.scriptBytes) &&
276
+ loadedIn(deps.run ?? defaultRun, deps.uid) && sampleFresh(p.output, deps.now()) ? "installed" : "missing";
277
+ }
278
+ /** Installs or repairs the agent in place. Idempotent: an unchanged, loaded agent is left running.
279
+ * A same-label agent publishing elsewhere is never replaced. */
280
+ export function installHostDiskSampler(dir, deps) {
281
+ const run = deps.run ?? defaultRun;
282
+ const p = plan(dir, deps);
283
+ if (typeof p === "string")
284
+ return p;
285
+ const existing = readOr(p.plist);
286
+ const plistChanged = !existing?.equals(Buffer.from(p.plistText));
287
+ if (existing && plistChanged && foreign(existing, p.output))
288
+ return "runner_host_disk_sampler_conflict";
289
+ try {
290
+ // The plist does not carry the script's bytes, so a changed script needs its own reload: a
291
+ // running agent keeps the code it started with until launchd starts it again.
292
+ const scriptChanged = !readOr(p.script)?.equals(p.scriptBytes);
293
+ if (scriptChanged)
294
+ writeAtomic(p.script, p.scriptBytes, 0o644);
295
+ // One synchronous sample proves the interpreter and the measured path before launchd owns it.
296
+ // Without --interval the script publishes once and exits; with it, it loops.
297
+ const loop = p.command.indexOf("--interval");
298
+ const oneShot = loop < 0 ? p.command : [...p.command.slice(0, loop), ...p.command.slice(loop + 2)];
299
+ const once = run(oneShot[0], oneShot.slice(1), 15_000);
300
+ if (once.status !== 0 || !sampleFresh(p.output, deps.now()))
301
+ return "runner_host_disk_sampler_failed";
302
+ if (plistChanged)
303
+ writeAtomic(p.plist, p.plistText, 0o644);
304
+ let domain = loadedIn(run, deps.uid);
305
+ if (domain && (plistChanged || scriptChanged)) {
306
+ run("/bin/launchctl", ["bootout", `${domain}/${HOST_DISK_LABEL}`], 15_000);
307
+ domain = null;
308
+ }
309
+ if (!domain) {
310
+ const gui = run("/bin/launchctl", ["bootstrap", `gui/${deps.uid}`, p.plist], 15_000).status === 0;
311
+ if (!gui && run("/bin/launchctl", ["bootstrap", `user/${deps.uid}`, p.plist], 15_000).status !== 0)
312
+ return "runner_host_disk_sampler_failed";
313
+ }
314
+ domain = loadedIn(run, deps.uid);
315
+ if (!domain)
316
+ return "runner_host_disk_sampler_failed";
317
+ // A demand start: launchd ran a kickstarted agent on mini-2 while it held RunAtLoad and the
318
+ // timer. The script loops by itself once started, and a running agent is left as it is.
319
+ run("/bin/launchctl", ["kickstart", `${domain}/${HOST_DISK_LABEL}`], 15_000);
320
+ return repeats(p, deps) ? "ready" : "runner_host_disk_sampler_not_repeating";
321
+ }
322
+ catch {
323
+ return "runner_host_disk_sampler_failed";
324
+ }
325
+ }
326
+ export const isRunnerDiskUnmeasured = (value) => value in UNMEASURED_WHY;
327
+ const UNMEASURED_WHY = {
328
+ physical_host_disk_sample_required: "no disk sampler is set up for it",
329
+ host_disk_sample_missing: "the disk sample file is missing",
330
+ host_disk_sample_stale: "the last disk sample is more than 30 seconds old",
331
+ host_disk_sample_invalid: "the disk sample cannot be read",
332
+ host_disk_unreadable: "setup cannot read the disk it measures",
333
+ };
334
+ /** One wording for setup and `catalyst ready`: the reason for zero free slots, then the fix. */
335
+ export function runnerDiskCopy(verdict, vm) {
336
+ if (verdict.state === "below_floor")
337
+ return {
338
+ line: `The runner advertises zero free slots: ${verdict.availableGiB} GiB of disk is free and it needs ${verdict.floorGiB} GiB.`,
339
+ fix: `Free at least ${Math.ceil(verdict.floorGiB - verdict.availableGiB)} GiB on ${vm ? "this Mac" : "the disk that holds Docker's data"}, for example with docker system prune. The runner takes work again by itself; run catalyst ready to confirm.`,
340
+ };
341
+ const line = `The runner advertises zero free slots: it has no fresh measurement of ${vm ? "this Mac's" : "this machine's"} disk, because ${UNMEASURED_WHY[verdict.detail]}.`;
342
+ return vm
343
+ ? { line, fix: `Run catalyst setup --runner. It installs and starts the ${HOST_DISK_LABEL} LaunchAgent, which measures the disk every 10 seconds. If it is already installed, run launchctl print gui/$(id -u)/${HOST_DISK_LABEL}, or user/$(id -u)/${HOST_DISK_LABEL} when no one is logged in at the console, to see why it stopped. If you publish your own sample with CATALYST_HOST_DISK_SAMPLE_DIR and CATALYST_HOST_DISK_SAMPLE_FILE, restart that publisher.` }
344
+ : { line, fix: "Run docker compose -p catalyst-host logs supervisor and look for host_disk_admission_refused, then run catalyst ready." };
345
+ }
@@ -8,6 +8,7 @@ import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboar
8
8
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
9
9
  import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
10
10
  import { runnerVmCapabilities } from "./onboard-runner-probes.js";
11
+ import { dockerRootAvailableBytes, foreignHostDiskSample, hostDiskSamplerStatus, installHostDiskSampler, runnerDiskVerdict, HOST_DISK_SAMPLE_FILE, } from "./onboard-runner-disk.js";
11
12
  import { darwinThoughtsInstallAdvice, darwinThoughtsInstallException, darwinThoughtsInstallRefusal, installDarwinThoughtsCustody } from "./onboard-runner-custody.js";
12
13
  import { emitCustodyFailure } from "./onboard-runner-telemetry.js";
13
14
  import { canonicalDarwinThoughtsJson, parseDarwinThoughtsAuthority } from "../vendor/self-host/darwin-thoughts-custody/verifier.mjs";
@@ -69,6 +70,8 @@ const DISK_POLICY_KEYS = [
69
70
  "CATALYST_SLOT_DISK_MODE", "CATALYST_SLOT_DISK_BUDGET_GIB", "CATALYST_DISK_BUDGET_GIB",
70
71
  "CATALYST_WORKSPACE_STORAGE", "CATALYST_WORKSPACE_RETENTION_HOURS",
71
72
  "CATALYST_HOST_FREE_FLOOR_GIB", "CATALYST_HOST_DISK_SAMPLE_DIR", "CATALYST_HOST_DISK_SAMPLE_FILE",
73
+ // Not read by Compose: where the Mac sampler measures, kept so unattended checks plan the same path.
74
+ "CATALYST_HOST_DISK_PATH",
72
75
  ];
73
76
  function isUnenrolled(value) {
74
77
  return value !== null && "unenrolled" in value;
@@ -549,6 +552,20 @@ export function dockerRunnerEngine(deps = {}) {
549
552
  return (read.code === 0 &&
550
553
  read.stdout.split("\n").some((line) => line.trim() === "supervisor"));
551
554
  },
555
+ async hostDisk(dir, vm) {
556
+ return runnerDiskVerdict({ dir, saved: readEnvFile(join(dir, ".env")), vm, nowMs: Date.now(),
557
+ nativeAvailableBytes: () => dockerRootAvailableBytes(env) });
558
+ },
559
+ ...((deps.platform ?? process.platform) === "darwin" ? {
560
+ async hostDiskSamplerStatus(dir, options) {
561
+ return hostDiskSamplerStatus(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
562
+ now: Date.now, dockerHost: await endpoint(), ...options });
563
+ },
564
+ async installHostDiskSampler(dir, options) {
565
+ return installHostDiskSampler(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
566
+ now: Date.now, dockerHost: await endpoint(), ...options });
567
+ },
568
+ } : {}),
552
569
  };
553
570
  }
554
571
  const serves = (host, teamKey) => host.teams.includes(teamKey);
@@ -590,6 +607,7 @@ function parseHosts(body) {
590
607
  capacity: capability ? capability.runtimeLive
591
608
  ? capability.placeableCapacity
592
609
  : 0 : null,
610
+ live: capability?.runtimeLive === true,
593
611
  failing: capability ? capability.failingRequired : [],
594
612
  });
595
613
  }
@@ -790,6 +808,7 @@ export function onboardRunnerAdapter(input = {}) {
790
808
  const gid = p.info.vm ? 0 : await engine.socketGid();
791
809
  if (!HOST_NAME.test(name) || gid === null)
792
810
  return null;
811
+ const sampler = samplerManaged(p, ctx, saved);
793
812
  return {
794
813
  DOCKER_SOCKET_PATH: "/var/run/docker.sock",
795
814
  DOCKER_SOCKET_GID: String(gid),
@@ -808,10 +827,22 @@ export function onboardRunnerAdapter(input = {}) {
808
827
  CATALYST_THOUGHTS_CUSTODY_RESPONSES_DIR: receivedCustody.result.responsesRoot,
809
828
  } : {}),
810
829
  CATALYST_SESSION_EGRESS_DIR: p.info.vm ? join(p.dir, "session-egress") : NATIVE_EGRESS_DIR,
830
+ ...(sampler ? { CATALYST_HOST_DISK_SAMPLE_FILE: HOST_DISK_SAMPLE_FILE } : {}),
811
831
  // Saved policy stays in merged(). An explicit value in this run overrides it.
812
832
  ...Object.fromEntries(DISK_POLICY_KEYS.flatMap(key => ctx.env[key] === undefined ? [] : [[key, ctx.env[key]]])),
813
833
  };
814
834
  }
835
+ /** CTC-5929: setup publishes the Mac's disk sample into the install's own `host-disk` bind. A
836
+ * custom sample directory or file belongs to its operator, who also runs its publisher. */
837
+ function samplerManaged(p, ctx, saved) {
838
+ const custom = (key) => (ctx.env[key] ?? saved?.[key] ?? "").trim();
839
+ return p.info.vm && engine.installHostDiskSampler !== undefined && custom("CATALYST_HOST_DISK_SAMPLE_DIR") === "" &&
840
+ ["", HOST_DISK_SAMPLE_FILE].includes(custom("CATALYST_HOST_DISK_SAMPLE_FILE"));
841
+ }
842
+ const samplerOptions = (ctx, saved) => {
843
+ const explicitPath = ctx.env.CATALYST_HOST_DISK_PATH ?? saved?.CATALYST_HOST_DISK_PATH;
844
+ return explicitPath ? { explicitPath } : {};
845
+ };
815
846
  /** The step owns its own keys only; a person's tuning (CATALYST_SLOTS and the rest) is kept. */
816
847
  const merged = (saved, want) => ({
817
848
  ...want,
@@ -947,6 +978,12 @@ export function onboardRunnerAdapter(input = {}) {
947
978
  const name = want.CATALYST_HOST_NAME;
948
979
  if (p.info.vm && persistedCustody(p.dir, saved) !== "installed")
949
980
  return mayAct && !afterAct ? { state: "pending" } : waiting(mayAct ? "runner_thoughts_custody_unavailable" : "runner_needs_runner_flag", { ...selected, hostName: name });
981
+ if (samplerManaged(p, ctx, saved) && engine.hostDiskSamplerStatus) {
982
+ const sampler = await engine.hostDiskSamplerStatus(p.dir, samplerOptions(ctx, saved));
983
+ if (sampler !== "installed")
984
+ return mayAct && !afterAct ? { state: "pending" } : waiting(!mayAct ? "runner_needs_runner_flag" : sampler === "missing" ? "runner_host_disk_sampler_failed" : sampler, { ...selected, hostName: name,
985
+ ...(sampler === "runner_host_disk_sampler_conflict" ? foreignHostDiskSample(ctx.home) : {}) });
986
+ }
950
987
  let enrolled = null;
951
988
  const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && serves(host, p.teamKey) && !host.revoked);
952
989
  let hosts = await listHosts(ctx, p, signal);
@@ -1023,6 +1060,15 @@ export function onboardRunnerAdapter(input = {}) {
1023
1060
  return pending();
1024
1061
  if (keyStatus !== "valid")
1025
1062
  return waiting(keyStatus === "missing" ? "runner_org_key_missing" : keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", evidence);
1063
+ if (host.capacity === 0 && host.live && host.failing.length === 0) {
1064
+ // The supervisor advertises zero slots when disk admission refuses; its reason stays in its log.
1065
+ const disk = await engine.hostDisk?.(p.dir, p.info.vm);
1066
+ if (disk && disk.state !== "measured")
1067
+ return waiting(disk.state === "below_floor" ? "runner_host_disk_below_floor" : "runner_host_disk_unmeasured", {
1068
+ ...evidence, floorGiB: disk.floorGiB, vm: p.info.vm,
1069
+ ...(disk.state === "below_floor" ? { availableGiB: disk.availableGiB } : { diskDetail: disk.detail }),
1070
+ });
1071
+ }
1026
1072
  if (host.capacity === 0 || host.failing.length > 0)
1027
1073
  return waiting("runner_host_not_ready", { ...evidence, failing: host.failing.join(",") });
1028
1074
  if (p.cloud) {
@@ -1166,6 +1212,12 @@ export function onboardRunnerAdapter(input = {}) {
1166
1212
  (advice ? " " + advice : ""), details, signal);
1167
1213
  }
1168
1214
  }
1215
+ if (samplerManaged(p, ctx, readEnvFile(join(p.dir, ".env"))) && engine.installHostDiskSampler) {
1216
+ const sampler = await engine.installHostDiskSampler(p.dir, samplerOptions(ctx, readEnvFile(join(p.dir, ".env"))));
1217
+ if (sampler !== "ready")
1218
+ return waiting(sampler, sampler === "runner_host_disk_sampler_conflict"
1219
+ ? { ...selected, ...foreignHostDiskSample(ctx.home) } : selected);
1220
+ }
1169
1221
  if (!writeEnv(want))
1170
1222
  return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
1171
1223
  const network = await engine.network(RUNNER_SESSION_NETWORK, signal);
package/dist/onboard.js CHANGED
@@ -324,6 +324,14 @@ function normalizeStep(value, fallbackAt) {
324
324
  "custodyResultInstalled",
325
325
  "custodyAuthorityMatched",
326
326
  "custodyTelemetry",
327
+ // CTC-5929: the runner's disk reason and a foreign sampler's paths. Without them the saved
328
+ // step read back as "no disk sampler" on a Linux host that was below its floor.
329
+ "floorGiB",
330
+ "availableGiB",
331
+ "vm",
332
+ "diskDetail",
333
+ "sampleDir",
334
+ "sampleFile",
327
335
  ]) {
328
336
  const item = evidence?.[key];
329
337
  if (item === null ||
package/dist/ready.js CHANGED
@@ -16,7 +16,9 @@ import { installedBundleVersion } from "./skills.js";
16
16
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
17
17
  import { selectedOnboardTeam } from "./onboard-existing.js";
18
18
  import { dispatchGateCopy, teamCheckCopy, valuesFix, whoFixes } from "./ready-copy.js";
19
- import { onboardStatePath, readOnboardJournal } from "./onboard.js";
19
+ import { onboardStatePath, onboardStateRoot, readOnboardJournal } from "./onboard.js";
20
+ import { dockerRunnerEngine, readEnvFile, RUNNER_HOST_IMAGES } from "./onboard-runner.js";
21
+ import { dockerRootAvailableBytes, runnerDiskCopy, runnerDiskVerdict } from "./onboard-runner-disk.js";
20
22
  export { semverOlder };
21
23
  /** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
22
24
  * mirror's snapshot path is safe for a large tenant, a customer following that advice is the
@@ -112,6 +114,35 @@ export function catalystCommandCheck(env, platform = process.platform) {
112
114
  "Keep using catalyst-skills on this machine, or put the npm global bin directory ahead of that one on PATH.",
113
115
  };
114
116
  }
117
+ /** CTC-5929: the disk admission the supervisor applies, read from the runner installed here. The
118
+ * running catalyst-host project names its own directory, so a moved state root is still found; the
119
+ * state root is only the fallback when Docker shows no such project or cannot be listed.
120
+ * A Mac runs the engine in a VM, so only the native sample counts; Linux measures Docker's data root. */
121
+ async function localRunnerDisk(ctx, engine) {
122
+ let dir = null;
123
+ const containers = await engine.projectContainers?.().catch(() => null);
124
+ if (containers?.some(row => row.service === "supervisor")) {
125
+ const installed = await engine.installation?.(ctx.home, ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor)
126
+ .catch(() => "unverified");
127
+ if (!installed || typeof installed === "string")
128
+ return { unverified: true };
129
+ dir = installed.dir;
130
+ }
131
+ else {
132
+ try {
133
+ dir = join(onboardStateRoot(ctx.home, ctx.env), "runner");
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ const saved = readEnvFile(join(dir, ".env"));
140
+ if (!saved?.CATALYST_SUPERVISOR_IMAGE)
141
+ return containers?.length ? { unverified: true } : null;
142
+ const vm = process.platform === "darwin";
143
+ return { vm, verdict: runnerDiskVerdict({ dir, saved, vm, nowMs: ctx.now().getTime(),
144
+ nativeAvailableBytes: () => dockerRootAvailableBytes(ctx.env) }) };
145
+ }
115
146
  export async function readyReport(ctx, deps) {
116
147
  const checks = [];
117
148
  const facts = deps.runtime ?? detectRuntime();
@@ -311,6 +342,21 @@ export async function readyReport(ctx, deps) {
311
342
  who: "you",
312
343
  });
313
344
  }
345
+ const disk = await (deps.runnerDisk ?? (() => localRunnerDisk(ctx, deps.runnerEngine ?? dockerRunnerEngine({ env: ctx.env }))))();
346
+ if (disk && "unverified" in disk) {
347
+ checks.push({ id: "runner:disk", ok: false, note: true,
348
+ line: "runner disk: a catalyst-host runner is installed here, but ready could not verify its installation to read its disk measurement",
349
+ fix: "run catalyst setup --runner, which checks the installation and names what is wrong" });
350
+ }
351
+ else if (disk) {
352
+ if (disk.verdict.state === "measured")
353
+ checks.push({ id: "runner:disk", ok: true,
354
+ line: `runner disk: ${disk.verdict.availableGiB} GiB free, at least ${disk.verdict.floorGiB} GiB needed` });
355
+ else {
356
+ const copy = runnerDiskCopy(disk.verdict, disk.vm);
357
+ checks.push({ id: "runner:disk", ok: false, line: `runner disk: ${copy.line}`, fix: copy.fix, who: "you" });
358
+ }
359
+ }
314
360
  const replica = replicaStatus(ctx, cfg);
315
361
  checks.push({
316
362
  id: "replica",
package/dist/runner.js CHANGED
@@ -8,6 +8,7 @@ import { loadConfig } from "./config.js";
8
8
  import { CliError, UsageError } from "./errors.js";
9
9
  import { onboardStateRoot } from "./onboard.js";
10
10
  import { dockerRunnerEngine, RUNNER_PROJECT, RUNNER_SESSION_NETWORK } from "./onboard-runner.js";
11
+ import { HOST_DISK_LABEL, hostDiskAgentOutput } from "./onboard-runner-disk.js";
11
12
  import { EGRESS_ARTIFACTS, NATIVE_EGRESS_DIR } from "./onboard-runner-egress.js";
12
13
  import { stdinIsTty } from "./prompt.js";
13
14
  import { apiClient } from "./transport.js";
@@ -23,7 +24,7 @@ const COMPOSE_VOLUMES = ["supervisor-state", "host-credential"].map(v => `${RUNN
23
24
  const COMPOSE_FILES = ["compose.yaml", "compose.darwin-thoughts.yaml"];
24
25
  /** Files setup writes into the runner directory. The work folders are listed separately. */
25
26
  const SETUP_FILES = [...COMPOSE_FILES, ".env", "key-request.json"];
26
- const SETUP_DIRS = ["session-egress", "host-disk"];
27
+ const SETUP_DIRS = ["session-egress", "host-disk", "host-disk-sampler"];
27
28
  /** Repository checkouts, the thoughts checkout and their locks: kept unless the owner says so. */
28
29
  const WORK_DIRS = ["slots", "thoughts", "locks"];
29
30
  const DARWIN_SIBLINGS = ["darwin-thoughts-custody", "darwin-thoughts-requests", "darwin-thoughts-responses"];
@@ -68,6 +69,20 @@ const exists = (path) => { try {
68
69
  catch {
69
70
  return false;
70
71
  } };
72
+ /** Setup's disk sampler publishes into this runner's own host-disk bind; a same-label agent that
73
+ * writes elsewhere is its operator's, as setup treats it (CTC-5929). */
74
+ function samplerIsSetups(text, runnerDir) {
75
+ const output = hostDiskAgentOutput(text);
76
+ if (!output || !runnerDir || basename(output) !== "space.json")
77
+ return false;
78
+ const real = (path) => { try {
79
+ return realpathSync(path);
80
+ }
81
+ catch {
82
+ return path;
83
+ } };
84
+ return real(dirname(output)) === real(join(runnerDir, "host-disk"));
85
+ }
71
86
  /** The onboarding engine's reads, through this command's own runner. */
72
87
  function dockerEnvRun(run) {
73
88
  return (args, options) => run("docker", args, { input: options.input, timeoutMs: options.timeoutMs });
@@ -512,7 +527,9 @@ async function uninstall(args, ctx, deps, inst) {
512
527
  for (const agent of darwinAgents) {
513
528
  let ours = false;
514
529
  try {
515
- ours = owned(agent.plist, uid, "file") && readFileSync(agent.plist, "utf8").includes(`<string>${agent.label}</string>`);
530
+ const text = readFileSync(agent.plist, "utf8");
531
+ ours = owned(agent.plist, uid, "file") && text.includes(`<string>${agent.label}</string>`) &&
532
+ (agent.label !== HOST_DISK_LABEL || samplerIsSetups(text, runnerDir));
516
533
  }
517
534
  catch { /* unreadable */ }
518
535
  if (!ours) {
@@ -112,6 +112,7 @@ export function createLiveEventsClient(sdk, opts) {
112
112
  baseUrl: opts.baseUrl,
113
113
  accountId: opts.accountId,
114
114
  auth: opts.auth,
115
+ connectParams: () => opts.cliVersion ? { cli_version: opts.cliVersion } : undefined,
115
116
  reseed: reseedHead,
116
117
  getCursor: () => state?.cursor ?? null,
117
118
  onChange: (frame) => {
package/dist/watch.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // an issue reference to its project through GET /api/v1/issues/:ref, cached in memory.
5
5
  import { spawn } from "node:child_process";
6
6
  import { flagList, flagString } from "./args.js";
7
- import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
7
+ import { apiBase, requireConfig, readManifest, watchCursorPathFor } from "./config.js";
8
8
  import { loadContract } from "./contract.js";
9
9
  import { CliError, UsageError } from "./errors.js";
10
10
  import { apiClient } from "./transport.js";
@@ -136,6 +136,7 @@ export async function runWatch(ctx, cfg, opts, deps = {}) {
136
136
  return createLiveEventsClient(sdk, {
137
137
  baseUrl: apiBase(cfg),
138
138
  accountId: cfg.account,
139
+ cliVersion: readManifest().version,
139
140
  auth: authStrategyFor(ctx, cfg),
140
141
  getToken: () => bearerFor(ctx, cfg),
141
142
  cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/cli",
3
- "version": "0.16.27",
3
+ "version": "0.16.29",
4
4
  "description": "Set up Catalyst Cloud, check your tickets and manage your account from the command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,16 +53,16 @@
53
53
  "prepublishOnly": "node scripts/release-train-check.mjs --publish cli"
54
54
  },
55
55
  "dependencies": {
56
- "@catalyst-cloud/sdk": "0.16.11",
56
+ "@catalyst-cloud/sdk": "0.16.19",
57
57
  "@clack/prompts": "1.8.1",
58
58
  "fast-string-width": "3.0.2",
59
59
  "fast-wrap-ansi": "0.2.2",
60
60
  "smol-toml": "^1.7.1",
61
61
  "yaml": "^2.9.0",
62
- "@catalyst-cloud/sdk-replica-node": "0.16.11"
62
+ "@catalyst-cloud/sdk-replica-node": "0.16.19"
63
63
  },
64
64
  "devDependencies": {
65
- "@catalyst-cloud/schema": "0.16.9",
65
+ "@catalyst-cloud/schema": "0.16.16",
66
66
  "@types/node": "^26.3.0",
67
67
  "@vitest/coverage-v8": "^4.1.11",
68
68
  "typescript": "^7.0.2",