@catalyst-cloud/cli 0.14.9 → 0.15.0
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 +13 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +267 -94
- package/dist/oauth.js +80 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -207
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- 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 +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- package/vendor/self-host/provenance.json +11 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { hostname, tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { loadConfig, normalizeBaseUrl, packageRoot } from "./config.js";
|
|
7
|
+
import { nativeEgressReady, NATIVE_EGRESS_DIR } from "./onboard-runner-egress.js";
|
|
8
|
+
import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboard-runner-cloud.js";
|
|
9
|
+
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
10
|
+
import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
|
|
11
|
+
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
12
|
+
import { onboardStateRoot, } from "./onboard.js";
|
|
13
|
+
// "Run Catalyst's work on this machine": the self-hosted host from catalyst-cloud's deploy/self-host
|
|
14
|
+
// (a supervisor and a deadline watchdog under Compose), enrolled with the person's own owner or
|
|
15
|
+
// admin login. Never the operator token. Missing host images are pulled anonymously with an empty
|
|
16
|
+
// disposable Docker config. Customer delivery uses public GHCR pins, without registry credentials.
|
|
17
|
+
/** The Compose project name the vendored file declares. */
|
|
18
|
+
export const RUNNER_PROJECT = "catalyst-host";
|
|
19
|
+
export const RUNNER_SESSION_NETWORK = "catalyst-session-v1";
|
|
20
|
+
/** Public multi-architecture images: host images from main 0b5495e144 (run 36999325814),
|
|
21
|
+
* runner from main 48b5876e56 (run 37057503445). Explicit environment image values override
|
|
22
|
+
* these pins; the runner also preserves a saved image before choosing its default. */
|
|
23
|
+
export const RUNNER_HOST_IMAGES = {
|
|
24
|
+
supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:fff1582e3ef763eae6728f195a8e3834383955ee03d39bcfe567f4a7361a8982",
|
|
25
|
+
watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:b64e38f9ee34240d1e3d256e954eb8de20e2d3e51ed873019d350aa60be639c1",
|
|
26
|
+
runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:50eeb256b4693fc42c81458bdfc887137a0df757260601f2a869738578d00782",
|
|
27
|
+
};
|
|
28
|
+
const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
|
|
29
|
+
const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
30
|
+
const JOIN_TOKEN = /^[A-Za-z0-9._~+/=-]{16,1024}$/;
|
|
31
|
+
const ENROLLMENTS = "/api/v1/hosts/enrollments";
|
|
32
|
+
const JOIN_TOKENS = "/api/v1/hosts/join-tokens";
|
|
33
|
+
const CAPACITY = "/api/v1/me/runner-capacity";
|
|
34
|
+
const RUNNER_UID = "10001:10001";
|
|
35
|
+
const DIRS = ["slots", "thoughts", "locks"];
|
|
36
|
+
function isUnenrolled(value) {
|
|
37
|
+
return value !== null && "unenrolled" in value;
|
|
38
|
+
}
|
|
39
|
+
const spawnDocker = (args, options) => new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn("docker", args, {
|
|
41
|
+
env: options.env,
|
|
42
|
+
cwd: options.cwd,
|
|
43
|
+
signal: options.signal,
|
|
44
|
+
timeout: options.timeoutMs ?? 30_000,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
let stdout = "", stderr = "";
|
|
48
|
+
child.stderr.setEncoding("utf8");
|
|
49
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 1_000_000)
|
|
50
|
+
stderr += chunk; });
|
|
51
|
+
child.stdout.setEncoding("utf8");
|
|
52
|
+
child.stdout.on("data", (chunk) => {
|
|
53
|
+
if (stdout.length < 1_000_000)
|
|
54
|
+
stdout += chunk;
|
|
55
|
+
});
|
|
56
|
+
child.on("error", reject);
|
|
57
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
58
|
+
child.stdin.end(options.input ?? "");
|
|
59
|
+
});
|
|
60
|
+
/** The real engine, through the docker CLI. Compose reads `.env` only: the caller's CATALYST_ and
|
|
61
|
+
* DOCKER_SOCKET_ variables are removed, because Compose prefers an exported value over the file. */
|
|
62
|
+
export function dockerRunnerEngine(deps = {}) {
|
|
63
|
+
const exec = deps.exec ?? spawnDocker;
|
|
64
|
+
const env = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
|
|
65
|
+
const docker = async (args, options = {}) => {
|
|
66
|
+
try {
|
|
67
|
+
return await exec(args, { env, ...options });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { code: 1, stdout: "" };
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const compose = (dir) => [
|
|
74
|
+
"compose",
|
|
75
|
+
"--project-name",
|
|
76
|
+
RUNNER_PROJECT,
|
|
77
|
+
"--file",
|
|
78
|
+
join(dir, "compose.yaml"),
|
|
79
|
+
"--env-file",
|
|
80
|
+
join(dir, ".env"),
|
|
81
|
+
];
|
|
82
|
+
const supervisorShell = (dir, script) => [
|
|
83
|
+
...compose(dir),
|
|
84
|
+
"run",
|
|
85
|
+
"--rm",
|
|
86
|
+
"-T",
|
|
87
|
+
"--no-deps",
|
|
88
|
+
"--entrypoint",
|
|
89
|
+
"sh",
|
|
90
|
+
"supervisor",
|
|
91
|
+
"-c",
|
|
92
|
+
script,
|
|
93
|
+
];
|
|
94
|
+
let nativeLocal = false;
|
|
95
|
+
const endpoint = async (signal) => {
|
|
96
|
+
// Reject conflicting selectors rather than infer precedence across Docker/Compose versions.
|
|
97
|
+
if (env.DOCKER_CONTEXT && env.DOCKER_HOST)
|
|
98
|
+
return null;
|
|
99
|
+
if (!env.DOCKER_CONTEXT && env.DOCKER_HOST)
|
|
100
|
+
return env.DOCKER_HOST;
|
|
101
|
+
const read = await docker(["context", "inspect", ...(env.DOCKER_CONTEXT ? [env.DOCKER_CONTEXT] : []),
|
|
102
|
+
"--format", '{{(index .Endpoints "docker").Host}}'], { signal });
|
|
103
|
+
return read.code === 0 ? read.stdout.trim() || null : null;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
async info(signal) {
|
|
107
|
+
nativeLocal = false;
|
|
108
|
+
const read = await docker(["info", "--format", "{{json .}}"], { signal });
|
|
109
|
+
if (read.code !== 0)
|
|
110
|
+
return null;
|
|
111
|
+
let body;
|
|
112
|
+
try {
|
|
113
|
+
body = JSON.parse(read.stdout);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const arches = {
|
|
119
|
+
x86_64: "amd64",
|
|
120
|
+
amd64: "amd64",
|
|
121
|
+
aarch64: "arm64",
|
|
122
|
+
arm64: "arm64",
|
|
123
|
+
};
|
|
124
|
+
const arch = arches[String(body.Architecture)];
|
|
125
|
+
if (body.OSType !== "linux" || !arch)
|
|
126
|
+
return null;
|
|
127
|
+
if ((await docker(["compose", "version"], { signal })).code !== 0)
|
|
128
|
+
return null;
|
|
129
|
+
const vm = /docker desktop|orbstack/i.test(String(body.OperatingSystem));
|
|
130
|
+
const host = await endpoint(signal);
|
|
131
|
+
const platform = deps.platform ?? process.platform;
|
|
132
|
+
nativeLocal = platform === "linux" && !vm && host === "unix:///var/run/docker.sock";
|
|
133
|
+
const supportedVm = platform === "darwin" && vm && host?.startsWith("unix:///");
|
|
134
|
+
return { arch, vm, ...(!nativeLocal && !supportedVm ? { unsupported: true } : {}) };
|
|
135
|
+
},
|
|
136
|
+
async imageArch(ref, signal) {
|
|
137
|
+
const read = await docker(["image", "inspect", "--format", "{{.Architecture}}", ref], { signal });
|
|
138
|
+
return read.code === 0 ? read.stdout.trim() || null : null;
|
|
139
|
+
},
|
|
140
|
+
async pull(ref, signal) {
|
|
141
|
+
const host = await endpoint(signal);
|
|
142
|
+
if (!host?.startsWith("unix:///"))
|
|
143
|
+
return false;
|
|
144
|
+
let config;
|
|
145
|
+
try {
|
|
146
|
+
config = mkdtempSync(join(tmpdir(), "catalyst-pull-"));
|
|
147
|
+
writeFileSync(join(config, "config.json"), JSON.stringify({ auths: {} }), { mode: 0o600 });
|
|
148
|
+
const anonymousEnv = { ...env, DOCKER_CONFIG: config };
|
|
149
|
+
delete anonymousEnv.DOCKER_CONTEXT;
|
|
150
|
+
delete anonymousEnv.DOCKER_AUTH_CONFIG;
|
|
151
|
+
return (await docker(["--host", host, "--config", config, "pull", "--quiet", ref], { env: anonymousEnv, signal, timeoutMs: 900_000 })).code === 0;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (config)
|
|
158
|
+
rmSync(config, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
async network(name, signal) {
|
|
162
|
+
const read = await docker(["network", "inspect", name], { signal });
|
|
163
|
+
const absent = `Error response from daemon: network ${name} not found`;
|
|
164
|
+
if (read.code !== 0 && (read.stdout.trim() === "" || read.stdout.trim() === "[]") && read.stderr?.trim() === absent)
|
|
165
|
+
return "missing";
|
|
166
|
+
if (read.code !== 0 || read.stderr?.trim())
|
|
167
|
+
return "unavailable";
|
|
168
|
+
try {
|
|
169
|
+
const [net] = JSON.parse(read.stdout);
|
|
170
|
+
const options = (net?.Options ?? {});
|
|
171
|
+
const labels = (net?.Labels ?? {});
|
|
172
|
+
return net?.Driver === "bridge" &&
|
|
173
|
+
net.Internal !== true &&
|
|
174
|
+
net.EnableIPv6 !== true &&
|
|
175
|
+
options["com.docker.network.bridge.enable_icc"] === "false" &&
|
|
176
|
+
options["com.docker.network.bridge.name"] === "catalyst-sess0" &&
|
|
177
|
+
labels["dev.catalystcloud.session-network"] === "v1"
|
|
178
|
+
? "ready"
|
|
179
|
+
: "misshaped";
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return "misshaped";
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
async createNetwork(name, signal) {
|
|
186
|
+
const args = [
|
|
187
|
+
"network",
|
|
188
|
+
"create",
|
|
189
|
+
"--driver=bridge",
|
|
190
|
+
"--opt",
|
|
191
|
+
"com.docker.network.bridge.enable_icc=false",
|
|
192
|
+
"--opt",
|
|
193
|
+
"com.docker.network.bridge.name=catalyst-sess0",
|
|
194
|
+
"--label",
|
|
195
|
+
"dev.catalystcloud.session-network=v1",
|
|
196
|
+
name,
|
|
197
|
+
];
|
|
198
|
+
return (await docker(args, { signal })).code === 0;
|
|
199
|
+
},
|
|
200
|
+
async nativeEgressStatus(nowMs, signal) { return nativeLocal && await nativeEgressReady(nowMs, signal); },
|
|
201
|
+
async socketGid() {
|
|
202
|
+
if (!nativeLocal)
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
return statSync(deps.socketPath ?? "/var/run/docker.sock").gid;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async claimDirs(dir, paths, signal) {
|
|
212
|
+
const args = [
|
|
213
|
+
...compose(dir),
|
|
214
|
+
"run",
|
|
215
|
+
"--rm",
|
|
216
|
+
"-T",
|
|
217
|
+
"--no-deps",
|
|
218
|
+
"--user",
|
|
219
|
+
"0",
|
|
220
|
+
// The service drops every capability; root needs CHOWN back to hand over a folder.
|
|
221
|
+
"--cap-add",
|
|
222
|
+
"CHOWN",
|
|
223
|
+
"--entrypoint",
|
|
224
|
+
"chown",
|
|
225
|
+
"supervisor",
|
|
226
|
+
RUNNER_UID,
|
|
227
|
+
...paths,
|
|
228
|
+
];
|
|
229
|
+
return (await docker(args, { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
|
|
230
|
+
},
|
|
231
|
+
async hasVolumeFile(dir, variable, signal) {
|
|
232
|
+
return ((await docker(supervisorShell(dir, `test -s "$${variable}"`), {
|
|
233
|
+
cwd: dir,
|
|
234
|
+
signal,
|
|
235
|
+
timeoutMs: 120_000,
|
|
236
|
+
})).code === 0);
|
|
237
|
+
},
|
|
238
|
+
async enrollment(dir, signal) {
|
|
239
|
+
const script = `(() => { const {readFileSync}=require("node:fs");
|
|
240
|
+
try { const state=JSON.parse(readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE,"utf8"));
|
|
241
|
+
if (state.version!==1 || typeof state.secret!=="string" || !/^[A-Za-z0-9_-]{32,256}$/.test(state.secret)) process.exit(1);
|
|
242
|
+
if (state.enrollment===null) {
|
|
243
|
+
if (!Array.isArray(state.spentJoinTokens) || !state.spentJoinTokens.every(v=>typeof v==="string" && /^[0-9a-f]{64}$/.test(v))) process.exit(1);
|
|
244
|
+
const token=process.env.CATALYST_HOST_JOIN_TOKEN;
|
|
245
|
+
const hash=token ? require("node:crypto").createHash("sha256").update(token).digest("hex") : null;
|
|
246
|
+
process.stdout.write(JSON.stringify({unenrolled:true,tokenSpent:!hash || state.spentJoinTokens.includes(hash)})); return;
|
|
247
|
+
}
|
|
248
|
+
const e=state.enrollment;
|
|
249
|
+
if (!e) process.exit(1);
|
|
250
|
+
process.stdout.write(JSON.stringify({hostId:e.hostId,tenant:e.tenant,team:e.team,enrollmentKind:e.enrollmentKind}));
|
|
251
|
+
} catch { process.exit(1); } })();`;
|
|
252
|
+
const read = await docker([...compose(dir), "run", "--rm", "-T", "--no-deps", "--entrypoint", "bun", "supervisor", "-e", script], { cwd: dir, signal, timeoutMs: 120_000 });
|
|
253
|
+
if (read.code !== 0)
|
|
254
|
+
return null;
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(read.stdout);
|
|
257
|
+
const e = object(parsed);
|
|
258
|
+
if (e?.unenrolled === true && typeof e.tokenSpent === "boolean")
|
|
259
|
+
return { unenrolled: true, tokenSpent: e.tokenSpent };
|
|
260
|
+
return e && [e.hostId, e.tenant, e.team].every((v) => typeof v === "string" && v.length > 0) &&
|
|
261
|
+
(e.enrollmentKind === "self_hosted" || e.enrollmentKind === "dedicated")
|
|
262
|
+
? { hostId: e.hostId, tenant: e.tenant, team: e.team, enrollmentKind: e.enrollmentKind } : null;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
async orgKeyStatus(dir, account, baseUrl, candidate, signal) {
|
|
269
|
+
// Only a status leaves the container. No stored key or provider response is printed.
|
|
270
|
+
const script = `(async () => {
|
|
271
|
+
const {readFileSync,lstatSync}=require("node:fs");
|
|
272
|
+
let input=""; for await (const chunk of process.stdin) input+=chunk;
|
|
273
|
+
let stored="", storedInvalid=false;
|
|
274
|
+
try {
|
|
275
|
+
const info=lstatSync(process.env.CATALYST_ORG_KEY_FILE);
|
|
276
|
+
if (!info.isFile() || (info.mode & 0o077)!==0) storedInvalid=true;
|
|
277
|
+
else stored=readFileSync(process.env.CATALYST_ORG_KEY_FILE,"utf8").trim();
|
|
278
|
+
} catch (error) { if (error.code!=="ENOENT") storedInvalid=true; }
|
|
279
|
+
const proposed=input.trim(), key=proposed||stored;
|
|
280
|
+
const status=(s)=>process.stdout.write(s);
|
|
281
|
+
if (!proposed && storedInvalid) return status("invalid");
|
|
282
|
+
if (!key) return status("missing");
|
|
283
|
+
if (!/^[\x21-\x7e]{16,1024}$/.test(key)) return status("invalid");
|
|
284
|
+
try {
|
|
285
|
+
const response=await fetch(process.argv[1]+"/api/v1/me", {redirect:"error",signal:AbortSignal.timeout(10000),
|
|
286
|
+
headers:{authorization:"Bearer "+key,accept:"application/json"}});
|
|
287
|
+
if (response.status===429 || response.status>=500) return status("unavailable");
|
|
288
|
+
if (!response.ok) return status("invalid");
|
|
289
|
+
const body=await response.json();
|
|
290
|
+
if (body.account!==process.argv[2] || body.principal!=="service" || body.user!==undefined ||
|
|
291
|
+
!Array.isArray(body.permissions) || body.permissions.length!==3 ||
|
|
292
|
+
!["mirror:read","mirror:write","mirror:feed"].every(p=>body.permissions.includes(p)))
|
|
293
|
+
return status("invalid");
|
|
294
|
+
return status(proposed && proposed!==stored ? "different" : "valid");
|
|
295
|
+
} catch { return status("unavailable"); }
|
|
296
|
+
})().catch(()=>process.stdout.write("unavailable"));`;
|
|
297
|
+
const read = await docker([...compose(dir), "run", "--rm", "-T", "--no-deps", "--entrypoint", "bun", "supervisor", "-e", script, baseUrl, account], { cwd: dir, input: candidate ? `${candidate}\n` : undefined, signal, timeoutMs: 120_000 });
|
|
298
|
+
const status = read.stdout.trim();
|
|
299
|
+
return read.code === 0 && ["valid", "different", "missing", "invalid", "unavailable"].includes(status)
|
|
300
|
+
? status : "unavailable";
|
|
301
|
+
},
|
|
302
|
+
async writeVolumeFile(dir, variable, value, signal) {
|
|
303
|
+
if (variable !== "CATALYST_ORG_KEY_FILE")
|
|
304
|
+
return false;
|
|
305
|
+
const script = `umask 077 && tmp=$(mktemp "$${variable}.onboard.XXXXXX") && trap 'rm -f "$tmp"' EXIT && cat > "$tmp" && test -s "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$${variable}"`;
|
|
306
|
+
return ((await docker(supervisorShell(dir, script), {
|
|
307
|
+
cwd: dir,
|
|
308
|
+
input: `${value}\n`,
|
|
309
|
+
signal,
|
|
310
|
+
timeoutMs: 120_000,
|
|
311
|
+
})).code === 0);
|
|
312
|
+
},
|
|
313
|
+
async composeUp(dir, signal) {
|
|
314
|
+
return ((await docker([...compose(dir), "up", "--detach"], { cwd: dir, signal, timeoutMs: 600_000 }))
|
|
315
|
+
.code === 0);
|
|
316
|
+
},
|
|
317
|
+
async composeRunning(dir, signal) {
|
|
318
|
+
const read = await docker([...compose(dir), "ps", "--status", "running", "--services"], { cwd: dir, signal });
|
|
319
|
+
return read.code === 0 && read.stdout.split("\n").some((line) => line.trim() === "supervisor");
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
324
|
+
? value
|
|
325
|
+
: null;
|
|
326
|
+
const waiting = (reason, evidence) => ({ state: "waiting", reason, ...(evidence ? { evidence } : {}) });
|
|
327
|
+
function parseHosts(body) {
|
|
328
|
+
const hosts = object(body)?.hosts;
|
|
329
|
+
if (!Array.isArray(hosts) || hosts.length > 1_000)
|
|
330
|
+
return null;
|
|
331
|
+
const parsed = [];
|
|
332
|
+
for (const value of hosts) {
|
|
333
|
+
const host = object(value);
|
|
334
|
+
const capability = host?.capability === null ? null : object(host?.capability);
|
|
335
|
+
if (!host ||
|
|
336
|
+
typeof host.hostId !== "string" ||
|
|
337
|
+
typeof host.hostName !== "string" ||
|
|
338
|
+
typeof host.team !== "string" ||
|
|
339
|
+
!(host.revokedAtMs === null || typeof host.revokedAtMs === "number") ||
|
|
340
|
+
(host.capability !== null &&
|
|
341
|
+
(!capability ||
|
|
342
|
+
!Number.isSafeInteger(capability.placeableCapacity) ||
|
|
343
|
+
capability.placeableCapacity < 0 ||
|
|
344
|
+
typeof capability.runtimeLive !== "boolean" ||
|
|
345
|
+
!Array.isArray(capability.failingRequired) ||
|
|
346
|
+
!capability.failingRequired.every((id) => typeof id === "string"))))
|
|
347
|
+
return null;
|
|
348
|
+
parsed.push({
|
|
349
|
+
hostId: host.hostId,
|
|
350
|
+
hostName: host.hostName,
|
|
351
|
+
team: host.team,
|
|
352
|
+
revoked: host.revokedAtMs !== null,
|
|
353
|
+
capacity: capability ? (capability.runtimeLive ? capability.placeableCapacity : 0) : null,
|
|
354
|
+
failing: capability ? capability.failingRequired : [],
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return parsed;
|
|
358
|
+
}
|
|
359
|
+
/** `KEY=value` lines; a single-quoted value is unquoted. Anything else is ignored. */
|
|
360
|
+
function readEnvFile(path) {
|
|
361
|
+
let text;
|
|
362
|
+
try {
|
|
363
|
+
text = readFileSync(path, "utf8");
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const values = {};
|
|
369
|
+
for (const line of text.split("\n")) {
|
|
370
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
371
|
+
if (!match)
|
|
372
|
+
continue;
|
|
373
|
+
const raw = match[2];
|
|
374
|
+
values[match[1]] = /^'[^']*'$/.test(raw) ? raw.slice(1, -1) : raw;
|
|
375
|
+
}
|
|
376
|
+
return values;
|
|
377
|
+
}
|
|
378
|
+
function renderEnv(values) {
|
|
379
|
+
return Object.entries(values)
|
|
380
|
+
.map(([key, value]) => {
|
|
381
|
+
if (/[\n\r']/.test(value))
|
|
382
|
+
throw new Error("runner_env_value_invalid");
|
|
383
|
+
return `${key}=${/^[A-Za-z0-9._:/@+=,-]*$/.test(value) ? value : `'${value}'`}\n`;
|
|
384
|
+
})
|
|
385
|
+
.join("");
|
|
386
|
+
}
|
|
387
|
+
function writePrivate(path, text) {
|
|
388
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
389
|
+
writeFileSync(temporary, text, { mode: 0o600 });
|
|
390
|
+
chmodSync(temporary, 0o600);
|
|
391
|
+
renameSync(temporary, path);
|
|
392
|
+
}
|
|
393
|
+
/** Enrollment uses the stored host id and exact team/name. Two machines with one hostname must not
|
|
394
|
+
* share it: a random suffix, kept in `.env` from the first write on. */
|
|
395
|
+
export function runnerDefaultHostName() {
|
|
396
|
+
const suffix = randomBytes(3).toString("hex");
|
|
397
|
+
const base = `catalyst-${hostname().split(".")[0] ?? "host"}`
|
|
398
|
+
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
399
|
+
.slice(0, 55);
|
|
400
|
+
const name = `${base}-${suffix}`;
|
|
401
|
+
return HOST_NAME.test(name) ? name : `catalyst-host-${suffix}`;
|
|
402
|
+
}
|
|
403
|
+
export function onboardRunnerAdapter(input = {}) {
|
|
404
|
+
const engine = input.engine ?? dockerRunnerEngine();
|
|
405
|
+
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
406
|
+
const waitMs = input.waitMs ?? 120_000;
|
|
407
|
+
const pollMs = input.pollMs ?? 5_000;
|
|
408
|
+
// One question per run: the engine checks again after acting, and the receipt row is "running"
|
|
409
|
+
// in between, so the answer lives here too.
|
|
410
|
+
let decided;
|
|
411
|
+
// Only `--runner` or a yes in this run starts or repairs the runner. A yes saved by an earlier run
|
|
412
|
+
// only reports on it, so an unattended check never restarts a runner the person stopped.
|
|
413
|
+
let mayAct = false;
|
|
414
|
+
let acted = false;
|
|
415
|
+
const selected = { selected: true };
|
|
416
|
+
async function decide(journal) {
|
|
417
|
+
if (decided !== undefined)
|
|
418
|
+
return decided;
|
|
419
|
+
if (input.selected !== undefined) {
|
|
420
|
+
mayAct = input.selected;
|
|
421
|
+
return (decided = input.selected);
|
|
422
|
+
}
|
|
423
|
+
const prior = journal.steps.find((step) => step.id === "runner")?.evidence?.selected;
|
|
424
|
+
if (typeof prior === "boolean")
|
|
425
|
+
return (decided = prior);
|
|
426
|
+
if (input.choose) {
|
|
427
|
+
mayAct = (await input.choose()) === true;
|
|
428
|
+
return (decided = mayAct);
|
|
429
|
+
}
|
|
430
|
+
return (decided = null);
|
|
431
|
+
}
|
|
432
|
+
/** Everything both check and act need, verified before any local change. */
|
|
433
|
+
async function prepare(ctx, journal, signal) {
|
|
434
|
+
const choice = await decide(journal);
|
|
435
|
+
if (choice !== true)
|
|
436
|
+
return {
|
|
437
|
+
state: "skipped",
|
|
438
|
+
reason: "runner_not_selected",
|
|
439
|
+
...(choice === false ? { evidence: { selected: false } } : {}),
|
|
440
|
+
};
|
|
441
|
+
const info = await engine.info(signal);
|
|
442
|
+
if (!info)
|
|
443
|
+
return { state: "skipped", reason: "runner_docker_missing", evidence: selected };
|
|
444
|
+
if (info.unsupported)
|
|
445
|
+
return waiting("runner_engine_unsupported", selected);
|
|
446
|
+
const cfg = loadConfig(ctx.home);
|
|
447
|
+
const account = journal.account ?? journal.tenant;
|
|
448
|
+
if (!cfg?.user ||
|
|
449
|
+
!["owner", "admin"].includes(cfg.user.role) ||
|
|
450
|
+
!account ||
|
|
451
|
+
cfg.account !== account ||
|
|
452
|
+
cfg.user.id !== journal.membershipId ||
|
|
453
|
+
!journal.baseUrl ||
|
|
454
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
455
|
+
return waiting("runner_identity_unverified", selected);
|
|
456
|
+
const support = await verifyOnboardRoutes(ctx, journal, [
|
|
457
|
+
{ method: "GET", path: ENROLLMENTS },
|
|
458
|
+
{ method: "POST", path: JOIN_TOKENS },
|
|
459
|
+
{ method: "GET", path: CAPACITY },
|
|
460
|
+
], signal);
|
|
461
|
+
if ("reason" in support)
|
|
462
|
+
return waiting(support.reason, selected);
|
|
463
|
+
const teamId = selectedOnboardTeam(journal);
|
|
464
|
+
const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
|
|
465
|
+
if (!teamId || !teamKey)
|
|
466
|
+
return waiting("runner_context_unverified", selected);
|
|
467
|
+
const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
|
|
468
|
+
if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
|
|
469
|
+
return waiting(runnerSupport.reason, selected);
|
|
470
|
+
const cloud = !("reason" in runnerSupport);
|
|
471
|
+
let state;
|
|
472
|
+
try {
|
|
473
|
+
state = onboardStateRoot(ctx.home, ctx.env);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return waiting("runner_directory_unavailable", selected);
|
|
477
|
+
}
|
|
478
|
+
const dir = join(state, "runner");
|
|
479
|
+
const saved = readEnvFile(join(dir, ".env"));
|
|
480
|
+
const images = {
|
|
481
|
+
supervisor: ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor,
|
|
482
|
+
watchdog: ctx.env.CATALYST_WATCHDOG_IMAGE ?? RUNNER_HOST_IMAGES.watchdog,
|
|
483
|
+
runner: ctx.env.CATALYST_RUNNER_IMAGE ?? saved?.CATALYST_RUNNER_IMAGE ?? RUNNER_HOST_IMAGES.runner,
|
|
484
|
+
};
|
|
485
|
+
if (!Object.values(images).every((ref) => IMAGE_REF.test(ref)))
|
|
486
|
+
return waiting("runner_image_unpinned", selected);
|
|
487
|
+
if (!info.vm && !(await engine.nativeEgressStatus?.(ctx.now().getTime(), signal)))
|
|
488
|
+
return waiting("runner_native_egress_setup_required", selected);
|
|
489
|
+
return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images };
|
|
490
|
+
}
|
|
491
|
+
async function desiredEnv(p, joinToken) {
|
|
492
|
+
const saved = readEnvFile(join(p.dir, ".env")) ?? {};
|
|
493
|
+
const name = saved.CATALYST_HOST_NAME || (input.hostName ?? runnerDefaultHostName)();
|
|
494
|
+
const gid = p.info.vm ? 0 : await engine.socketGid();
|
|
495
|
+
if (!HOST_NAME.test(name) || gid === null)
|
|
496
|
+
return null;
|
|
497
|
+
return {
|
|
498
|
+
DOCKER_SOCKET_PATH: "/var/run/docker.sock",
|
|
499
|
+
DOCKER_SOCKET_GID: String(gid),
|
|
500
|
+
CATALYST_WATCHDOG_IMAGE: p.images.watchdog,
|
|
501
|
+
CATALYST_SUPERVISOR_IMAGE: p.images.supervisor,
|
|
502
|
+
CATALYST_MIRROR_URL: p.baseUrl,
|
|
503
|
+
CATALYST_HOST_NAME: name,
|
|
504
|
+
CATALYST_HOST_JOIN_TOKEN: joinToken ?? saved.CATALYST_HOST_JOIN_TOKEN ?? "",
|
|
505
|
+
CATALYST_RUNNER_IMAGE: p.images.runner,
|
|
506
|
+
CATALYST_SLOTS_DIR: join(p.dir, "slots"),
|
|
507
|
+
CATALYST_THOUGHTS_DIR: join(p.dir, "thoughts"),
|
|
508
|
+
CATALYST_POOL_LOCK_DIR: join(p.dir, "locks"),
|
|
509
|
+
CATALYST_SESSION_EGRESS_DIR: p.info.vm ? join(p.dir, "session-egress") : NATIVE_EGRESS_DIR,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/** The step owns its own keys only; a person's tuning (CATALYST_SLOTS and the rest) is kept. */
|
|
513
|
+
const merged = (saved, want) => ({
|
|
514
|
+
...want,
|
|
515
|
+
...Object.fromEntries(Object.entries(saved ?? {}).filter(([key]) => !(key in want))),
|
|
516
|
+
});
|
|
517
|
+
async function listHosts(ctx, p, signal) {
|
|
518
|
+
const read = await readExistingOnboardJson(ctx, `${ENROLLMENTS}?account=${encodeURIComponent(p.account)}`, signal);
|
|
519
|
+
return "reason" in read ? null : parseHosts(read.body);
|
|
520
|
+
}
|
|
521
|
+
async function mintJoinToken(ctx, p, hostName, signal) {
|
|
522
|
+
const cfg = loadConfig(ctx.home);
|
|
523
|
+
const expiry = cfg?.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
|
|
524
|
+
const bearer = cfg?.key ||
|
|
525
|
+
(cfg?.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
|
|
526
|
+
if (!bearer)
|
|
527
|
+
return waiting("runner_login_refresh_required", selected);
|
|
528
|
+
try {
|
|
529
|
+
const response = await ctx.fetch(`${p.baseUrl}${JOIN_TOKENS}?account=${encodeURIComponent(p.account)}`, {
|
|
530
|
+
method: "POST",
|
|
531
|
+
redirect: "error",
|
|
532
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
533
|
+
headers: {
|
|
534
|
+
authorization: `Bearer ${bearer}`,
|
|
535
|
+
"content-type": "application/json",
|
|
536
|
+
accept: "application/json",
|
|
537
|
+
},
|
|
538
|
+
body: JSON.stringify({
|
|
539
|
+
version: 1,
|
|
540
|
+
team: p.teamKey,
|
|
541
|
+
enrollmentKind: "self_hosted",
|
|
542
|
+
hostName,
|
|
543
|
+
ttlMs: 3_600_000,
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
const body = object(await response.json().catch(() => null));
|
|
547
|
+
if (body?.refusal === "not_tenant_admin")
|
|
548
|
+
return waiting("runner_identity_unverified", selected);
|
|
549
|
+
if (response.status !== 201 || body?.ok !== true || typeof body.joinToken !== "string" || !JOIN_TOKEN.test(body.joinToken))
|
|
550
|
+
return waiting("runner_join_token_unavailable", selected);
|
|
551
|
+
return body.joinToken;
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return waiting(signal?.aborted ? "interrupted" : "runner_join_token_unavailable", selected);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function orgKeyFromFile(ctx) {
|
|
558
|
+
const path = ctx.env.CATALYST_RUNNER_ORG_KEY_FILE;
|
|
559
|
+
if (!path)
|
|
560
|
+
return undefined;
|
|
561
|
+
try {
|
|
562
|
+
if (!lstatSync(path).isFile())
|
|
563
|
+
return null;
|
|
564
|
+
const lines = readFileSync(path, "utf8").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
565
|
+
return lines.length === 1 && /^[\x21-\x7e]{16,1024}$/.test(lines[0]) ? lines[0] : null;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
check: async (ctx, journal, signal) => {
|
|
573
|
+
// Only the check right after this run's own act waits for the host to appear.
|
|
574
|
+
const afterAct = acted;
|
|
575
|
+
acted = false;
|
|
576
|
+
const p = await prepare(ctx, journal, signal);
|
|
577
|
+
if (!("dir" in p))
|
|
578
|
+
return p;
|
|
579
|
+
const saved = readEnvFile(join(p.dir, ".env"));
|
|
580
|
+
const want = await desiredEnv(p);
|
|
581
|
+
if (!want)
|
|
582
|
+
return waiting("runner_docker_socket_unreadable", selected);
|
|
583
|
+
const name = want.CATALYST_HOST_NAME;
|
|
584
|
+
let enrolled = null;
|
|
585
|
+
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.hostName === name && host.team === p.teamKey && !host.revoked);
|
|
586
|
+
let hosts = await listHosts(ctx, p, signal);
|
|
587
|
+
if (!hosts)
|
|
588
|
+
return waiting("runner_enrollment_unavailable", selected);
|
|
589
|
+
if (hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
|
|
590
|
+
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
591
|
+
const running = await engine.composeRunning(p.dir, signal);
|
|
592
|
+
const changed = !saved || Object.entries(want).some(([key, value]) => saved[key] !== value);
|
|
593
|
+
const hostEvidence = { ...selected, hostName: name };
|
|
594
|
+
const pending = () => mayAct ? { state: "pending" } : waiting("runner_needs_runner_flag", hostEvidence);
|
|
595
|
+
if (!running || changed)
|
|
596
|
+
return afterAct ? waiting("runner_compose_not_running", hostEvidence) : pending();
|
|
597
|
+
// The supervisor enrolls on its first start and advertises shortly after.
|
|
598
|
+
for (let attempt = 1;; attempt++) {
|
|
599
|
+
const credential = await engine.enrollment(p.dir, signal);
|
|
600
|
+
enrolled = isUnenrolled(credential) ? null : credential;
|
|
601
|
+
// Enrollment can complete while the container metadata read is running. The cloud
|
|
602
|
+
// snapshot must follow that read before a missing credential ID can be called stale.
|
|
603
|
+
hosts = await listHosts(ctx, p, signal);
|
|
604
|
+
if (!hosts)
|
|
605
|
+
return waiting("runner_enrollment_unavailable", hostEvidence);
|
|
606
|
+
if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
|
|
607
|
+
return waiting("runner_identity_unverified", hostEvidence);
|
|
608
|
+
if (enrolled && enrolled.team !== p.teamKey)
|
|
609
|
+
return waiting("runner_enrolled_for_other_team", hostEvidence);
|
|
610
|
+
if (enrolled && !hosts.some((host) => host.hostId === enrolled.hostId && host.hostName === name && !host.revoked))
|
|
611
|
+
return waiting("runner_enrollment_stale", hostEvidence);
|
|
612
|
+
const host = live(hosts);
|
|
613
|
+
if (host && host.capacity !== null)
|
|
614
|
+
break;
|
|
615
|
+
if (!host && !afterAct)
|
|
616
|
+
return pending();
|
|
617
|
+
if (attempt >= Math.max(1, Math.ceil(waitMs / pollMs)) || signal?.aborted)
|
|
618
|
+
return waiting(host ? "runner_capability_pending" : "runner_enrollment_unverified", hostEvidence);
|
|
619
|
+
await sleep(pollMs);
|
|
620
|
+
}
|
|
621
|
+
const host = live(hosts);
|
|
622
|
+
const evidence = { ...hostEvidence, hostId: host.hostId, capacity: host.capacity };
|
|
623
|
+
const orgKey = orgKeyFromFile(ctx);
|
|
624
|
+
if (orgKey === null)
|
|
625
|
+
return waiting("runner_org_key_file_invalid", evidence);
|
|
626
|
+
const keyStatus = await engine.orgKeyStatus(p.dir, p.account, p.baseUrl, orgKey, signal);
|
|
627
|
+
if (keyStatus === "different")
|
|
628
|
+
return !afterAct ? pending() : waiting("runner_org_key_write_failed", evidence);
|
|
629
|
+
if (keyStatus === "missing" && p.cloud && mayAct && !afterAct)
|
|
630
|
+
return pending();
|
|
631
|
+
if (keyStatus !== "valid")
|
|
632
|
+
return waiting(keyStatus === "missing" ? "runner_org_key_missing" : keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", evidence);
|
|
633
|
+
if (host.capacity === 0 || host.failing.length > 0)
|
|
634
|
+
return waiting("runner_host_not_ready", { ...evidence, failing: host.failing.join(",") });
|
|
635
|
+
if (p.cloud) {
|
|
636
|
+
const admission = await runnerAdmission(ctx, journal, p.teamId, false, signal);
|
|
637
|
+
if ("reason" in admission)
|
|
638
|
+
return waiting(admission.reason, evidence);
|
|
639
|
+
if (!admission.ready)
|
|
640
|
+
return mayAct && !afterAct ? pending() : waiting("runner_admission_disabled", evidence);
|
|
641
|
+
}
|
|
642
|
+
const admission = await onboardTeamAdmission(ctx, p.teamKey, signal);
|
|
643
|
+
if (admission !== true)
|
|
644
|
+
return waiting(admission === false ? "runner_admission_operator" : "runner_admission_unverified", evidence);
|
|
645
|
+
input.message?.(`This machine runs Catalyst's work as host ${name}, with ${host.capacity} slots free.`);
|
|
646
|
+
return { state: "done", evidence };
|
|
647
|
+
},
|
|
648
|
+
act: async (ctx, journal, signal) => {
|
|
649
|
+
const p = await prepare(ctx, journal, signal);
|
|
650
|
+
if (!("dir" in p))
|
|
651
|
+
return p;
|
|
652
|
+
if (!mayAct)
|
|
653
|
+
return waiting("runner_needs_runner_flag", selected);
|
|
654
|
+
try {
|
|
655
|
+
mkdirSync(p.dir, { recursive: true, mode: 0o700 });
|
|
656
|
+
if (lstatSync(p.dir).isSymbolicLink())
|
|
657
|
+
throw new Error("runner_directory_unsafe");
|
|
658
|
+
chmodSync(p.dir, 0o700);
|
|
659
|
+
for (const sub of DIRS) {
|
|
660
|
+
mkdirSync(join(p.dir, sub), { recursive: true, mode: 0o700 });
|
|
661
|
+
// On native Linux the runner uid owns these after the first run; leave them alone.
|
|
662
|
+
if (statSync(join(p.dir, sub)).uid === process.getuid?.())
|
|
663
|
+
chmodSync(join(p.dir, sub), 0o700);
|
|
664
|
+
}
|
|
665
|
+
// Mounted read-only into the supervisor (uid 10001), which reads the egress attestation.
|
|
666
|
+
mkdirSync(join(p.dir, "session-egress"), { recursive: true, mode: 0o755 });
|
|
667
|
+
const compose = readFileSync(join(packageRoot(), "vendor", "self-host", "compose.yaml"));
|
|
668
|
+
let current = null;
|
|
669
|
+
try {
|
|
670
|
+
current = readFileSync(join(p.dir, "compose.yaml"));
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
/* first run */
|
|
674
|
+
}
|
|
675
|
+
if (!current?.equals(compose))
|
|
676
|
+
writeFileSync(join(p.dir, "compose.yaml"), compose, { mode: 0o644 });
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
680
|
+
}
|
|
681
|
+
let want = await desiredEnv(p);
|
|
682
|
+
if (!want)
|
|
683
|
+
return waiting("runner_docker_socket_unreadable", selected);
|
|
684
|
+
const writeEnv = (values) => {
|
|
685
|
+
try {
|
|
686
|
+
const saved = readEnvFile(join(p.dir, ".env"));
|
|
687
|
+
if (saved && Object.entries(values).every(([key, value]) => saved[key] === value))
|
|
688
|
+
return true;
|
|
689
|
+
writePrivate(join(p.dir, ".env"), renderEnv(merged(saved, values)));
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
if (!writeEnv(want))
|
|
697
|
+
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
698
|
+
for (const ref of Object.values(p.images)) {
|
|
699
|
+
if (!(await engine.imageArch(ref, signal))) {
|
|
700
|
+
await engine.pull(ref, signal);
|
|
701
|
+
}
|
|
702
|
+
const arch = await engine.imageArch(ref, signal);
|
|
703
|
+
if (!arch)
|
|
704
|
+
return waiting("runner_images_unavailable", { ...selected, image: ref });
|
|
705
|
+
if (arch !== p.info.arch)
|
|
706
|
+
return waiting("runner_image_emulated", { ...selected, image: ref });
|
|
707
|
+
}
|
|
708
|
+
const network = await engine.network(RUNNER_SESSION_NETWORK, signal);
|
|
709
|
+
if (network === "misshaped")
|
|
710
|
+
return waiting("runner_session_network_misshaped", selected);
|
|
711
|
+
if (network === "unavailable")
|
|
712
|
+
return waiting("runner_session_network_unavailable", selected);
|
|
713
|
+
if (network === "missing" && !(await engine.createNetwork(RUNNER_SESSION_NETWORK, signal)))
|
|
714
|
+
return { state: "failed", reason: "runner_session_network_failed", evidence: selected };
|
|
715
|
+
if (!p.info.vm && !(await engine.claimDirs(p.dir, DIRS.map((sub) => join(p.dir, sub)), signal)))
|
|
716
|
+
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
717
|
+
const name = want.CATALYST_HOST_NAME;
|
|
718
|
+
const hasCredential = await engine.hasVolumeFile(p.dir, "CATALYST_HOST_CREDENTIAL_FILE", signal);
|
|
719
|
+
const credential = hasCredential ? await engine.enrollment(p.dir, signal) : null;
|
|
720
|
+
const enrolled = isUnenrolled(credential) ? null : credential;
|
|
721
|
+
const hosts = await listHosts(ctx, p, signal);
|
|
722
|
+
if (!hosts)
|
|
723
|
+
return waiting("runner_enrollment_unavailable", selected);
|
|
724
|
+
if (hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
|
|
725
|
+
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
726
|
+
if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
|
|
727
|
+
return waiting("runner_identity_unverified", { ...selected, hostName: name });
|
|
728
|
+
if (enrolled && enrolled.team !== p.teamKey)
|
|
729
|
+
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
730
|
+
if (hasCredential && !isUnenrolled(credential) && (!enrolled || !hosts.some((host) => host.hostId === enrolled.hostId && host.hostName === name && host.team === p.teamKey && !host.revoked)))
|
|
731
|
+
return waiting("runner_enrollment_stale", { ...selected, hostName: name });
|
|
732
|
+
// A lost volume also lost the secret that redeemed the old token. Mint a new token and match
|
|
733
|
+
// the new host id from the supervisor's credential, never an old advertisement with this name.
|
|
734
|
+
// A lost reply must retry the same token and retained secret: a new token would be
|
|
735
|
+
// refused as identity_invalid after the cloud committed that secret's enrollment.
|
|
736
|
+
// Only a token known refused/spent (or missing) can be replaced for an unenrolled file.
|
|
737
|
+
if (!hasCredential || (isUnenrolled(credential) && credential.tokenSpent)) {
|
|
738
|
+
const token = await mintJoinToken(ctx, p, name, signal);
|
|
739
|
+
if (typeof token !== "string")
|
|
740
|
+
return token;
|
|
741
|
+
want = (await desiredEnv(p, token));
|
|
742
|
+
if (!writeEnv(want))
|
|
743
|
+
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
744
|
+
}
|
|
745
|
+
const orgKey = orgKeyFromFile(ctx);
|
|
746
|
+
if (orgKey === null)
|
|
747
|
+
return waiting("runner_org_key_file_invalid", selected);
|
|
748
|
+
let keyStatus = await engine.orgKeyStatus(p.dir, p.account, p.baseUrl, orgKey, signal);
|
|
749
|
+
if (keyStatus === "invalid" || keyStatus === "unavailable")
|
|
750
|
+
return waiting(keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", selected);
|
|
751
|
+
if (orgKey && keyStatus === "different")
|
|
752
|
+
if (!(await engine.writeVolumeFile(p.dir, "CATALYST_ORG_KEY_FILE", orgKey, signal)))
|
|
753
|
+
return { state: "failed", reason: "runner_org_key_write_failed", evidence: selected };
|
|
754
|
+
if (keyStatus === "missing" && p.cloud) {
|
|
755
|
+
const issued = await issueRunnerOrgKey(ctx, journal, { dir: p.dir, teamId: p.teamId, hostName: name }, value => engine.writeVolumeFile(p.dir, "CATALYST_ORG_KEY_FILE", value, signal), signal);
|
|
756
|
+
if ("reason" in issued)
|
|
757
|
+
return waiting(issued.reason, selected);
|
|
758
|
+
keyStatus = await engine.orgKeyStatus(p.dir, p.account, p.baseUrl, undefined, signal);
|
|
759
|
+
if (keyStatus !== "valid")
|
|
760
|
+
return waiting(keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", selected);
|
|
761
|
+
}
|
|
762
|
+
if (p.cloud) {
|
|
763
|
+
const read = await runnerAdmission(ctx, journal, p.teamId, false, signal);
|
|
764
|
+
if ("reason" in read)
|
|
765
|
+
return waiting(read.reason, selected);
|
|
766
|
+
if (!read.ready) {
|
|
767
|
+
const enabled = await runnerAdmission(ctx, journal, p.teamId, true, signal);
|
|
768
|
+
if ("reason" in enabled)
|
|
769
|
+
return waiting(enabled.reason, selected);
|
|
770
|
+
if (!enabled.ready)
|
|
771
|
+
return waiting("runner_admission_unverified", selected);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (!(await engine.composeUp(p.dir, signal)))
|
|
775
|
+
return { state: "failed", reason: "runner_compose_failed", evidence: selected };
|
|
776
|
+
acted = true;
|
|
777
|
+
return { state: "done" };
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}
|