viber-channel 0.8.11 → 0.8.12

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.
@@ -18,6 +18,7 @@ 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
+ import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
21
22
 
22
23
  /** A validated role from the server snapshot (allowlisted shape). */
23
24
  export interface SnapshotRole {
@@ -148,7 +149,13 @@ export function validateAgainstPolicy(cmd: ClaimedCommand, policy: RunnerPolicy)
148
149
 
149
150
  export type TeamSpawnRunner = (
150
151
  args: string[],
151
- ) => Promise<{ ok: boolean; recap: string }>;
152
+ /** #496: the nonce the child must echo on its outcome marker for it to be
153
+ * believed. REQUIRED (Codex review): an optional nonce lets a future launcher
154
+ * omit it and silently fall back to trusting any marker — the very hole the
155
+ * explicit trust parameter closes. A mock may ignore the argument; it cannot
156
+ * forget that it exists. */
157
+ nonce: string,
158
+ ) => Promise<{ ok: boolean; recap: string; reason?: SpawnReason; detail?: string }>;
152
159
 
153
160
  /**
154
161
  * Build a CURATED child environment (Codex review, blocking): `execFile` would
@@ -202,7 +209,7 @@ export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): stri
202
209
  /** Default launcher: shell vibe-master via execFile with an ARGV ARRAY (never a
203
210
  * shell string), a CURATED env, bounded time + output, and process-tree kill on
204
211
  * timeout. The binary is resolved via VIBE_MASTER_CMD (default `vibe-master`). */
205
- export const execFileTeamSpawn: TeamSpawnRunner = (args) => {
212
+ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
206
213
  const base = vibeMasterBaseCmd();
207
214
  const bin = base[0] as string;
208
215
  return new Promise((resolve) => {
@@ -211,8 +218,19 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args) => {
211
218
  [...base.slice(1), ...args],
212
219
  { timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
213
220
  (err, stdout, stderr) => {
221
+ // #496: read the outcome marker from RAW stderr, BEFORE redaction and
222
+ // before the 4 000-char cut. The marker is written last, so it is the
223
+ // first thing `.slice()` drops on a real (verbose) roster — parsing the
224
+ // recap would pass every test and fail every actual spawn. Redaction can
225
+ // also mangle the JSON. Both hazards disappear by reading first.
226
+ const parsed = parseSpawnResult(stderr ?? "", { nonce });
214
227
  const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
215
- resolve({ ok: !err, recap });
228
+ resolve({
229
+ ok: !err,
230
+ recap,
231
+ ...(parsed?.reason ? { reason: parsed.reason } : {}),
232
+ ...(parsed?.detail ? { detail: parsed.detail } : {}),
233
+ });
216
234
  },
217
235
  );
218
236
  });
@@ -290,6 +308,18 @@ export function writeRunnerLog(commandId: string, recap: string, cwd: string = p
290
308
  return path;
291
309
  }
292
310
 
311
+ /** A `failed` report body carrying a structured reason (#496). The sentence is
312
+ * derived from the REASON, so what the web shows is server/runner-authored copy,
313
+ * never a raw client string; `error` keeps the operator-facing precision the
314
+ * local log used to hold alone. */
315
+ function refusalBody(
316
+ cmd: ClaimedCommand,
317
+ reason: SpawnReason,
318
+ error: string,
319
+ ): Record<string, unknown> {
320
+ return { fencing_token: cmd.fencing_token, status: "failed", reason, error };
321
+ }
322
+
293
323
  async function report(
294
324
  auth: RunnerAuthLite,
295
325
  cmdId: string,
@@ -331,14 +361,24 @@ export async function executeClaim(
331
361
  try {
332
362
  policy = deps.policy ?? loadRunnerPolicy();
333
363
  } 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);
364
+ const cause = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
365
+ await report(
366
+ auth,
367
+ cmd.id,
368
+ refusalBody(cmd, "policy_invalid", `local policy invalid: ${cause}`),
369
+ fetchImpl,
370
+ );
336
371
  return "failed";
337
372
  }
338
373
 
339
374
  const refusal = validateAgainstPolicy(cmd, policy);
340
375
  if (refusal) {
341
- await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: `local policy: ${refusal}` }, fetchImpl);
376
+ await report(
377
+ auth,
378
+ cmd.id,
379
+ refusalBody(cmd, "policy_refused", `local policy: ${refusal}`),
380
+ fetchImpl,
381
+ );
342
382
  return "failed";
343
383
  }
344
384
 
