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.
@@ -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
+ }