viber-channel 0.8.7 → 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.
@@ -46,6 +46,21 @@ export interface ConversationMessage {
46
46
  sender_instance_id?: string | null;
47
47
  }
48
48
 
49
+ /**
50
+ * Persistent cross-run dedup watermark for ONE conversation (#429, mirror of
51
+ * `DmWatermark` in dm_stream.ts). Holds the highest message id already DELIVERED
52
+ * (replied to) in a prior run of this conversation's stream. The caller keeps one
53
+ * per `convId` and passes it back on every re-join, so it SURVIVES the
54
+ * abort+restart cycle `runConversationStream` does on a re-pushed join — unlike
55
+ * the run-scoped `handledIds` (fresh + empty each run). Without it the first
56
+ * await-invite pass re-forwards (re-replies to) the most recent trigger on every
57
+ * re-spawn/rejoin. Mutated in place. `0` = nothing delivered yet.
58
+ */
59
+ export interface BridgeWatermark {
60
+ /** Highest delivered (replied-to) message id (0 = nothing delivered yet). */
61
+ lastId: number;
62
+ }
63
+
49
64
  /** One live conversation stream's mutable runtime state (token rotates). */
50
65
  export interface ConversationRuntime {
51
66
  id: string;
@@ -333,6 +348,32 @@ export function decideTurnPostAction(opts: {
333
348
  return "auto-post";
334
349
  }
335
350
 
351
+ /**
352
+ * #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
353
+ * True when this id was already DELIVERED in a prior run and must be skipped on a
354
+ * re-join. The D1 message id is `INTEGER PRIMARY KEY AUTOINCREMENT` (migration
355
+ * 0009) → strictly increasing, never reused, so anything at or below the watermark
356
+ * was already handled. `Number.isSafeInteger` (not `isFinite`) gate: an id beyond
357
+ * 2^53 — where `Number()` loses precision and could collide — falls through to
358
+ * normal handling instead of being mis-gated. No watermark / absent / NaN id →
359
+ * never skips. Pure + exported for unit tests (pattern `decideTurnPostAction`).
360
+ */
361
+ export function watermarkShouldSkip(numId: number, watermark?: BridgeWatermark): boolean {
362
+ return !!watermark && Number.isSafeInteger(numId) && numId <= watermark.lastId;
363
+ }
364
+
365
+ /**
366
+ * #429 advance the cross-run watermark after a SUCCESSFUL delivery (mirror of
367
+ * dm_stream L166-168). Monotone: only ever moves forward, so a brief overlap of
368
+ * two streams during supersession can't regress it. Same `Number.isSafeInteger`
369
+ * gate; a NaN/absent/unsafe id is never watermarked. Pure + exported.
370
+ */
371
+ export function watermarkAdvance(numId: number, watermark?: BridgeWatermark): void {
372
+ if (watermark && Number.isSafeInteger(numId) && numId > watermark.lastId) {
373
+ watermark.lastId = numId;
374
+ }
375
+ }
376
+
336
377
  /** Bearer + fingerprint + CF Access headers for a conversation request. */
337
378
  function conversationHeaders(conversationToken: string, fingerprint: string): Record<string, string> {
338
379
  return {
@@ -378,6 +419,13 @@ export interface ConversationStreamDeps {
378
419
  options: SseLoopOptions;
379
420
  /** Registry of live streams (keyed by conversation id) — re-join supersedes. */
380
421
  activeStreams: Map<string, AbortController>;
422
+ /**
423
+ * Registry of persistent per-conversation watermarks (#429). Keyed by
424
+ * conversation id. UNLIKE `activeStreams`, this MUST survive the abort+restart
425
+ * on re-join (never deleted in the stream's finally) — that persistence is the
426
+ * whole point of the cross-run dedup.
427
+ */
428
+ watermarks: Map<string, BridgeWatermark>;
381
429
  /** Global shutdown signal — aborting it stops every stream. */
382
430
  parentSignal: AbortSignal;
383
431
  baseUrl: string;
@@ -405,7 +453,7 @@ export async function runConversationStream(
405
453
  minted: ConversationMintResponse,
406
454
  deps: ConversationStreamDeps,
407
455
  ): Promise<void> {
408
- const { fingerprint, instanceKey, options, activeStreams, parentSignal, baseUrl, lockDir, logPrefix, sessionTag, makeRunTurn } = deps;
456
+ const { fingerprint, instanceKey, options, activeStreams, watermarks, parentSignal, baseUrl, lockDir, logPrefix, sessionTag, makeRunTurn } = deps;
409
457
  const convId = minted.conversation_id;
410
458
  if (parentSignal.aborted) return;
411
459
 
@@ -443,7 +491,15 @@ export async function runConversationStream(
443
491
  });
444
492
  const runTurn = await makeRunTurn(convId, runtime, ctrl.signal);
445
493
  process.stderr.write(`${logPrefix} stream ready for ${convId}\n`);
446
- await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, baseUrl, logPrefix, runTurn });
494
+ // Get-or-create the persistent watermark for this conversation. It lives in
495
+ // the caller-owned `watermarks` registry so it SURVIVES this stream's
496
+ // abort+restart on re-join (the finally below deliberately does NOT delete it).
497
+ let watermark = watermarks.get(convId);
498
+ if (!watermark) {
499
+ watermark = { lastId: 0 };
500
+ watermarks.set(convId, watermark);
501
+ }
502
+ await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, baseUrl, logPrefix, runTurn, watermark });
447
503
  } finally {
448
504
  parentSignal.removeEventListener("abort", onParentAbort);
449
505
  runtime.scheduler?.cancel();
@@ -484,8 +540,21 @@ export async function sseLoop(opts: {
484
540
  baseUrl: string;
485
541
  logPrefix: string;
486
542
  runTurn: RunTurn;
543
+ /**
544
+ * Persistent cross-run watermark for this conversation (#429). When present,
545
+ * a re-join skips re-replying to any message id already delivered in a prior
546
+ * run. Unused in step-01 (plumbing only); the guard/advance lands in step-02.
547
+ * `runConversationStream` always passes it; a direct test caller may omit it.
548
+ */
549
+ watermark?: BridgeWatermark;
550
+ /** Injectable for tests; defaults to global fetch (mirror dm_stream). */
551
+ fetchImpl?: typeof fetch;
552
+ /** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
553
+ fetchMessagesImpl?: typeof fetchMessages;
487
554
  }): Promise<void> {
488
- const { minted, runtime, fingerprint, ownInstanceKey, options, signal, baseUrl, logPrefix, runTurn } = opts;
555
+ const { minted, runtime, fingerprint, ownInstanceKey, options, signal, baseUrl, logPrefix, runTurn, watermark } = opts;
556
+ const fetchImpl = opts.fetchImpl ?? fetch;
557
+ const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
489
558
  const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
490
559
  const ownPostedIds = new Set<string>();
491
560
 
@@ -563,7 +632,7 @@ export async function sseLoop(opts: {
563
632
  if (!idStr) return;
564
633
  if (ackedIds.has(idStr) || ackInFlightIds.has(idStr)) return;
565
634
  ackInFlightIds.add(idStr);
566
- void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal)
635
+ void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal, fetchImpl)
567
636
  .then((ok) => {
568
637
  if (ok) ackedIds.add(idStr);
569
638
  else process.stderr.write(`${logPrefix} ack failed for message ${idStr} (will retry on reconnect)\n`);
@@ -573,10 +642,18 @@ export async function sseLoop(opts: {
573
642
 
574
643
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
575
644
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
645
+ // TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
646
+ // Number("") === 0, so a blank id never satisfies the `<= lastId` guard.
647
+ const numId = idStr ? Number(idStr) : Number.NaN;
576
648
  // Confirm receipt to the web INDEPENDENTLY of handled/reply state, BEFORE the
577
649
  // handledIds early-return — so a previously-failed ACK is retried when this
578
650
  // message comes back through catch-up on reconnect (codex step-02 review).
579
651
  ackInboundReceipt(msg);
652
+ // #429 cross-run dedup: a re-join re-delivers the trigger; skip replying to
653
+ // anything already delivered in a prior run. Placed AFTER ackInboundReceipt
654
+ // so the #346 read-ACK still fires on a re-delivered trigger, only the reply
655
+ // is suppressed (mirror of dm_stream; handledIds still gates the intra-run dup).
656
+ if (watermarkShouldSkip(numId, watermark)) return false;
580
657
  if (idStr && handledIds.has(idStr)) return false;
581
658
  if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
582
659
  if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
@@ -590,6 +667,12 @@ export async function sseLoop(opts: {
590
667
  try {
591
668
  const aborted = await replyForTurn(msg.content!, msg);
592
669
  if (aborted) return false;
670
+ // #429 advance ONLY after a non-aborted reply (delivered — incl.
671
+ // suppress-outbound and skip-empty). INSIDE the try, after the abort
672
+ // check, never in catch/finally: a thrown reply (caught below) is a
673
+ // failed delivery → NO advance, so it retries on the next re-join
674
+ // (at-least-once, same asymmetry as dm_stream's watermark).
675
+ watermarkAdvance(numId, watermark);
593
676
  } catch (err) {
594
677
  if (err instanceof BridgeShutdownError) throw err;
595
678
  process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
@@ -607,7 +690,7 @@ export async function sseLoop(opts: {
607
690
  // is the SSE/voice host (the CF tunnel in staging/prod). Using ws_url here
608
691
  // 404s whenever the SSE host differs from the API host (masked in dev where
609
692
  // they collapse to one origin). The SSE subscription still uses ws_url.
610
- msgs = await fetchMessages(baseUrl, minted.conversation_id, runtime.token);
693
+ msgs = await fetchMessagesImpl(baseUrl, minted.conversation_id, runtime.token);
611
694
  } catch (err) {
612
695
  if (err instanceof ConversationTokenExpiredError) throw err;
613
696
  process.stderr.write(`${logPrefix} catch-up fetch failed: ${safeErrorMessage(err)}\n`);
@@ -660,7 +743,7 @@ export async function sseLoop(opts: {
660
743
  requestHeaders.Accept = "text/event-stream";
661
744
  process.stderr.write(`${logPrefix} connecting to SSE: ${sseUrl}\n`);
662
745
 
663
- const resp = await fetch(sseUrl, { headers: requestHeaders, signal });
746
+ const resp = await fetchImpl(sseUrl, { headers: requestHeaders, signal });
664
747
  if (resp.status === 401) throw new ConversationTokenExpiredError();
665
748
  if (!resp.ok || !resp.body) throw new Error(`SSE connection failed: HTTP ${resp.status}`);
666
749
 
@@ -983,6 +1066,11 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
983
1066
  let bridgeLock: BridgeLock | null = null;
984
1067
  let controlStream: PersistentControlStream | null = null;
985
1068
  const activeStreams = new Map<string, AbortController>();
1069
+ // Persistent per-conversation watermarks (#429). Lives for the WHOLE bridge run
1070
+ // (never cleared per-stream) so a conversation's watermark survives the
1071
+ // abort+restart on re-join. Bounded growth: one `{lastId}` per convId seen —
1072
+ // acceptable (consensus reviewers, step-01 invariant).
1073
+ const watermarks = new Map<string, BridgeWatermark>();
986
1074
  const streamTasks = new Set<Promise<void>>();
987
1075
  let voiceBaseUrl = "";
988
1076
  let instanceKey = "";
@@ -998,6 +1086,7 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
998
1086
  instanceKey,
999
1087
  options,
1000
1088
  activeStreams,
1089
+ watermarks,
1001
1090
  parentSignal: shutdown.signal,
1002
1091
  baseUrl,
1003
1092
  lockDir,
@@ -45,3 +45,14 @@ export function clientFingerprint(folderPath: string): string {
45
45
  const absPath = normalizePath(realpathSync(folderPath) as string);
46
46
  return createHash("sha256").update(`${machineId}:${absPath}`).digest("hex").slice(0, 32);
47
47
  }
48
+
49
+ /**
50
+ * Per-MACHINE fingerprint (#460 Phase 2, step-07) — the runner is a machine-level
51
+ * principal, so unlike clientFingerprint it is folder-INDEPENDENT. 32 hex chars,
52
+ * matching the server's bound format (`/^[0-9a-zA-Z_-]{8,128}$/`). Deterministic:
53
+ * re-enrolling the same machine yields the same fingerprint (→ atomic rotation).
54
+ */
55
+ export function machineFingerprint(): string {
56
+ const machineId = getMachineId();
57
+ return createHash("sha256").update(`runner:${machineId}`).digest("hex").slice(0, 32);
58
+ }
package/lib/heartbeat.ts CHANGED
@@ -15,7 +15,32 @@
15
15
 
16
16
  import { type HeartbeatResult, sendInstanceHeartbeat } from "./control_stream.ts";
17
17
 
18
- export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
18
+ /**
19
+ * #478 — 25s → 60s to cut CF Worker requests (the instance heartbeat was ~59% of
20
+ * all Worker traffic, the single dominant source). Every beat is one
21
+ * `POST /api/instances/:id/heartbeat` = one Worker request per agent, forever.
22
+ *
23
+ * The three server-side windows this must stay under were raised in the same
24
+ * change so a 60s cadence keeps the SAME safety margins it had at 25s:
25
+ * - `VIBER_INSTANCE_PROBE_SUSPECT` (voice_pipeline.py) 8s → 75s. This is the
26
+ * one that actually caused the observed ~10-13s effective cadence: the
27
+ * sweeper probed (`beat_now`) every agent whose liveness was older than 8s,
28
+ * i.e. every 15s sweep, and each probe forces an EXTRA beat on top of the
29
+ * timer. With SUSPECT > the beat interval, a healthy agent is never probed
30
+ * and the timer is the only beat. Cost: a CRASHED agent is force-offlined in
31
+ * ~90-105s instead of ~13s — both probe stages are quantized to the 15s
32
+ * sweep, so arming AND deadline-enforcement each cost up to one sweep on top
33
+ * of SUSPECT + PROBE_TIMEOUT (Codex review of #478).
34
+ * - `VIBER_INSTANCE_CONTROL_PRESENCE_TIMEOUT` 90s → 150s, so a single missed
35
+ * beat still cannot force an agent offline (150 > 2 x 60).
36
+ * - `LEASE_TTL_SECONDS` (viber-api) 90s → 180s, same reason for the #400 lease.
37
+ *
38
+ * NB do NOT "de-dupe" the `beat_now` answer client-side: an unanswered probe
39
+ * force-offlines the agent at its deadline (`_enforce_instance_probes` requires
40
+ * a beat at-or-after `requested_at`), so skipping it would false-offline a live
41
+ * agent. The probe is fixed by not suspecting healthy agents, not by ignoring it.
42
+ */
43
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
19
44
 
20
45
  /**
21
46
  * #400 (recadrage) — consecutive INDETERMINATE lease beats tolerated before the
@@ -26,9 +51,11 @@ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
26
51
  export const DEFAULT_MAX_INDETERMINATE_LEASE_BEATS = 3;
27
52
 
28
53
  /**
29
- * Resolve the beat interval from the environment (seconds), defaulting to 25s.
30
- * Must stay comfortably under the server timeout (VIBER_CHANNEL_PRESENCE_TIMEOUT,
31
- * default 90s) so a single missed beat never trips the sweeper.
54
+ * Resolve the beat interval from the environment (seconds), defaulting to 60s.
55
+ * Must stay under HALF the server presence timeout
56
+ * (VIBER_INSTANCE_CONTROL_PRESENCE_TIMEOUT, default 150s since #478) so a single
57
+ * missed beat never trips the sweeper, and under VIBER_INSTANCE_PROBE_SUSPECT
58
+ * (default 75s) so a healthy agent is never `beat_now`-probed.
32
59
  */
33
60
  export function resolveHeartbeatIntervalMs(
34
61
  env: Record<string, string | undefined> = process.env,
package/lib/messages.ts CHANGED
@@ -112,10 +112,11 @@ export async function ackMessage(
112
112
  conversationToken: string,
113
113
  messageId: string | number,
114
114
  signal?: AbortSignal,
115
+ fetchImpl: typeof fetch = fetch,
115
116
  ): Promise<boolean> {
116
117
  const url = `${baseUrl}/api/conversations/${conversationId}/messages/${messageId}/ack`;
117
118
  try {
118
- const resp = await fetch(url, {
119
+ const resp = await fetchImpl(url, {
119
120
  method: "POST",
120
121
  headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
121
122
  signal,
@@ -0,0 +1,210 @@
1
+ /**
2
+ * runner_enroll.ts — client side of the runner enrollment ceremony (#460 Phase 2,
3
+ * step-07/#472). Turns an enrollment URL into `.viber/runner.json` so the machine
4
+ * can run the resident spawn daemon.
5
+ *
6
+ * Invoked as:
7
+ * bunx viber-channel enroll-runner "https://viber.dgypx.dev/connect/runner/<claim_id>#<wait_token>"
8
+ *
9
+ * The owner opens the enrollment in the web (JWT-owner, C2), which yields the
10
+ * URL above carrying the claim_id (path) + the wait_token (fragment — a secret
11
+ * that gates the CAS bind so a leaked confirm-link can't be pre-bound). The
12
+ * client here:
13
+ * 1. generates its OWN runner_token (kept local, never sent — only its SHA-256);
14
+ * 2. binds (fingerprint + token hash) via the wait_token;
15
+ * 3. prints the confirm URL for the human to approve the shown fingerprint;
16
+ * 4. polls consume until the runner is materialized;
17
+ * 5. writes `.viber/runner.json` (0600) with the raw token.
18
+ */
19
+
20
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { createHash, randomBytes } from "node:crypto";
22
+ import { dirname, join } from "node:path";
23
+ import { machineFingerprint } from "./fingerprint.js";
24
+ import { cfAccessHeaders } from "./cfAccess.js";
25
+ import { isTrustedViberOrigin } from "./urls.js";
26
+
27
+ const POLL_INTERVAL_MS = 2000;
28
+ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
29
+
30
+ export interface ParsedEnrollUrl {
31
+ baseUrl: string;
32
+ claimId: string;
33
+ waitToken: string;
34
+ }
35
+
36
+ const CLAIM_ID_RE = /^[a-f0-9]{16,64}$/i; // server mints randomHex(16) = 32 hex
37
+ const WAIT_TOKEN_RE = /^[a-f0-9]{64}$/; // server mints randomHex(32) = 64 hex
38
+
39
+ /**
40
+ * Parse + HARDEN an enrollment URL (Codex review — adversarial inputs).
41
+ * Uses the WHATWG `URL` parser (canonicalizes `%2F`, `\`, `//`, ports) then:
42
+ * - rejects userinfo (`user:pass@host` — no credential smuggling);
43
+ * - requires https (or explicit localhost for dev);
44
+ * - accepts EXACTLY `/connect/runner/<claim_id>` (claim id format-checked);
45
+ * - takes the wait_token ONLY from the fragment, format-checked (64 hex).
46
+ * The fragment never reaches the server/logs (OAuth-implicit pattern); there is
47
+ * no query-param fallback on purpose (a `?wait_token=` would be logged).
48
+ */
49
+ export function parseEnrollUrl(input: string): ParsedEnrollUrl {
50
+ let url: URL;
51
+ try {
52
+ url = new URL(input);
53
+ } catch {
54
+ throw new Error(`Invalid URL: ${input}`);
55
+ }
56
+ if (url.username || url.password) {
57
+ throw new Error("Enrollment URL must not contain credentials (userinfo).");
58
+ }
59
+ // The origin must be a KNOWN Viber host (or configured VIBER_BASE_URL / dev
60
+ // localhost). Rejects a forged `https://evil.example/...` that would otherwise
61
+ // capture the runner bearer (Codex review — https alone is not enough).
62
+ if (!isTrustedViberOrigin(url.origin)) {
63
+ throw new Error(`Untrusted enrollment origin: ${url.origin}. Expected a Viber host.`);
64
+ }
65
+ // Canonical pathname match (URL already decoded %2F etc. — a smuggled
66
+ // `/connect/runner/x%2F..` would NOT match this exact shape).
67
+ const match = url.pathname.match(/^\/connect\/runner\/([^/]+)\/?$/);
68
+ if (!match || !CLAIM_ID_RE.test(match[1])) {
69
+ throw new Error(
70
+ "Not a runner enrollment URL. Expected: https://<host>/connect/runner/<claim_id>#<wait_token>",
71
+ );
72
+ }
73
+ const waitToken = url.hash.replace(/^#/, "");
74
+ if (!WAIT_TOKEN_RE.test(waitToken)) {
75
+ throw new Error("Enrollment URL is missing/invalid wait_token fragment (#<64-hex>).");
76
+ }
77
+ return { baseUrl: url.origin, claimId: match[1], waitToken };
78
+ }
79
+
80
+ function sha256Hex(input: string): string {
81
+ return createHash("sha256").update(input).digest("hex");
82
+ }
83
+
84
+ function sleep(ms: number): Promise<void> {
85
+ return new Promise((resolve) => setTimeout(resolve, ms));
86
+ }
87
+
88
+ function writeRunnerJson(
89
+ cwd: string,
90
+ data: { baseUrl: string; runnerId: string; runnerToken: string; machineFingerprint: string },
91
+ ): string {
92
+ const viberDir = join(cwd, ".viber");
93
+ mkdirSync(viberDir, { recursive: true });
94
+ const runnerPath = join(viberDir, "runner.json");
95
+ mkdirSync(dirname(runnerPath), { recursive: true });
96
+ const payload = {
97
+ schema_version: 1,
98
+ base_url: data.baseUrl,
99
+ runner_id: data.runnerId,
100
+ // The bearer — a local-code-execution capability. Stored client-side (the
101
+ // server keeps only its SHA-256). We request owner-only perms below, but note
102
+ // the real protection differs by OS (see chmod).
103
+ runner_token: data.runnerToken,
104
+ machine_fingerprint: data.machineFingerprint,
105
+ };
106
+ writeFileSync(runnerPath, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
107
+ // Honesty (Opus review P1): `mode 0o600` + chmod are POSIX bits — effective on
108
+ // Linux/macOS (owner-read-only). On WINDOWS (JP's primary platform) NTFS uses
109
+ // ACLs, not POSIX bits, so this is essentially a no-op: the file inherits the
110
+ // user profile's ACL. That is the actual protection on Windows — not a claimed
111
+ // 0600. A stricter explicit ACL (icacls) is a documented follow-up.
112
+ try {
113
+ chmodSync(runnerPath, 0o600);
114
+ } catch {
115
+ /* best-effort — Windows / some filesystems reject chmod */
116
+ }
117
+
118
+ // Ensure `.viber/` is git-ignored so runner.json (the cleartext bearer) is not
119
+ // committed on a fresh external repo (mirrors connect.ts — Opus review P1).
120
+ const gitignorePath = join(cwd, ".gitignore");
121
+ let current = "";
122
+ if (existsSync(gitignorePath)) current = readFileSync(gitignorePath, "utf-8");
123
+ const alreadyIgnored = current
124
+ .split(/\r?\n/)
125
+ .some((line) => {
126
+ const t = line.trim();
127
+ return t === ".viber/" || t === ".viber";
128
+ });
129
+ if (!alreadyIgnored) {
130
+ const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
131
+ appendFileSync(gitignorePath, `${prefix}.viber/\n`);
132
+ }
133
+ return runnerPath;
134
+ }
135
+
136
+ /**
137
+ * Run the enrollment ceremony. Writes `.viber/runner.json` on success; throws on
138
+ * any terminal outcome (rejected/superseded/expired/timeout).
139
+ */
140
+ export async function runRunnerEnroll(
141
+ enrollUrl: string,
142
+ cwd: string = process.cwd(),
143
+ label?: string,
144
+ ): Promise<void> {
145
+ const { baseUrl, claimId, waitToken } = parseEnrollUrl(enrollUrl);
146
+ const fingerprint = machineFingerprint();
147
+
148
+ // Client-generated bearer — kept here, only its hash leaves the machine.
149
+ const runnerToken = randomBytes(32).toString("hex");
150
+ const tokenHash = sha256Hex(runnerToken);
151
+
152
+ process.stderr.write(`[viber-channel] runner fingerprint=${fingerprint.slice(0, 8)}…\n`);
153
+
154
+ const bindResp = await fetch(`${baseUrl}/api/connect/runner/${claimId}/bind`, {
155
+ method: "POST",
156
+ redirect: "error", // parity with the daemon — never send the secret across a redirect
157
+ headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
158
+ body: JSON.stringify({
159
+ wait_token: waitToken,
160
+ machine_fingerprint: fingerprint,
161
+ proposed_runner_token_hash: tokenHash,
162
+ ...(label ? { label } : {}),
163
+ }),
164
+ });
165
+ if (!bindResp.ok) {
166
+ const body = await bindResp.text().catch(() => "");
167
+ throw new Error(`Bind failed (HTTP ${bindResp.status}): ${body}`);
168
+ }
169
+
170
+ process.stdout.write(
171
+ `\nApprove this machine in your browser (confirm the shown fingerprint):\n` +
172
+ ` ${baseUrl}/runners/enroll/${claimId}\n\n` +
173
+ `Polling for confirmation (up to 10 minutes)…\n`,
174
+ );
175
+
176
+ const consumeUrl = `${baseUrl}/api/connect/runner/${claimId}/consume`;
177
+ const deadline = Date.now() + MAX_POLL_DURATION_MS;
178
+ while (Date.now() < deadline) {
179
+ await sleep(POLL_INTERVAL_MS);
180
+ const resp = await fetch(consumeUrl, {
181
+ method: "POST",
182
+ redirect: "error",
183
+ headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
184
+ body: JSON.stringify({ wait_token: waitToken }),
185
+ });
186
+ if (resp.status === 200) {
187
+ const data = (await resp.json().catch(() => ({}))) as { runner_id?: string };
188
+ if (!data.runner_id || typeof data.runner_id !== "string") {
189
+ throw new Error("Consume succeeded but returned no runner_id — server response malformed.");
190
+ }
191
+ const path = writeRunnerJson(cwd, {
192
+ baseUrl,
193
+ runnerId: data.runner_id,
194
+ runnerToken,
195
+ machineFingerprint: fingerprint,
196
+ });
197
+ process.stdout.write(
198
+ `\n✓ Runner enrolled (id ${data.runner_id.slice(0, 8)}…). Wrote ${path}\n\n` +
199
+ `Start the runner daemon with:\n bunx viber-channel run-runner\n\n`,
200
+ );
201
+ return;
202
+ }
203
+ if (resp.status === 425) continue; // not confirmed yet — keep polling
204
+ if (resp.status === 409) throw new Error("Superseded by a newer enrollment — re-enroll this machine.");
205
+ if (resp.status === 401) throw new Error("Invalid wait token — the enrollment URL is wrong or stale.");
206
+ const body = await resp.text().catch(() => "");
207
+ throw new Error(`Consume failed (HTTP ${resp.status}): ${body}`);
208
+ }
209
+ throw new Error(`Enrollment timed out after ${MAX_POLL_DURATION_MS / 60_000} minutes.`);
210
+ }
@@ -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
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * runner_stream.ts — the resident runner daemon loop (#460 Phase 2, step-07/#472).
3
+ *
4
+ * Holds a persistent SSE control stream to the server (wake channel, ADR C6) and,
5
+ * CRUCIALLY, treats that stream as a WAKE hint only — never the path of progress:
6
+ * - it reconcile-pulls the durable command table UNCONDITIONALLY on every
7
+ * (re)connection AND on a bounded periodic timer (with jitter), so a lost
8
+ * wake-poke (server restart, reconnect) only adds latency, never blocks;
9
+ * - the D1 command table is the source of truth (step-08), not the SSE event.
10
+ *
11
+ * step-07 SCOPE: this is the SKELETON — it establishes the stream, heartbeat and
12
+ * reconcile CADENCE. The actual command claim + local execution land in step-08
13
+ * (durable `spawn_commands`) and step-09 (local `runTeamLaunches`); here
14
+ * `reconcile()` is a no-op stub that logs "0 pending".
15
+ */
16
+
17
+ import { readFileSync, rmSync } from "node:fs";
18
+ import { randomBytes } from "node:crypto";
19
+ import { join } from "node:path";
20
+ import { cfAccessHeaders } from "./cfAccess.js";
21
+ import { isTrustedViberOrigin } from "./urls.js";
22
+ import { reconcileOnce } from "./runner_exec.js";
23
+
24
+ /** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
25
+ * at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
26
+ * inflight markers are NOT cleaned (they must survive a restart by design). */
27
+ function cleanStaleSpecFiles(cwd: string = process.cwd()): void {
28
+ try {
29
+ rmSync(join(cwd, ".viber", "runner-specs"), { recursive: true, force: true });
30
+ } catch {
31
+ /* best-effort — a leftover spec is harmless if this fails */
32
+ }
33
+ }
34
+
35
+ interface RunnerAuth {
36
+ base_url: string;
37
+ runner_id: string;
38
+ runner_token: string;
39
+ machine_fingerprint: string;
40
+ }
41
+
42
+ const HEARTBEAT_INTERVAL_MS = 30_000;
43
+ const RECONCILE_INTERVAL_MS = 60_000; // periodic safety-net pull (C6)
44
+ const RECONNECT_BASE_MS = 1_000;
45
+ const RECONNECT_MAX_MS = 30_000;
46
+
47
+ export function loadRunnerAuth(cwd: string = process.cwd()): RunnerAuth {
48
+ const path = join(cwd, ".viber", "runner.json");
49
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial<RunnerAuth>;
50
+ if (!raw.base_url || !raw.runner_id || !raw.runner_token || !raw.machine_fingerprint) {
51
+ throw new Error(`Invalid ${path}: missing runner fields. Re-run enroll-runner.`);
52
+ }
53
+ assertSafeBaseUrl(raw.base_url);
54
+ return raw as RunnerAuth;
55
+ }
56
+
57
+ function jitter(ms: number): number {
58
+ // ±20% so a fleet of runners restarting together (post-deploy reconnect storm)
59
+ // doesn't hit the reconcile endpoint in lockstep. CRYPTO entropy (Codex C6): a
60
+ // clock-derived spread gives NO decorrelation across processes that restart at
61
+ // the same instant. Re-evaluated per tick (see scheduleReconcile).
62
+ const spread = ms * 0.2;
63
+ const frac = randomBytes(2).readUInt16BE(0) / 0xffff; // [0,1] uniform
64
+ return ms - spread + frac * (2 * spread);
65
+ }
66
+
67
+ /** base_url must be a TRUSTED Viber origin (Codex review): https + no userinfo is
68
+ * not enough — it still accepts https://evil.example, which a tampered
69
+ * runner.json would use to capture the bearer. Pin to the known Viber hosts /
70
+ * configured VIBER_BASE_URL / dev localhost. */
71
+ function assertSafeBaseUrl(baseUrl: string): void {
72
+ let u: URL;
73
+ try {
74
+ u = new URL(baseUrl);
75
+ } catch {
76
+ throw new Error(`Invalid base_url in runner.json: ${baseUrl}`);
77
+ }
78
+ if (!isTrustedViberOrigin(u.origin)) {
79
+ throw new Error(`Untrusted base_url origin in runner.json: ${u.origin}. Re-enroll.`);
80
+ }
81
+ }
82
+
83
+ // Coalescing guard: the timer, an SSE poke, and a reconnect can all fire a
84
+ // reconcile concurrently. With step-08's atomic claim that would risk wasted
85
+ // double-claims, so at most ONE reconcile runs at a time; a request that arrives
86
+ // while one is in flight is dropped (the running one covers it).
87
+ let reconciling = false;
88
+
89
+ /**
90
+ * Reconcile-pull the durable command table. step-07 stub — step-08 replaces the
91
+ * body with: claim pending commands (atomic lease), hand them to step-09's local
92
+ * executor. Coalesced + self-contained error handling so a reconcile failure
93
+ * NEVER propagates into the SSE stream's reconnect/backoff logic.
94
+ */
95
+ async function safeReconcile(auth: RunnerAuth): Promise<void> {
96
+ if (reconciling) return;
97
+ reconciling = true;
98
+ try {
99
+ const handled = await reconcileOnce(auth);
100
+ if (handled > 0) process.stderr.write(`[runner] reconcile: handled ${handled} command(s)\n`);
101
+ } catch (err) {
102
+ process.stderr.write(`[runner] reconcile error (non-fatal): ${String(err)}\n`);
103
+ } finally {
104
+ reconciling = false;
105
+ }
106
+ }
107
+
108
+ /** Heartbeat. Returns "revoked" on a 401 (the runner was revoked) so the caller
109
+ * can tear down mid-stream (revocation is otherwise only seen at reconnect —
110
+ * Opus/Codex review). Any other failure is swallowed (best-effort presence). */
111
+ async function sendHeartbeat(auth: RunnerAuth): Promise<"ok" | "revoked"> {
112
+ try {
113
+ const resp = await fetch(`${auth.base_url}/api/runners/${auth.runner_id}/heartbeat`, {
114
+ method: "POST",
115
+ // redirect:"error" — never follow a redirect while carrying the bearer, so
116
+ // a tampered base_url / hostile redirect can't exfiltrate it (Codex review).
117
+ redirect: "error",
118
+ headers: {
119
+ Authorization: `Bearer ${auth.runner_token}`,
120
+ "Content-Type": "application/json",
121
+ ...cfAccessHeaders(),
122
+ },
123
+ body: JSON.stringify({
124
+ capabilities: { runtimes: ["claude-code", "codex", "gemma"], platform: process.platform, version: 1 },
125
+ }),
126
+ });
127
+ return resp.status === 401 ? "revoked" : "ok";
128
+ } catch {
129
+ /* best-effort — durable presence is the server's last_seen; a missed beat is fine */
130
+ return "ok";
131
+ }
132
+ }
133
+
134
+ function sleep(ms: number): Promise<void> {
135
+ return new Promise((resolve) => setTimeout(resolve, ms));
136
+ }
137
+
138
+ /**
139
+ * Run the runner daemon forever. Opens the control SSE, reconcile-pulls on each
140
+ * (re)connect + periodic timer, and heartbeats. Reconnects with exponential
141
+ * backoff. A 401 (revoked runner) is terminal — the operator must re-enroll.
142
+ */
143
+ export async function runPersistentRunnerStream(
144
+ cwd: string = process.cwd(),
145
+ ): Promise<void> {
146
+ const auth = loadRunnerAuth(cwd);
147
+ cleanStaleSpecFiles(cwd); // drop crash-leftover temp specs before we start
148
+ process.stderr.write(`[runner] starting daemon for runner ${auth.runner_id.slice(0, 8)}…\n`);
149
+
150
+ // Background cadences, independent of the SSE connection state (C6): even if
151
+ // the stream is down, these keep the runner live + reconciled.
152
+ let stopped = false;
153
+ let revoked = false;
154
+ // Shared abort so a heartbeat-detected revocation tears down the live SSE now,
155
+ // instead of waiting for the next reconnect.
156
+ let currentAbort: AbortController | null = null;
157
+ const heartbeatTimer = setInterval(() => {
158
+ void sendHeartbeat(auth).then((r) => {
159
+ if (r === "revoked") {
160
+ revoked = true;
161
+ stopped = true;
162
+ currentAbort?.abort();
163
+ }
164
+ });
165
+ }, HEARTBEAT_INTERVAL_MS);
166
+ // Reconcile timer is a RE-JITTERED recursive setTimeout (not setInterval): each
167
+ // tick draws a fresh crypto jitter so a fleet never re-synchronizes (Codex C6).
168
+ let reconcileTimer: ReturnType<typeof setTimeout>;
169
+ const scheduleReconcile = (): void => {
170
+ reconcileTimer = setTimeout(() => {
171
+ void safeReconcile(auth);
172
+ if (!stopped) scheduleReconcile();
173
+ }, jitter(RECONCILE_INTERVAL_MS));
174
+ };
175
+ scheduleReconcile();
176
+
177
+ let backoff = RECONNECT_BASE_MS;
178
+ try {
179
+ while (!stopped) {
180
+ try {
181
+ // Reconcile UNCONDITIONALLY before/at each (re)connect — a poke missed
182
+ // while disconnected is recovered here (C6). safeReconcile is coalesced +
183
+ // self-contained (its errors never trigger the SSE backoff below).
184
+ await safeReconcile(auth);
185
+ // Close the tiny window where a heartbeat-detected revocation fired
186
+ // against the PREVIOUS (now-null) controller: bail before opening a new
187
+ // stream (Opus nit).
188
+ if (stopped) break;
189
+ currentAbort = new AbortController();
190
+ const terminal = await streamOnce(auth, currentAbort.signal);
191
+ if (terminal || revoked) {
192
+ process.stderr.write("[runner] runner revoked (401) — re-enroll. Stopping.\n");
193
+ stopped = true;
194
+ break;
195
+ }
196
+ backoff = RECONNECT_BASE_MS; // clean close → reset backoff
197
+ // Floor between reconnections on a CLEAN close, so a server that
198
+ // accepts-then-immediately-closes (200 → done) can't spin a tight loop.
199
+ if (!stopped) await sleep(RECONNECT_BASE_MS);
200
+ } catch (err) {
201
+ if (revoked) break; // abort was our own revocation teardown, not an error
202
+ process.stderr.write(`[runner] stream error: ${String(err)} — reconnecting in ${backoff}ms\n`);
203
+ await sleep(backoff); // error path: single backoff sleep (no double floor)
204
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
205
+ }
206
+ }
207
+ } finally {
208
+ clearInterval(heartbeatTimer);
209
+ clearTimeout(reconcileTimer!);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Open the SSE control stream once. Returns true if the runner is revoked (401 —
215
+ * terminal), false on a clean/recoverable close (caller reconnects). Each `spawn`
216
+ * wake-poke triggers a reconcile-pull; the event itself carries no authority.
217
+ */
218
+ async function streamOnce(auth: RunnerAuth, signal: AbortSignal): Promise<boolean> {
219
+ const resp = await fetch(`${auth.base_url}/api/runners/${auth.runner_id}/events`, {
220
+ method: "GET",
221
+ redirect: "error", // never follow a redirect carrying the bearer (Codex review)
222
+ signal,
223
+ headers: {
224
+ Authorization: `Bearer ${auth.runner_token}`,
225
+ Accept: "text/event-stream",
226
+ "Cache-Control": "no-cache",
227
+ ...cfAccessHeaders(),
228
+ },
229
+ });
230
+ if (resp.status === 401) return true; // revoked — terminal
231
+ if (!resp.ok || !resp.body) {
232
+ throw new Error(`control stream HTTP ${resp.status}`);
233
+ }
234
+
235
+ const reader = resp.body.getReader();
236
+ const decoder = new TextDecoder();
237
+ let buffer = "";
238
+ while (true) {
239
+ const { value, done } = await reader.read();
240
+ if (done) return false; // server closed — recoverable, reconnect
241
+ buffer += decoder.decode(value, { stream: true });
242
+ // Parse complete SSE frames (blank-line separated).
243
+ let sep: number;
244
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard SSE frame split
245
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
246
+ const frame = buffer.slice(0, sep);
247
+ buffer = buffer.slice(sep + 2);
248
+ const eventLine = frame.split("\n").find((l) => l.startsWith("event:"));
249
+ const event = eventLine?.slice("event:".length).trim();
250
+ if (event === "stop") return false; // server asked us to unwind
251
+ // Any wake-poke ("spawn_pending" etc.) or reconnect → reconcile-pull. The
252
+ // event is a HINT; the durable table is the truth. safeReconcile is
253
+ // coalesced + self-contained (never throws into this stream).
254
+ if (event && event !== "ping" && event !== "connected") {
255
+ await safeReconcile(auth);
256
+ }
257
+ }
258
+ }
259
+ }
package/lib/urls.ts CHANGED
@@ -10,3 +10,36 @@
10
10
  export function buildSseUrl(voiceBaseUrl: string, conversationId: string): string {
11
11
  return `${voiceBaseUrl}/api/conversations/${conversationId}/events`;
12
12
  }
13
+
14
+ /**
15
+ * Trusted Viber origins for the runner bearer (#460 step-07, Codex review).
16
+ * A runner_token is a local-code-exec capability, so it must only ever be sent
17
+ * to a KNOWN origin — validating "https + no userinfo" alone still accepts
18
+ * `https://evil.example`, letting a forged enrollment URL exfiltrate the bearer.
19
+ * Allowlist = the canonical prod + dev hosts, any localhost (dev), and the
20
+ * explicitly-configured `VIBER_BASE_URL` origin (self-host / dev override).
21
+ */
22
+ export function isTrustedViberOrigin(origin: string): boolean {
23
+ let u: URL;
24
+ try {
25
+ u = new URL(origin);
26
+ } catch {
27
+ return false;
28
+ }
29
+ if (u.username || u.password) return false;
30
+ const host = u.hostname;
31
+ const isLocalhost = host === "localhost" || host === "127.0.0.1";
32
+ if (u.protocol === "http:" && isLocalhost) return true;
33
+ if (u.protocol !== "https:") return false;
34
+ if (host === "viber.dgypx.dev" || host === "viber-dev.dgypx.dev") return true;
35
+ // Explicitly-configured base (self-host / dev override) — matched by origin.
36
+ const configured = process.env.VIBER_BASE_URL;
37
+ if (configured) {
38
+ try {
39
+ if (new URL(configured).origin === u.origin) return true;
40
+ } catch {
41
+ /* ignore a malformed env */
42
+ }
43
+ }
44
+ return false;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -97,11 +97,46 @@ import { capabilitiesText } from "./lib/capabilities.ts";
97
97
  `viber-channel — voice + text MCP channel for Claude Code\n\n` +
98
98
  `USAGE\n` +
99
99
  ` bunx viber-channel connect <claim_url> one-shot auth flow; writes .viber/auth.json\n` +
100
+ ` bunx viber-channel enroll-runner <url> enroll this machine as a spawn runner (#460)\n` +
101
+ ` bunx viber-channel run-runner start the resident runner daemon (#460)\n` +
100
102
  ` bunx viber-channel start the MCP server (used by Claude Code)\n\n` +
101
103
  `Get <claim_url> from https://viber.dgypx.dev/projects → Create connection.\n`,
102
104
  );
103
105
  process.exit(0);
104
106
  }
107
+ if (sub === "enroll-runner") {
108
+ // #460 Phase 2: enroll THIS machine as a spawn runner. URL carries the
109
+ // claim_id (path) + wait_token (fragment); the owner opens it from the web.
110
+ const enrollUrl = process.argv[3];
111
+ if (!enrollUrl) {
112
+ process.stderr.write(
113
+ `[viber-channel] Missing enrollment URL.\n` +
114
+ `Usage: bunx viber-channel enroll-runner "https://<host>/connect/runner/<claim_id>#<wait_token>"\n` +
115
+ `Get the URL from the Viber web UI → Runners → Enroll this machine.\n`,
116
+ );
117
+ process.exit(1);
118
+ }
119
+ await warnIfStale();
120
+ try {
121
+ const { runRunnerEnroll } = await import("./lib/runner_enroll.ts");
122
+ await runRunnerEnroll(enrollUrl, process.cwd(), process.argv[4]);
123
+ process.exit(0);
124
+ } catch (err) {
125
+ process.stderr.write(`[viber-channel] ${err instanceof Error ? err.message : String(err)}\n`);
126
+ process.exit(1);
127
+ }
128
+ }
129
+ if (sub === "run-runner") {
130
+ // #460 Phase 2: start the resident runner daemon (reads .viber/runner.json).
131
+ try {
132
+ const { runPersistentRunnerStream } = await import("./lib/runner_stream.ts");
133
+ await runPersistentRunnerStream(process.cwd());
134
+ process.exit(0);
135
+ } catch (err) {
136
+ process.stderr.write(`[viber-channel] ${err instanceof Error ? err.message : String(err)}\n`);
137
+ process.exit(1);
138
+ }
139
+ }
105
140
  if (sub === "connect") {
106
141
  const claimUrl = process.argv[3];
107
142
  if (!claimUrl) {