@@ -350,7 +390,7 @@ export async function executeClaim(
350
390
  const s = await report(
351
391
  auth,
352
392
  cmd.id,
353
- { fencing_token: cmd.fencing_token, status: "failed", error: "prior partial run detected on this runner — not relaunched" },
393
+ refusalBody(cmd, "partial_run", "prior partial run detected on this runner — not relaunched"),
354
394
  fetchImpl,
355
395
  );
356
396
  if (s >= 200 && s < 300) inflight.clear(cmd.id);
@@ -360,7 +400,12 @@ export async function executeClaim(
360
400
  // template_name is the positional <name> in argv: positive allowlist (no
361
401
  // leading '-', no control char) — argv-flag-injection defense.
362
402
  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);
403
+ await report(
404
+ auth,
405
+ cmd.id,
406
+ refusalBody(cmd, "unsafe_template_name", "unsafe template_name"),
407
+ fetchImpl,
408
+ );
364
409
  return "failed";
365
410
  }
366
411
 
@@ -378,21 +423,43 @@ export async function executeClaim(
378
423
  const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
379
424
  try {
380
425
  // Allowlisted argv (C3): fixed flags + validated values, no free string.
381
- const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix];
426
+ // #496: a fresh nonce per launch, echoed back on the outcome marker.
427
+ // Threat model, stated precisely (Codex review): this DE-CORRELATES an
428
+ // accidental look-alike — an agent that prints this contract in its own
429
+ // output on the shared, deliberately-inherited stderr — which is the case
430
+ // that actually happens here. It is NOT authentication: a same-user process
431
+ // can read the parent's command line on Windows and forge the value. Nothing
432
+ // on this path can defend against a hostile local process, which already runs
433
+ // as the user; what protects the RENDERED text is the roster intersection
434
+ // below, not this nonce.
435
+ const nonce = randomBytes(16).toString("hex");
436
+ const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix, "--result-nonce", nonce];
382
437
  if (cmd.team_name) args.push("--team", cmd.team_name);
383
438
  if (cmd.env) args.push("--env", cmd.env);
384
439
 
385
440
  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).
441
+ const { ok, recap, reason, detail } = await runTeamSpawn(args, nonce);
442
+
443
+ // #496: the failure now travels as a REASON plus a sentence built from it —
444
+ // whoever clicked Spawn in a browser can act on what they read. The full
445
+ // recap still goes ONLY to the 0600 local log (no secret leak); what changed
446
+ // is that the local log is no longer the sole place the cause exists. With no
447
+ // marker (older vibe-master, output lost to maxBuffer) we degrade to exactly
448
+ // the previous message rather than inventing a cause.
389
449
  const logPath = deps.writeLog ? deps.writeLog(cmd.id, recap) : writeRunnerLog(cmd.id, recap);
