viber-channel 0.8.19 → 0.8.21

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.
@@ -13,13 +13,16 @@
13
13
  * injectable so the flow is unit-testable without shelling or a live server.
14
14
  */
15
15
 
16
- import { execFile } from "node:child_process";
16
+ import { execFile } from "./hidden_proc.js";
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
21
  import { gatewayNothingToDo, gatewaySpawnOk, parseGatewayResult } from "./gateway_result.js";
22
22
  import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
23
+ import { identityBaseUrl } from "./base_urls.js";
24
+ import { recordSpawn, registryPath, type SpawnForRegistry, updateRegistry } from "./runner_registry.js";
25
+ import { relaunchInstance } from "./runner_relaunch.js";
23
26
 
24
27
  /** A validated role from the server snapshot (allowlisted shape). */
25
28
  export interface SnapshotRole {
@@ -39,12 +42,79 @@ export interface ClaimedCommand {
39
42
  prefix: string;
40
43
  team_name: string | null;
41
44
  env: string | null;
45
+ /** From the LIST entry (the claim response does not carry it). #632: the app
46
+ * mode maps it to a local folder; the source runner ignores it. */
47
+ project_id?: number;
48
+ /** #627: `relaunch` brings back ONE agent from the runner's registry; absent = team. */
49
+ kind?: "team" | "relaunch";
50
+ relaunch_instance_id?: string | null;
42
51
  }
43
52
 
44
53
  export interface RunnerAuthLite {
45
54
  base_url: string;
46
55
  runner_id: string;
47
56
  runner_token: string;
57
+ /** #627: the web host the machine enrolled on. Absent in a runner.json written
58
+ * before #627 — `runnerIdentity` then falls back to `VIBER_BASE_URL`. */
59
+ identity_base_url?: string;
60
+ }
61
+
62
+ /**
63
+ * #627 — the backend identity of each launch env, i.e. the `VIBER_BASE_URL`
64
+ * vibe-master gives an agent launched with `--env <name>`.
65
+ *
66
+ * ⚠ A COPY of `VIBER_ENVS[*].baseUrl` in `vibe-master/lib/env.ts` — viber-channel is
67
+ * published on its own and cannot import vibe-master. `test/runner_identity.test.ts`
68
+ * pins the two together, so a changed host breaks the build instead of silently
69
+ * sending an agent's presence record into a directory its runner never reads.
70
+ */
71
+ export const ENV_IDENTITY: Readonly<Record<string, string>> = {
72
+ dev: "https://viber-dev.dgypx.dev",
73
+ staging: "https://viber.dgypx.dev",
74
+ qa: "https://viber-qa.dgypx.dev",
75
+ };
76
+
77
+ /** The backend this runner serves, verbatim (never normalized, #480). */
78
+ export function runnerIdentity(auth: Pick<RunnerAuthLite, "identity_base_url">, env: NodeJS.ProcessEnv = process.env): string {
79
+ if (auth.identity_base_url !== undefined) return auth.identity_base_url;
80
+ if (!legacyIdentityAnnounced) {
81
+ // A runner.json from before #627: the enrolled host was not recorded, so this is a
82
+ // GUESS (VIBER_BASE_URL, else the default). Wrong for an app runner enrolled on dev.
83
+ legacyIdentityAnnounced = true;
84
+ process.stderr.write(
85
+ `[runner] runner.json has no identity_base_url (enrolled before #627): assuming ${identityBaseUrl(env)} — re-enroll to record it
86
+ `,
87
+ );
88
+ }
89
+ return identityBaseUrl(env);
90
+ }
91
+ let legacyIdentityAnnounced = false;
92
+
93
+ /**
94
+ * The env a team must be launched with so its agents land on THIS runner's backend,
95
+ * or a refusal. An explicit env pointing elsewhere is refused: the agents would write
96
+ * their presence records under another backend, the runner would never see them, and
97
+ * they would stay paused forever (#627 step-06). No env → the runner's own, when the
98
+ * identity is a known one; else unchanged (a custom backend, as before #627).
99
+ */
100
+ export function launchEnvFor(
101
+ cmdEnv: string | null,
102
+ identity: string,
103
+ ): { ok: true; env: string | null } | { ok: false; error: string } {
104
+ if (cmdEnv !== null) {
105
+ const target = ENV_IDENTITY[cmdEnv];
106
+ if (target === identity) return { ok: true, env: cmdEnv };
107
+ return {
108
+ ok: false,
109
+ error:
110
+ `env "${cmdEnv}" runs agents on ${target ?? "an unknown backend"}, but this runner serves ${identity}` +
111
+ (target !== undefined && target.replace(/\/+$/, "") === identity.replace(/\/+$/, "")
112
+ ? " — the enrolled host differs only by a trailing slash: re-enroll this machine"
113
+ : ""),
114
+ };
115
+ }
116
+ const own = Object.keys(ENV_IDENTITY).find((name) => ENV_IDENTITY[name] === identity);
117
+ return { ok: true, env: own ?? null };
48
118
  }
49
119
 
50
120
  /**
@@ -374,6 +444,8 @@ export function buildGatewayTeamArgs(cmd: {
374
444
  teamName?: string;
375
445
  specPath: string;
376
446
  cwd: string;
447
+ /** #627: the project, for the runner registry the gateway writes. */
448
+ projectId?: number;
377
449
  registryDir: string;
378
450
  permission: string;
379
451
  env?: string;
@@ -423,6 +495,8 @@ export function buildGatewayTeamArgs(cmd: {
423
495
  cmd.nonce,
424
496
  ];
425
497
  if (cmd.env) args.push("--env", cmd.env);
498
+ // #627: the gateway writes the team into the runner registry, keyed by project.
499
+ if (cmd.projectId !== undefined) args.push("--project", String(cmd.projectId));
426
500
  // ▶ Only when set: an empty `--vibe-master-cmd` would make the gateway resolve an
427
501
  // empty command, which is worse than not passing the flag at all.
428
502
  if (cmd.vibeMasterCmd && cmd.vibeMasterCmd.trim().length > 0) {
@@ -452,8 +526,12 @@ export function buildGatewayTeamArgs(cmd: {
452
526
  * "we could not read the verdict" must not collapse into "it worked"
453
527
  * (`gatewaySpawnOk`).
454
528
  */
455
- export const execFileGatewayTeamSpawn: TeamSpawnRunner = (args, nonce) => {
456
- const base = viberGatewayBaseCmd();
529
+ export const execFileGatewayTeamSpawn: TeamSpawnRunner = (args, nonce) =>
530
+ gatewayTeamSpawnWith(viberGatewayBaseCmd())(args, nonce);
531
+
532
+ /** #632: the same launcher with an explicit base command — the app mode passes its
533
+ * own absolute `process.execPath`, never a bare name resolved through PATH. */
534
+ export const gatewayTeamSpawnWith = (base: string[]): TeamSpawnRunner => (args, nonce) => {
457
535
  const bin = base[0] as string;
458
536
  return new Promise((resolve) => {
459
537
  execFile(
@@ -540,6 +618,38 @@ export interface ExecDeps {
540
618
  writeLog?: (commandId: string, recap: string) => string;
541
619
  /** Injectable daemon-stderr writer (tests). Defaults to process.stderr. */
542
620
  writeDaemonLine?: (line: string) => void;
621
+ /**
622
+ * #632: the folder the AGENTS work in, per project. Absent = the runner's own
623
+ * cwd (source runner, unchanged). `null` = no folder for this project (the user
624
+ * cancelled the picker): the command is REFUSED, never launched in a default.
625
+ */
626
+ resolveWorkspace?: (projectId: number | undefined) => Promise<string | null>;
627
+ /** #632: `vibe-master` for the launched team, when the env var is not the source. */
628
+ vibeMasterCmd?: string;
629
+ /** #627: remember a launched team on this machine (default: the registry on disk). */
630
+ recordLaunch?: (spawn: SpawnForRegistry) => void;
631
+ /** #627: runs `vibe-master <args>` for a relaunch (default: execFile, like a team). */
632
+ runSingleSpawn?: (args: string[]) => Promise<{ ok: boolean; recap: string }>;
633
+ /** #627: relaunch seams (tests). */
634
+ relaunchLockDir?: string;
635
+ relaunchEnumerate?: () => Promise<Map<number, number | undefined> | undefined>;
636
+ }
637
+
638
+ /**
639
+ * #627 — the default `recordLaunch`: write the team into this backend's registry.
640
+ * The backend is `runnerIdentity(auth)` — the enrolled web host, which `launchEnvFor`
641
+ * guarantees is also the agents' `VIBER_BASE_URL`, so the registry sits in the
642
+ * directory their presence records are written to. `auth.base_url` would be wrong
643
+ * here: it may be the announced API host (#480), not the identity.
644
+ */
645
+ export function recordLaunchOnDisk(
646
+ identity: string,
647
+ spawn: SpawnForRegistry,
648
+ log: (line: string) => void = (l) => process.stderr.write(`${l}\n`),
649
+ now: () => number = Date.now,
650
+ ): void {
651
+ const result = updateRegistry(registryPath(identity), (r) => recordSpawn(r, spawn, now()));
652
+ if (!result.ok) log(`[runner] registry NOT updated for command=${spawn.command_id}: ${result.error}`);
543
653
  }
544
654
 
545
655
  /** Where the log lives, as shown to whoever reads the WEB (#506): RELATIVE, so
@@ -648,6 +758,7 @@ export async function executeClaim(
648
758
  // talks to `vibe-master` directly, and its witnesses keep measuring that contract.
649
759
  const runTeamSpawn = deps.runTeamSpawn ?? execFileGatewayTeamSpawn;
650
760
  const fetchImpl = deps.apiFetch ?? fetch;
761
+ if (cmd.kind === "relaunch") return executeRelaunchClaim(auth, cmd, deps, fetchImpl);
651
762
  const inflight = deps.inflight ?? fsInflightStore();
652
763
 
653
764
  // P1-4: a PRESENT-but-invalid local policy REFUSES execution (never the
@@ -666,6 +777,19 @@ export async function executeClaim(
666
777
  return "failed";
667
778
  }
668
779
 
780
+ // #632: the agents' folder. Resolved BEFORE any report, so a cancelled picker
781
+ // refuses the command without a side effect.
782
+ const workspace = deps.resolveWorkspace ? await deps.resolveWorkspace(cmd.project_id) : process.cwd();
783
+ if (workspace === null) {
784
+ await report(
785
+ auth,
786
+ cmd.id,
787
+ refusalBody(cmd, "launch_refused", `no local folder chosen for project ${cmd.project_id ?? "?"}`),
788
+ fetchImpl,
789
+ );
790
+ return "failed";
791
+ }
792
+
669
793
  const refusal = validateAgainstPolicy(cmd, policy);
670
794
  if (refusal) {
671
795
  await report(
@@ -677,6 +801,14 @@ export async function executeClaim(
677
801
  return "failed";
678
802
  }
679
803
 
804
+ // #627: the agents must land on this runner's backend, or it can never vouch for them.
805
+ const identity = runnerIdentity(auth);
806
+ const launchEnv = launchEnvFor(cmd.env, identity);
807
+ if (!launchEnv.ok) {
808
+ await report(auth, cmd.id, refusalBody(cmd, "launch_refused", launchEnv.error), fetchImpl);
809
+ return "failed";
810
+ }
811
+
680
812
  // Local idempotence (C4): a marker for this command_id means a PRIOR run
681
813
  // already started it here (crashed mid-spawn, now re-claimed). Do NOT relaunch.
682
814
  // Keep the marker unless the failed report is ACCEPTED (2xx) — else a later
@@ -746,7 +878,8 @@ export async function executeClaim(
746
878
  prefix: cmd.prefix,
747
879
  ...(cmd.team_name ? { teamName: cmd.team_name } : {}),
748
880
  specPath,
749
- cwd: process.cwd(),
881
+ cwd: workspace,
882
+ ...(cmd.project_id !== undefined ? { projectId: cmd.project_id } : {}),
750
883
  // Beside `runner-specs`/`runner-logs`/`runner-inflight`, never the bare cwd - see
751
884
  // `buildGatewayTeamArgs`, where the measurement that forced this is recorded.
752
885
  registryDir: ensureGatewayRegistryDir(),
@@ -757,13 +890,17 @@ export async function executeClaim(
757
890
  // permission from the ARGV and never from the spec, so a mixed roster used to
758
891
  // launch a `read-only` member `read-write` with nothing going red.
759
892
  permission: cmd.template_spec.roles[0]?.permission ?? "read-only",
760
- ...(cmd.env ? { env: cmd.env } : {}),
893
+ ...(launchEnv.env ? { env: launchEnv.env } : {}),
761
894
  nonce,
762
895
  // ⚠ See (3) on `buildGatewayTeamArgs`: the runner no longer launches
763
896
  // `vibe-master`, so this must TRAVEL or dev silently uses the installed
764
897
  // binary. Read from the env here, where the variable is already the
765
898
  // convention, rather than re-invented as a second knob.
766
- ...(process.env.VIBE_MASTER_CMD ? { vibeMasterCmd: process.env.VIBE_MASTER_CMD } : {}),
899
+ ...(process.env.VIBE_MASTER_CMD
900
+ ? { vibeMasterCmd: process.env.VIBE_MASTER_CMD }
901
+ : deps.vibeMasterCmd
902
+ ? { vibeMasterCmd: deps.vibeMasterCmd }
903
+ : {}),
767
904
  });
768
905
 
769
906
  inflight.mark(cmd.id); // durable "started here" BEFORE launch (C4)
@@ -797,6 +934,22 @@ export async function executeClaim(
797
934
  // reassuring word promoted on no proof — and it would do so while LOOKING like belt-and-braces.
798
935
  const nothingToDo = launcherSaysNothingToDo ?? false;
799
936
 
937
+ // #627: a launched team is remembered on this machine, so the web can offer to
938
+ // relaunch its members with these settings after a crash or a reboot. Only on a
939
+ // real launch — "already running" launched nothing and must not reset entries.
940
+ if (ok) {
941
+ (deps.recordLaunch ?? ((s: SpawnForRegistry) => recordLaunchOnDisk(identity, s)))({
942
+ command_id: cmd.id,
943
+ template_name: cmd.template_name,
944
+ prefix: cmd.prefix,
945
+ team_name: cmd.team_name,
946
+ env: launchEnv.env,
947
+ project_id: cmd.project_id ?? null,
948
+ workspace,
949
+ roles: cmd.template_spec.roles,
950
+ });
951
+ }
952
+
800
953
  // #496: the failure now travels as a REASON plus a sentence built from it —
801
954
  // whoever clicked Spawn in a browser can act on what they read. The full
802
955
  // recap still goes ONLY to the 0600 local log (no secret leak); what changed
@@ -878,7 +1031,7 @@ export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}):
878
1031
  headers: { Authorization: `Bearer ${auth.runner_token}`, ...cfAccessHeaders() },
879
1032
  });
880
1033
  if (!listResp.ok) return 0;
881
- const { commands } = (await listResp.json()) as { commands: { id: string }[] };
1034
+ const { commands } = (await listResp.json()) as { commands: { id: string; project_id?: number }[] };
882
1035
  if (!commands.length) return 0;
883
1036
 
884
1037
  const first = commands[0];
@@ -888,6 +1041,75 @@ export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}):
888
1041
  );
889
1042
  if (claimResp.status !== 200) return 0; // lost the claim — another pass will retry
890
1043
  const claimed = (await claimResp.json()) as ClaimedCommand;
1044
+ if (claimed.project_id === undefined && first.project_id !== undefined) claimed.project_id = first.project_id;
891
1045
  await executeClaim(auth, claimed, deps);
892
1046
  return 1;
893
1047
  }
1048
+
1049
+ /**
1050
+ * #627 step-07 — a RELAUNCH command: one agent, from this runner's registry. The
1051
+ * decision (refuse / re-attach / launch) is `relaunchInstance`'s; this reports it with
1052
+ * the same fencing-guarded reports as a team spawn. No automatic retry on failure.
1053
+ */
1054
+ async function executeRelaunchClaim(
1055
+ auth: RunnerAuthLite,
1056
+ cmd: ClaimedCommand,
1057
+ deps: ExecDeps,
1058
+ fetchImpl: typeof fetch,
1059
+ ): Promise<"succeeded" | "failed" | "aborted"> {
1060
+ const instanceId = cmd.relaunch_instance_id ?? "";
1061
+ if (!/^[0-9a-f]{32}$/.test(instanceId)) {
1062
+ await report(auth, cmd.id, refusalBody(cmd, "launch_refused", "relaunch command without a valid instance id"), fetchImpl);
1063
+ return "failed";
1064
+ }
1065
+ // Same local idempotence as a team (C4): a marker means a PRIOR run already started
1066
+ // this relaunch here — never launch a second agent for one command.
1067
+ const inflight = deps.inflight ?? fsInflightStore();
1068
+ if (inflight.has(cmd.id)) {
1069
+ const r = await report(auth, cmd.id, refusalBody(cmd, "launch_refused", "this relaunch already ran on this machine"), fetchImpl);
1070
+ if (r >= 200 && r < 300) inflight.clear(cmd.id);
1071
+ return "failed";
1072
+ }
1073
+ // Only a 2xx means the claim is still ours — anything else, never launch (Codex).
1074
+ const starting = await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "starting" }, fetchImpl);
1075
+ if (starting < 200 || starting >= 300) return "aborted";
1076
+ inflight.mark(cmd.id);
1077
+ // The saved settings meet TODAY's local policy, like a team does (Codex): an operator
1078
+ // who restricted the machine since the first launch is obeyed.
1079
+ let policy: RunnerPolicy;
1080
+ try {
1081
+ policy = deps.policy ?? loadRunnerPolicy();
1082
+ } catch (err) {
1083
+ const cause = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
1084
+ await report(auth, cmd.id, refusalBody(cmd, "policy_invalid", `local policy invalid: ${cause}`), fetchImpl);
1085
+ inflight.clear(cmd.id);
1086
+ return "failed";
1087
+ }
1088
+ const outcome = await relaunchInstance(instanceId, {
1089
+ identity: runnerIdentity(auth),
1090
+ ...(deps.relaunchLockDir !== undefined ? { lockDir: deps.relaunchLockDir } : {}),
1091
+ ...(deps.relaunchEnumerate !== undefined ? { enumerate: deps.relaunchEnumerate } : {}),
1092
+ runSpawn: deps.runSingleSpawn ?? ((args) => execFileTeamSpawn(args, randomBytes(8).toString("hex"))),
1093
+ checkPolicy: (entry) =>
1094
+ validateAgainstPolicy(
1095
+ { ...cmd, env: entry.env, template_spec: { roles: [{ ...entry.role }] } },
1096
+ policy,
1097
+ ),
1098
+ });
1099
+ // #627 (live validation): the web says "see the runner log" — so the log must carry it.
1100
+ // vibe-master's own output goes to the 0600 LOCAL log only, like a team's recap (#496:
1101
+ // it may hold secrets) — stderr can be captured elsewhere (Opus). The console line
1102
+ // carries only the runner-authored sentence and where the detail is.
1103
+ if (outcome.kind === "refused") {
1104
+ const logPath = outcome.recap ? (deps.writeLog ? deps.writeLog(cmd.id, outcome.recap) : writeRunnerLog(cmd.id, outcome.recap)) : undefined;
1105
+ console.error(`[runner] relaunch ${instanceId.slice(0, 8)} refused: ${outcome.error}${logPath ? ` (log: ${logPath})` : ""}`);
1106
+ }
1107
+ const body =
1108
+ outcome.kind === "refused"
1109
+ ? refusalBody(cmd, outcome.policy ? "policy_refused" : "launch_refused", outcome.error)
1110
+ : { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: outcome.message } };
1111
+ const final = await report(auth, cmd.id, body, fetchImpl);
1112
+ if (final < 200 || final >= 300) return "aborted"; // marker kept for reconcile
1113
+ inflight.clear(cmd.id);
1114
+ return outcome.kind === "refused" ? "failed" : "succeeded";
1115
+ }