viber-channel 0.8.6 → 0.8.8
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/lib/bridge_core.ts +95 -6
- package/lib/dm_stream.ts +45 -0
- package/lib/fingerprint.ts +11 -0
- package/lib/heartbeat.ts +31 -4
- package/lib/messages.ts +2 -1
- package/lib/runner_enroll.ts +210 -0
- package/lib/runner_exec.ts +436 -0
- package/lib/runner_stream.ts +259 -0
- package/lib/urls.ts +33 -0
- package/package.json +1 -1
- package/viber-channel.ts +57 -1
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner_exec.ts — local execution of a claimed spawn command (#460 Phase 2,
|
|
3
|
+
* step-09/#474). Replaces the step-07 reconcile() stub.
|
|
4
|
+
*
|
|
5
|
+
* Flow (per claimed command): validate the SERVER-VALIDATED role snapshot
|
|
6
|
+
* against the machine's local DEFAULT-DENY policy (permissions = template ∩
|
|
7
|
+
* local, never widened — C3) → report `starting` → shell
|
|
8
|
+
* `vibe-master team spawn` via an argv ARRAY (execFile, never a shell string —
|
|
9
|
+
* no injection) → report `succeeded`/`failed` (fencing-guarded). A 409 report
|
|
10
|
+
* (re-claim/cancel) → cooperative abort (don't overwrite a newer outcome — C4).
|
|
11
|
+
*
|
|
12
|
+
* Dependency-injected: `runTeamSpawn` (the launcher) and `apiFetch` are
|
|
13
|
+
* injectable so the flow is unit-testable without shelling or a live server.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
21
|
+
|
|
22
|
+
/** A validated role from the server snapshot (allowlisted shape). */
|
|
23
|
+
export interface SnapshotRole {
|
|
24
|
+
role: string;
|
|
25
|
+
runtime: string;
|
|
26
|
+
permission: string;
|
|
27
|
+
rolePrompt?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
count?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaimedCommand {
|
|
33
|
+
id: string;
|
|
34
|
+
fencing_token: number;
|
|
35
|
+
template_name: string;
|
|
36
|
+
template_spec: { roles: SnapshotRole[] };
|
|
37
|
+
prefix: string;
|
|
38
|
+
team_name: string | null;
|
|
39
|
+
env: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunnerAuthLite {
|
|
43
|
+
base_url: string;
|
|
44
|
+
runner_id: string;
|
|
45
|
+
runner_token: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Local default-deny policy (`.viber/runner-policy.json`, optional). The machine
|
|
50
|
+
* can only RESTRICT what a template asks for, never widen it. Conservative
|
|
51
|
+
* defaults when the file is absent.
|
|
52
|
+
*/
|
|
53
|
+
export interface RunnerPolicy {
|
|
54
|
+
allowedRuntimes: string[];
|
|
55
|
+
/** Ceiling permission the machine will grant. */
|
|
56
|
+
maxPermission: "read-only" | "read-write";
|
|
57
|
+
maxRoles: number;
|
|
58
|
+
allowedEnvs: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_POLICY: RunnerPolicy = {
|
|
62
|
+
allowedRuntimes: ["claude-code", "codex", "gemma", "claude"],
|
|
63
|
+
maxPermission: "read-write",
|
|
64
|
+
maxRoles: 8,
|
|
65
|
+
allowedEnvs: ["staging", "dev"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PERMISSION_RANK: Record<string, number> = { "read-only": 1, "read-write": 2 };
|
|
69
|
+
|
|
70
|
+
// prefix/team_name become CLI argv. execFile already blocks SHELL injection, but
|
|
71
|
+
// an unconstrained value like "--env" would be an ARGV-FLAG injection into
|
|
72
|
+
// vibe-master. The server validates this too; the runner re-checks (defense in
|
|
73
|
+
// depth) and REFUSES rather than pass a suspicious argv.
|
|
74
|
+
const ARGV_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,31}$/;
|
|
75
|
+
|
|
76
|
+
/** Thrown when a policy file is PRESENT but malformed — the caller must REFUSE
|
|
77
|
+
* execution, never fall back to the permissive baseline (Codex review P1-4). */
|
|
78
|
+
export class RunnerPolicyInvalidError extends Error {}
|
|
79
|
+
|
|
80
|
+
function isStringArray(v: unknown): v is string[] {
|
|
81
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load the local policy. ABSENT file → the baseline (an operator who wrote no
|
|
86
|
+
* policy accepts the default). PRESENT but malformed (bad JSON / bad shape) →
|
|
87
|
+
* THROW: it is NOT fail-closed to silently grant read-write when the operator
|
|
88
|
+
* clearly intended to constrain but fat-fingered the file (Codex review P1-4).
|
|
89
|
+
*/
|
|
90
|
+
export function loadRunnerPolicy(cwd: string = process.cwd()): RunnerPolicy {
|
|
91
|
+
const path = join(cwd, ".viber", "runner-policy.json");
|
|
92
|
+
if (!existsSync(path)) return DEFAULT_POLICY;
|
|
93
|
+
let raw: unknown;
|
|
94
|
+
try {
|
|
95
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
96
|
+
} catch {
|
|
97
|
+
throw new RunnerPolicyInvalidError("runner-policy.json is not valid JSON");
|
|
98
|
+
}
|
|
99
|
+
if (typeof raw !== "object" || raw === null) {
|
|
100
|
+
throw new RunnerPolicyInvalidError("runner-policy.json must be an object");
|
|
101
|
+
}
|
|
102
|
+
const p = raw as Record<string, unknown>;
|
|
103
|
+
if (!isStringArray(p.allowedRuntimes)) throw new RunnerPolicyInvalidError("allowedRuntimes must be string[]");
|
|
104
|
+
if (p.maxPermission !== "read-only" && p.maxPermission !== "read-write") {
|
|
105
|
+
throw new RunnerPolicyInvalidError("maxPermission must be read-only|read-write");
|
|
106
|
+
}
|
|
107
|
+
if (typeof p.maxRoles !== "number" || !Number.isInteger(p.maxRoles) || p.maxRoles < 1) {
|
|
108
|
+
throw new RunnerPolicyInvalidError("maxRoles must be a positive integer");
|
|
109
|
+
}
|
|
110
|
+
if (!isStringArray(p.allowedEnvs)) throw new RunnerPolicyInvalidError("allowedEnvs must be string[]");
|
|
111
|
+
return {
|
|
112
|
+
allowedRuntimes: p.allowedRuntimes,
|
|
113
|
+
maxPermission: p.maxPermission,
|
|
114
|
+
maxRoles: p.maxRoles,
|
|
115
|
+
allowedEnvs: p.allowedEnvs,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Validate the server snapshot against the local policy. Returns null if OK, or
|
|
120
|
+
* a human reason if the command must be REFUSED (default-deny). */
|
|
121
|
+
export function validateAgainstPolicy(cmd: ClaimedCommand, policy: RunnerPolicy): string | null {
|
|
122
|
+
const roles = cmd.template_spec?.roles;
|
|
123
|
+
if (!Array.isArray(roles) || roles.length === 0) return "template has no roles";
|
|
124
|
+
// Cap on the TOTAL agent count, not just distinct roles (Codex): a role may
|
|
125
|
+
// carry a `count`, so sum(count ?? 1) is the real agent count. A bad count
|
|
126
|
+
// (non-positive-int) is itself a refusal.
|
|
127
|
+
let totalAgents = 0;
|
|
128
|
+
for (const r of roles) {
|
|
129
|
+
const n = r.count ?? 1;
|
|
130
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return `invalid role count for '${r.role}'`;
|
|
131
|
+
totalAgents += n;
|
|
132
|
+
}
|
|
133
|
+
if (totalAgents > policy.maxRoles) return `too many agents (${totalAgents} > ${policy.maxRoles})`;
|
|
134
|
+
if (cmd.env && !policy.allowedEnvs.includes(cmd.env)) return `env '${cmd.env}' not allowed locally`;
|
|
135
|
+
// Argv-injection guard (defense in depth): prefix/team_name must be plain names,
|
|
136
|
+
// never something that could be read as a flag by vibe-master.
|
|
137
|
+
if (!ARGV_NAME_RE.test(cmd.prefix)) return `invalid prefix '${cmd.prefix}'`;
|
|
138
|
+
if (cmd.team_name !== null && !ARGV_NAME_RE.test(cmd.team_name)) return `invalid team_name '${cmd.team_name}'`;
|
|
139
|
+
const cap = PERMISSION_RANK[policy.maxPermission] ?? 1;
|
|
140
|
+
for (const r of roles) {
|
|
141
|
+
if (!policy.allowedRuntimes.includes(r.runtime)) return `runtime '${r.runtime}' not allowed locally`;
|
|
142
|
+
const want = PERMISSION_RANK[r.permission];
|
|
143
|
+
if (want === undefined) return `unknown permission '${r.permission}'`;
|
|
144
|
+
if (want > cap) return `permission '${r.permission}' exceeds local ceiling '${policy.maxPermission}'`;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type TeamSpawnRunner = (
|
|
150
|
+
args: string[],
|
|
151
|
+
) => Promise<{ ok: boolean; recap: string }>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build a CURATED child environment (Codex review, blocking): `execFile` would
|
|
155
|
+
* otherwise inherit ALL of process.env, exposing every local secret (GitHub,
|
|
156
|
+
* cloud, unrelated tokens) to the child. Pass only OS essentials + the Viber/CF
|
|
157
|
+
* vars vibe-master actually needs to spawn agents. Nothing else is inherited.
|
|
158
|
+
*/
|
|
159
|
+
// EXACT-NAME allowlist (Codex review — NOT a prefix sweep). Only OS essentials
|
|
160
|
+
// plus the specific Viber/CF vars vibe-master needs to spawn agents. A generic
|
|
161
|
+
// `VIBER_*`/`CF_ACCESS_*` sweep could pass an unrelated or overly-sensitive
|
|
162
|
+
// secret through to child agents; naming each var keeps exactly what's exposed
|
|
163
|
+
// visible and reviewable. (Extend this list — never a prefix — if step-11 live
|
|
164
|
+
// QA shows vibe-master needs another var.)
|
|
165
|
+
const CHILD_ENV_ALLOW = [
|
|
166
|
+
// OS essentials
|
|
167
|
+
"PATH", "Path", "HOME", "USERPROFILE", "SYSTEMROOT", "windir", "TEMP", "TMP", "TMPDIR", "LANG", "APPDATA", "LOCALAPPDATA", "ComSpec",
|
|
168
|
+
// Viber/CF vars vibe-master requires to reach the backend + CF-Access tunnel
|
|
169
|
+
"VIBER_BASE_URL", "VIBER_AUTH_FILE", "VIBER_CF_CLIENT_ID", "VIBER_CF_CLIENT_SECRET",
|
|
170
|
+
"CF_ACCESS_CLIENT_ID", "CF_ACCESS_CLIENT_SECRET",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export function buildChildEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
174
|
+
const env: NodeJS.ProcessEnv = {};
|
|
175
|
+
for (const k of CHILD_ENV_ALLOW) {
|
|
176
|
+
const v = source[k];
|
|
177
|
+
if (v !== undefined) env[k] = v;
|
|
178
|
+
}
|
|
179
|
+
return env;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Redact long secret-like runs (hex/base64/token) from captured output before
|
|
183
|
+
* it is reported to the server (Codex — the CLI/children may print secrets). */
|
|
184
|
+
export function redactSecrets(text: string): string {
|
|
185
|
+
return text
|
|
186
|
+
.replace(/\b[0-9a-f]{32,}\b/gi, "[redacted-hex]")
|
|
187
|
+
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, "[redacted-token]");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The base command that launches vibe-master. Defaults to the installed
|
|
191
|
+
* `vibe-master` on PATH (production). `VIBE_MASTER_CMD` overrides it as a
|
|
192
|
+
* space-separated token list so a DEV runner can point at the source
|
|
193
|
+
* (`VIBE_MASTER_CMD="bun /path/vibe-master.ts"`) instead of a stale global
|
|
194
|
+
* binary — the #460 dev-QA foot-gun ("dev must run from source"). Passed to
|
|
195
|
+
* execFile as (bin, [...baseArgs, ...args]) — still an argv array, no shell. */
|
|
196
|
+
export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): string[] {
|
|
197
|
+
const raw = source.VIBE_MASTER_CMD?.trim();
|
|
198
|
+
const parts = raw ? raw.split(/\s+/) : ["vibe-master"];
|
|
199
|
+
return parts.length > 0 ? parts : ["vibe-master"];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Default launcher: shell vibe-master via execFile with an ARGV ARRAY (never a
|
|
203
|
+
* shell string), a CURATED env, bounded time + output, and process-tree kill on
|
|
204
|
+
* timeout. The binary is resolved via VIBE_MASTER_CMD (default `vibe-master`). */
|
|
205
|
+
export const execFileTeamSpawn: TeamSpawnRunner = (args) => {
|
|
206
|
+
const base = vibeMasterBaseCmd();
|
|
207
|
+
const bin = base[0] as string;
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
execFile(
|
|
210
|
+
bin,
|
|
211
|
+
[...base.slice(1), ...args],
|
|
212
|
+
{ timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
|
|
213
|
+
(err, stdout, stderr) => {
|
|
214
|
+
const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
|
|
215
|
+
resolve({ ok: !err, recap });
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Local idempotence by command_id (Codex review, blocking): fencing protects
|
|
223
|
+
* REPORTS, not the local SIDE EFFECT. If the runner crashes mid-spawn and a new
|
|
224
|
+
* daemon re-claims the same command, it must NOT relaunch (double team). A
|
|
225
|
+
* durable marker written BEFORE launch records "this command_id was started
|
|
226
|
+
* here"; a re-claim seeing the marker refuses to relaunch. (Full reconciliation
|
|
227
|
+
* — finding+adopting the already-spawned team — is a documented follow-up.)
|
|
228
|
+
*/
|
|
229
|
+
export interface InflightStore {
|
|
230
|
+
has(commandId: string): boolean;
|
|
231
|
+
mark(commandId: string): void;
|
|
232
|
+
clear(commandId: string): void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function fsInflightStore(cwd: string = process.cwd()): InflightStore {
|
|
236
|
+
const dir = join(cwd, ".viber", "runner-inflight");
|
|
237
|
+
const path = (id: string) => join(dir, `${id}.json`);
|
|
238
|
+
return {
|
|
239
|
+
has: (id) => existsSync(path(id)),
|
|
240
|
+
mark: (id) => {
|
|
241
|
+
mkdirSync(dir, { recursive: true });
|
|
242
|
+
writeFileSync(path(id), JSON.stringify({ started: true }), { mode: 0o600 });
|
|
243
|
+
},
|
|
244
|
+
clear: (id) => rmSync(path(id), { force: true }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Materialize the server-validated snapshot to a temp file the CLI reads via
|
|
249
|
+
* `--spec-file`, returning its path + a cleanup. Exclusive create (`wx`, 0600),
|
|
250
|
+
* unpredictable name — so vibe-master runs EXACTLY the authorized roles, never a
|
|
251
|
+
* name-resolved local template (P1-1). */
|
|
252
|
+
export interface SpecFileWriter {
|
|
253
|
+
(spec: { name: string; roles: SnapshotRole[] }): { path: string; cleanup: () => void };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function fsWriteSpec(cwd: string = process.cwd()): SpecFileWriter {
|
|
257
|
+
return (spec) => {
|
|
258
|
+
const dir = join(cwd, ".viber", "runner-specs");
|
|
259
|
+
mkdirSync(dir, { recursive: true });
|
|
260
|
+
const path = join(dir, `${randomBytes(16).toString("hex")}.json`);
|
|
261
|
+
// `wx` = exclusive create (fail if it somehow exists) + owner-only perms.
|
|
262
|
+
writeFileSync(path, JSON.stringify(spec), { flag: "wx", mode: 0o600 });
|
|
263
|
+
return { path, cleanup: () => rmSync(path, { force: true }) };
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface ExecDeps {
|
|
268
|
+
runTeamSpawn?: TeamSpawnRunner;
|
|
269
|
+
policy?: RunnerPolicy;
|
|
270
|
+
apiFetch?: typeof fetch;
|
|
271
|
+
inflight?: InflightStore;
|
|
272
|
+
/** Injectable spec-file writer (tests). */
|
|
273
|
+
writeSpec?: SpecFileWriter;
|
|
274
|
+
/** Injectable local-log writer (tests). Returns the log path. */
|
|
275
|
+
writeLog?: (commandId: string, recap: string) => string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Write the full launch recap to a 0600 LOCAL log (never sent to the server —
|
|
279
|
+
* it may hold secrets). Returns the path recorded in the generic remote report.
|
|
280
|
+
* Best-effort: a write failure returns a placeholder, never blocks the report. */
|
|
281
|
+
export function writeRunnerLog(commandId: string, recap: string, cwd: string = process.cwd()): string {
|
|
282
|
+
const dir = join(cwd, ".viber", "runner-logs");
|
|
283
|
+
const path = join(dir, `${commandId}.log`);
|
|
284
|
+
try {
|
|
285
|
+
mkdirSync(dir, { recursive: true });
|
|
286
|
+
writeFileSync(path, recap, { mode: 0o600 });
|
|
287
|
+
} catch {
|
|
288
|
+
return "(local log unavailable)";
|
|
289
|
+
}
|
|
290
|
+
return path;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function report(
|
|
294
|
+
auth: RunnerAuthLite,
|
|
295
|
+
cmdId: string,
|
|
296
|
+
body: Record<string, unknown>,
|
|
297
|
+
fetchImpl: typeof fetch,
|
|
298
|
+
): Promise<number> {
|
|
299
|
+
const resp = await fetchImpl(`${auth.base_url}/api/runners/${auth.runner_id}/commands/${cmdId}/report`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
redirect: "error",
|
|
302
|
+
headers: {
|
|
303
|
+
Authorization: `Bearer ${auth.runner_token}`,
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
...cfAccessHeaders(),
|
|
306
|
+
},
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
});
|
|
309
|
+
return resp.status;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Execute one already-claimed command. Validates against local policy, reports
|
|
314
|
+
* starting → runs the team spawn → reports the outcome (fencing-guarded). A 409
|
|
315
|
+
* on the `starting` report means the claim is no longer ours (re-claim/cancel)
|
|
316
|
+
* → cooperative abort BEFORE spawning (never launch on a lost claim). Returns
|
|
317
|
+
* the terminal status the runner recorded (or "aborted").
|
|
318
|
+
*/
|
|
319
|
+
export async function executeClaim(
|
|
320
|
+
auth: RunnerAuthLite,
|
|
321
|
+
cmd: ClaimedCommand,
|
|
322
|
+
deps: ExecDeps = {},
|
|
323
|
+
): Promise<"succeeded" | "failed" | "aborted"> {
|
|
324
|
+
const runTeamSpawn = deps.runTeamSpawn ?? execFileTeamSpawn;
|
|
325
|
+
const fetchImpl = deps.apiFetch ?? fetch;
|
|
326
|
+
const inflight = deps.inflight ?? fsInflightStore();
|
|
327
|
+
|
|
328
|
+
// P1-4: a PRESENT-but-invalid local policy REFUSES execution (never the
|
|
329
|
+
// permissive baseline). An absent policy resolves to the baseline in loader.
|
|
330
|
+
let policy: RunnerPolicy;
|
|
331
|
+
try {
|
|
332
|
+
policy = deps.policy ?? loadRunnerPolicy();
|
|
333
|
+
} catch (err) {
|
|
334
|
+
const reason = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
|
|
335
|
+
await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: `local policy invalid: ${reason}` }, fetchImpl);
|
|
336
|
+
return "failed";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const refusal = validateAgainstPolicy(cmd, policy);
|
|
340
|
+
if (refusal) {
|
|
341
|
+
await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: `local policy: ${refusal}` }, fetchImpl);
|
|
342
|
+
return "failed";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Local idempotence (C4): a marker for this command_id means a PRIOR run
|
|
346
|
+
// already started it here (crashed mid-spawn, now re-claimed). Do NOT relaunch.
|
|
347
|
+
// Keep the marker unless the failed report is ACCEPTED (2xx) — else a later
|
|
348
|
+
// re-claim must still see it (P1-2).
|
|
349
|
+
if (inflight.has(cmd.id)) {
|
|
350
|
+
const s = await report(
|
|
351
|
+
auth,
|
|
352
|
+
cmd.id,
|
|
353
|
+
{ fencing_token: cmd.fencing_token, status: "failed", error: "prior partial run detected on this runner — not relaunched" },
|
|
354
|
+
fetchImpl,
|
|
355
|
+
);
|
|
356
|
+
if (s >= 200 && s < 300) inflight.clear(cmd.id);
|
|
357
|
+
return "failed";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// template_name is the positional <name> in argv: positive allowlist (no
|
|
361
|
+
// leading '-', no control char) — argv-flag-injection defense.
|
|
362
|
+
if (!/^[A-Za-z0-9_.][A-Za-z0-9 _.-]{0,63}$/.test(cmd.template_name)) {
|
|
363
|
+
await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: "unsafe template_name" }, fetchImpl);
|
|
364
|
+
return "failed";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Report starting FIRST. Launch ONLY on a 2xx accept (P1-3): 409 = claim lost
|
|
368
|
+
// (re-claim/cancel), ANY other status (401/403/500/network) = abort with NO
|
|
369
|
+
// side effect. Never spawn on a claim we don't provably hold.
|
|
370
|
+
const startingStatus = await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "starting" }, fetchImpl);
|
|
371
|
+
if (startingStatus < 200 || startingStatus >= 300) return "aborted";
|
|
372
|
+
|
|
373
|
+
// P1-1: execute EXACTLY the server-validated snapshot. Serialize the already-
|
|
374
|
+
// validated roles to a temp spec file and invoke `team spawn --spec-file` — so
|
|
375
|
+
// vibe-master runs THOSE roles, never a name-resolved local template. Serialize
|
|
376
|
+
// the validated object verbatim (no role rebuild). Cleaned up in `finally`.
|
|
377
|
+
const spec = { name: cmd.template_name, roles: cmd.template_spec.roles };
|
|
378
|
+
const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
|
|
379
|
+
try {
|
|
380
|
+
// Allowlisted argv (C3): fixed flags + validated values, no free string.
|
|
381
|
+
const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix];
|
|
382
|
+
if (cmd.team_name) args.push("--team", cmd.team_name);
|
|
383
|
+
if (cmd.env) args.push("--env", cmd.env);
|
|
384
|
+
|
|
385
|
+
inflight.mark(cmd.id); // durable "started here" BEFORE launch (C4)
|
|
386
|
+
const { ok, recap } = await runTeamSpawn(args);
|
|
387
|
+
|
|
388
|
+
// Generic remote report; full recap to a 0600 LOCAL log only (no secret leak).
|
|
389
|
+
const logPath = deps.writeLog ? deps.writeLog(cmd.id, recap) : writeRunnerLog(cmd.id, recap);
|
|
390
|
+
const finalStatus = await report(
|
|
391
|
+
auth,
|
|
392
|
+
cmd.id,
|
|
393
|
+
ok
|
|
394
|
+
? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
|
|
395
|
+
: { fencing_token: cmd.fencing_token, status: "failed", error: "team spawn failed — see local runner log" },
|
|
396
|
+
fetchImpl,
|
|
397
|
+
);
|
|
398
|
+
// Clear the inflight marker ONLY when the TERMINAL report is accepted (2xx)
|
|
399
|
+
// (P1-2): on 409 (newer holder took over) or a network/other failure, KEEP
|
|
400
|
+
// the marker so a re-claim doesn't relaunch a command that already ran here.
|
|
401
|
+
if (finalStatus >= 200 && finalStatus < 300) {
|
|
402
|
+
inflight.clear(cmd.id);
|
|
403
|
+
return ok ? "succeeded" : "failed";
|
|
404
|
+
}
|
|
405
|
+
return "aborted"; // report not accepted (409/other) — marker kept for reconcile
|
|
406
|
+
} finally {
|
|
407
|
+
cleanup(); // remove the temp spec (may hold sensitive rolePrompts)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One reconcile pass: list claimable commands, claim the first, execute it.
|
|
413
|
+
* Returns the number of commands handled (0 if none). One command per pass —
|
|
414
|
+
* the daemon's coalescing + the lease keep it serial.
|
|
415
|
+
*/
|
|
416
|
+
export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}): Promise<number> {
|
|
417
|
+
const fetchImpl = deps.apiFetch ?? fetch;
|
|
418
|
+
const listResp = await fetchImpl(`${auth.base_url}/api/runners/${auth.runner_id}/commands`, {
|
|
419
|
+
method: "GET",
|
|
420
|
+
redirect: "error",
|
|
421
|
+
headers: { Authorization: `Bearer ${auth.runner_token}`, ...cfAccessHeaders() },
|
|
422
|
+
});
|
|
423
|
+
if (!listResp.ok) return 0;
|
|
424
|
+
const { commands } = (await listResp.json()) as { commands: { id: string }[] };
|
|
425
|
+
if (!commands.length) return 0;
|
|
426
|
+
|
|
427
|
+
const first = commands[0];
|
|
428
|
+
const claimResp = await fetchImpl(
|
|
429
|
+
`${auth.base_url}/api/runners/${auth.runner_id}/commands/${first.id}/claim`,
|
|
430
|
+
{ method: "POST", redirect: "error", headers: { Authorization: `Bearer ${auth.runner_token}`, ...cfAccessHeaders() } },
|
|
431
|
+
);
|
|
432
|
+
if (claimResp.status !== 200) return 0; // lost the claim — another pass will retry
|
|
433
|
+
const claimed = (await claimResp.json()) as ClaimedCommand;
|
|
434
|
+
await executeClaim(auth, claimed, deps);
|
|
435
|
+
return 1;
|
|
436
|
+
}
|