390
450
  const finalStatus = await report(
391
451
  auth,
392
452
  cmd.id,
393
453
  ok
394
454
  ? { 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" },
455
+ : {
456
+ fencing_token: cmd.fencing_token,
457
+ status: "failed",
458
+ error: reason
459
+ ? messageForReason(reason, detail, rosterNames(cmd.prefix, cmd.template_spec.roles))
460
+ : "team spawn failed — see local runner log",
461
+ ...(reason ? { reason } : {}),
462
+ },
396
463
  fetchImpl,
397
464
  );
398
465
  // Clear the inflight marker ONLY when the TERMINAL report is accepted (2xx)
@@ -0,0 +1,214 @@
1
+ /**
2
+ * spawn_reason.ts — the runner's half of the spawn-outcome contract (#496).
3
+ *
4
+ * A spawn refused on this machine used to reach the web as one sentence,
5
+ * "team spawn failed — see local runner log". Whoever clicked Spawn from a
6
+ * browser has no terminal and no access to that log: they saw a red failure and
7
+ * had nowhere to go. The cause existed — it was on the runner's stderr — and was
8
+ * thrown away here.
9
+ *
10
+ * `vibe-master` now emits a machine line (`VIBEMASTER_RESULT {"reason":…}`,
11
+ * see vibe-master/lib/spawn_result.ts). This module extracts it and turns a
12
+ * reason into a sentence the web can show. The runner's OWN refusals (policy,
13
+ * unsafe input, a prior partial run) carry reasons from the same vocabulary, so
14
+ * the web never has to speak two languages.
15
+ */
16
+
17
+ /** Reasons a spawn can be refused. Mirrors vibe-master's vocabulary plus the
18
+ * four the runner produces itself. The API keeps its own allowlist: an unknown
19
+ * reason is dropped there, never stored raw — it comes from a client machine. */
20
+ export const SPAWN_REASONS = [
21
+ // Emitted by vibe-master
22
+ "already_running",
23
+ "partial_team",
24
+ "template_invalid",
25
+ "codex_unavailable",
26
+ "launch_failed",
27
+ // Emitted by the runner itself
28
+ "policy_invalid",
29
+ "policy_refused",
30
+ "unsafe_template_name",
31
+ "partial_run",
32
+ ] as const;
33
+
34
+ export type SpawnReason = (typeof SPAWN_REASONS)[number];
35
+
36
+ const MARKER = "VIBEMASTER_RESULT";
37
+
38
+ /** Detail is diagnostic context, never shown raw to a user (the server bounds it
39
+ * again and the web renders from the REASON). Bounded here too so a runaway
40
+ * child cannot push a megabyte into a report. */
41
+ const DETAIL_MAX = 300;
42
+
43
+ export interface ParsedSpawnResult {
44
+ reason: SpawnReason;
45
+ detail?: string;
46
+ }
47
+
48
+ /**
49
+ * Keep only the names that belong to THIS command's roster.
50
+ *
51
+ * A charset check is not enough (Codex review): `session-expired, sign-in, now`
52
+ * satisfies any agent-id pattern and would be rendered as though it listed
53
+ * members. The only trustworthy authority on what a name may be is the roster the
54
+ * SERVER validated and sent — so `detail` is not sanitized, it is INTERSECTED
55
+ * with that roster. A name the server never asked to spawn cannot reach the
56
+ * screen, whatever the runner sent, and prose cannot survive at all.
57
+ */
58
+ const MAX_NAMES = 10;
59
+
60
+ export function keepRosterNames(
61
+ detail: string | undefined,
62
+ roster: readonly string[],
63
+ ): string | undefined {
64
+ if (!detail) return undefined;
65
+ const allowed = new Set(roster);
66
+ const names = detail
67
+ .split(",")
68
+ .map((t) => t.trim())
69
+ .filter((t) => allowed.has(t))
70
+ .slice(0, MAX_NAMES);
71
+ return names.length > 0 ? names.join(", ") : undefined;
72
+ }
73
+
74
+ /**
75
+ * The agent names a command may talk about: `{prefix}-{role}`, plus
76
+ * `{prefix}-{role}-{n}` for replicated roles. Mirrors vibe-master's naming in
77
+ * `resolveTeamSpawn` — the two must move together.
78
+ */
79
+ export function rosterNames(
80
+ prefix: string,
81
+ roles: readonly { role: string; count?: number }[],
82
+ ): string[] {
83
+ const names: string[] = [];
84
+ for (const r of roles) {
85
+ const count = r.count && r.count > 1 ? r.count : 1;
86
+ if (count === 1) {
87
+ names.push(`${prefix}-${r.role}`);
88
+ continue;
89
+ }
90
+ for (let i = 1; i <= count; i++) names.push(`${prefix}-${r.role}-${i}`);
91
+ }
92
+ return names;
93
+ }
94
+
95
+ /**
96
+ * Extract the outcome marker from RAW child stderr.
97
+ *
98
+ * Read this before the recap is built, never after: the recap is redacted and
99
+ * then cut to 4 000 characters, and the marker is written LAST — exactly the end
100
+ * that the cut removes. A four-member roster overflows that budget easily, so
101
+ * parsing the recap would work in tests and fail on a real spawn.
102
+ *
103
+ * Scans for the LAST line that starts with the marker AND parses AND carries a
104
+ * known reason — not simply the last line. On `launch_failed` the marker is
105
+ * written after child processes have inherited this same stderr, so trailing
106
+ * child output can follow it; and a line quoting the marker inside other text is
107
+ * ignored because the prefix must start the line.
108
+ *
109
+ * Returns null when there is no marker (an older vibe-master, or output lost to
110
+ * a maxBuffer overflow) — the caller then behaves exactly as before.
111
+ */
112
+ export type MarkerTrust =
113
+ /** Believe only a marker echoing this nonce (the runner path — the default). */
114
+ | { nonce: string }
115
+ /** No emitter check at all. Named so a caller cannot fall into it by omission
116
+ * (Opus review): an optional nonce silently skipped would reopen "last valid
117
+ * marker wins" with no test turning red. Only for reading output nobody
118
+ * else could have written into — a human running the CLI, or a unit test. */
119
+ | { trustUnauthenticated: true };
120
+
121
+ export function parseSpawnResult(
122
+ rawStderr: string,
123
+ trust: MarkerTrust,
124
+ ): ParsedSpawnResult | null {
125
+ const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
126
+ if (expectedNonce !== undefined && expectedNonce.length === 0) {
127
+ throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
128
+ }
129
+ const known = new Set<string>(SPAWN_REASONS);
130
+ for (const line of rawStderr.split(/\r?\n/).reverse()) {
131
+ if (!line.startsWith(`${MARKER} `)) continue;
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = JSON.parse(line.slice(MARKER.length + 1));
135
+ } catch {
136
+ continue; // truncated or interleaved — keep looking further back
137
+ }
138
+ if (typeof parsed !== "object" || parsed === null) continue;
139
+ const { reason, detail, nonce } = parsed as {
140
+ reason?: unknown;
141
+ detail?: unknown;
142
+ nonce?: unknown;
143
+ };
144
+ // Emitter proof. Spawned agents inherit this stderr, and an agent working on
145
+ // THIS repo prints this very contract in its own output — "the last valid
146
+ // marker wins" would let such a line overrule the real outcome and turn a
147
+ // launch_failed into a neutral already_running. Only the process we handed
148
+ // the nonce to can echo it.
149
+ if (expectedNonce && nonce !== expectedNonce) continue;
150
+ if (typeof reason !== "string" || !known.has(reason)) continue;
151
+ return {
152
+ reason: reason as SpawnReason,
153
+ ...(typeof detail === "string" && detail.length > 0
154
+ ? { detail: detail.slice(0, DETAIL_MAX) }
155
+ : {}),
156
+ };
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * The sentence the web shows. Built from the REASON. Where agent names carry the
163
+ * actionable part ("which member is missing"), they go through
164
+ * {@link keepRosterNames} first, so what is interpolated is provably a subset of
165
+ * the roster the server itself validated — never a sentence a machine chose.
166
+ * The roster is REQUIRED (Opus review): defaulting it to `[]` would let a caller
167
+ * drop every name in silence — the same "bypass by omission" the explicit marker
168
+ * trust closes, failing safe but failing quietly. A caller with no roster passes
169
+ * `[]` deliberately, and that shows in the diff.
170
+ *
171
+ * `policy_refused` interpolates NOTHING: its detail is free-form policy text, not
172
+ * a name list, so it cannot be shape-checked. The precision stays in the
173
+ * operator-facing `error`, off the screen.
174
+ */
175
+ export function messageForReason(
176
+ reason: SpawnReason,
177
+ detail?: string,
178
+ roster: readonly string[],
179
+ ): string {
180
+ const safe = keepRosterNames(detail, roster);
181
+ const names = safe ? ` (${safe})` : "";
182
+ switch (reason) {
183
+ case "already_running":
184
+ // ATTRIBUTED, not asserted (Opus review). Of the nine reasons this is the
185
+ // ONLY one that renders as reassuring — every other paints red. So it is
186
+ // the one a buggy or compromised runner could use to claim "nothing was
187
+ // launched" while agents are actually running: the exact false green this
188
+ // issue exists to remove, re-entering through the pipe we just built. The
189
+ // server cannot verify the claim, so the copy SOURCES it instead. A user
190
+ // whose screen disagrees with their machine then knows what to doubt.
191
+ return `the runner reports that a team with this prefix was already running on this machine${names} — so nothing was launched`;
192
+ case "partial_team":
193
+ return `the runner reports that a team with this prefix is only PARTLY running — these members are missing${names}. Stop the survivors or pick a fresh prefix`;
194
+ case "template_invalid":
195
+ // On the web path this is NOT a user mistake: the runner never passes a
196
+ // template NAME, it writes the server-validated snapshot to a file and
197
+ // passes --spec-file. So this means the server's own snapshot could not be
198
+ // read back — a defect. Telling the user to go read a log they cannot open,
199
+ // for a problem they did not cause, would repeat this issue in miniature.
200
+ return "the roster sent for this spawn could not be read back on the runner machine — this is likely a defect, not something you did wrong; the full error is in the machine's log";
201
+ case "codex_unavailable":
202
+ return "codex is not launchable on this machine, so no agent was started — install codex or set VIBER_CODEX_BIN";
203
+ case "launch_failed":
204
+ return `a member failed to start${names}; the rest of the roster was skipped — the team is incomplete`;
205
+ case "policy_invalid":
206
+ return "this machine's local runner policy is present but invalid, so the spawn was refused";
207
+ case "policy_refused":
208
+ return "this machine's local policy refuses this spawn";
209
+ case "unsafe_template_name":
210
+ return "the template name was rejected as unsafe by the runner";
211
+ case "partial_run":
212
+ return "a prior run of this command already started here and was not relaunched — check the machine before retrying";
213
+ }
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
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": {