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.
- package/lib/bridge_core.ts +95 -6
- 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 +35 -0
package/lib/bridge_core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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,
|
package/lib/fingerprint.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
30
|
-
* Must stay
|
|
31
|
-
* default
|
|
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
|
|
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.
|
|
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) {
|