viber-channel 0.8.11 → 0.8.13
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/runner_exec.ts +146 -14
- package/lib/spawn_reason.ts +230 -0
- package/package.json +1 -1
package/lib/runner_exec.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
});
|
|
@@ -273,6 +291,49 @@ export interface ExecDeps {
|
|
|
273
291
|
writeSpec?: SpecFileWriter;
|
|
274
292
|
/** Injectable local-log writer (tests). Returns the log path. */
|
|
275
293
|
writeLog?: (commandId: string, recap: string) => string;
|
|
294
|
+
/** Injectable daemon-stderr writer (tests). Defaults to process.stderr. */
|
|
295
|
+
writeDaemonLine?: (line: string) => void;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Where the log lives, as shown to whoever reads the WEB (#506): RELATIVE, so
|
|
299
|
+
* it names the file without carrying the machine's absolute path — home
|
|
300
|
+
* directory and user name included — to the server. The absolute path stays on
|
|
301
|
+
* the daemon's own stderr, which never leaves the machine. */
|
|
302
|
+
export function runnerLogRef(commandId: string): string {
|
|
303
|
+
return `.viber/runner-logs/${commandId}.log`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What `writeRunnerLog` returns when it could NOT write (unwritable dir, full
|
|
307
|
+
* disk). Named, because the remote message must not point at a file that was
|
|
308
|
+
* never created. */
|
|
309
|
+
export const LOG_UNAVAILABLE = "(local log unavailable)";
|
|
310
|
+
|
|
311
|
+
/** The log reference to put in the REMOTE message, or undefined when no log
|
|
312
|
+
* exists (#506 review, both reviewers): pointing someone at
|
|
313
|
+
* `.viber/runner-logs/<id>.log` when the write failed would be this issue in
|
|
314
|
+
* miniature — a component asserting more than it knows, on the very sentence
|
|
315
|
+
* this step fixes. Callers fall back to the no-path wording. */
|
|
316
|
+
export function remoteLogRef(commandId: string, logPath: string): string | undefined {
|
|
317
|
+
return logPath === LOG_UNAVAILABLE ? undefined : runnerLogRef(commandId);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The one line of a recap worth putting on the daemon's stderr: its first
|
|
321
|
+
* `error:` line, bounded. Already redacted upstream by `redactSecrets`. Falls
|
|
322
|
+
* back to the first non-empty line when the CLI printed no `error:` line, so a
|
|
323
|
+
* failure is never reported as silence.
|
|
324
|
+
*
|
|
325
|
+
* The result must be ONE line: it is written with a trailing newline onto a
|
|
326
|
+
* shared stream, so any control character surviving inside it could forge a
|
|
327
|
+
* second log line. Hence splitting on a LONE `\r` too (Codex review — a bare CR
|
|
328
|
+
* is what a progress-bar writer emits), and stripping the remaining C0/C1
|
|
329
|
+
* controls rather than trusting the split alone. */
|
|
330
|
+
export function firstErrorLine(recap: string, max = 300): string {
|
|
331
|
+
const lines = recap
|
|
332
|
+
.split(/\r\n|\r|\n/)
|
|
333
|
+
.map((l) => l.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim())
|
|
334
|
+
.filter(Boolean);
|
|
335
|
+
const line = lines.find((l) => l.startsWith("error:")) ?? lines[0] ?? "(no output)";
|
|
336
|
+
return line.slice(0, max);
|
|
276
337
|
}
|
|
277
338
|
|
|
278
339
|
/** Write the full launch recap to a 0600 LOCAL log (never sent to the server —
|
|
@@ -285,11 +346,23 @@ export function writeRunnerLog(commandId: string, recap: string, cwd: string = p
|
|
|
285
346
|
mkdirSync(dir, { recursive: true });
|
|
286
347
|
writeFileSync(path, recap, { mode: 0o600 });
|
|
287
348
|
} catch {
|
|
288
|
-
return
|
|
349
|
+
return LOG_UNAVAILABLE;
|
|
289
350
|
}
|
|
290
351
|
return path;
|
|
291
352
|
}
|
|
292
353
|
|
|
354
|
+
/** A `failed` report body carrying a structured reason (#496). The sentence is
|
|
355
|
+
* derived from the REASON, so what the web shows is server/runner-authored copy,
|
|
356
|
+
* never a raw client string; `error` keeps the operator-facing precision the
|
|
357
|
+
* local log used to hold alone. */
|
|
358
|
+
function refusalBody(
|
|
359
|
+
cmd: ClaimedCommand,
|
|
360
|
+
reason: SpawnReason,
|
|
361
|
+
error: string,
|
|
362
|
+
): Record<string, unknown> {
|
|
363
|
+
return { fencing_token: cmd.fencing_token, status: "failed", reason, error };
|
|
364
|
+
}
|
|
365
|
+
|
|
293
366
|
async function report(
|
|
294
367
|
auth: RunnerAuthLite,
|
|
295
368
|
cmdId: string,
|
|
@@ -331,14 +404,24 @@ export async function executeClaim(
|
|
|
331
404
|
try {
|
|
332
405
|
policy = deps.policy ?? loadRunnerPolicy();
|
|
333
406
|
} catch (err) {
|
|
334
|
-
const
|
|
335
|
-
await report(
|
|
407
|
+
const cause = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
|
|
408
|
+
await report(
|
|
409
|
+
auth,
|
|
410
|
+
cmd.id,
|
|
411
|
+
refusalBody(cmd, "policy_invalid", `local policy invalid: ${cause}`),
|
|
412
|
+
fetchImpl,
|
|
413
|
+
);
|
|
336
414
|
return "failed";
|
|
337
415
|
}
|
|
338
416
|
|
|
339
417
|
const refusal = validateAgainstPolicy(cmd, policy);
|
|
340
418
|
if (refusal) {
|
|
341
|
-
await report(
|
|
419
|
+
await report(
|
|
420
|
+
auth,
|
|
421
|
+
cmd.id,
|
|
422
|
+
refusalBody(cmd, "policy_refused", `local policy: ${refusal}`),
|
|
423
|
+
fetchImpl,
|
|
424
|
+
);
|
|
342
425
|
return "failed";
|
|
343
426
|
}
|
|
344
427
|
|
|
@@ -350,7 +433,7 @@ export async function executeClaim(
|
|
|
350
433
|
const s = await report(
|
|
351
434
|
auth,
|
|
352
435
|
cmd.id,
|
|
353
|
-
|
|
436
|
+
refusalBody(cmd, "partial_run", "prior partial run detected on this runner — not relaunched"),
|
|
354
437
|
fetchImpl,
|
|
355
438
|
);
|
|
356
439
|
if (s >= 200 && s < 300) inflight.clear(cmd.id);
|
|
@@ -360,7 +443,12 @@ export async function executeClaim(
|
|
|
360
443
|
// template_name is the positional <name> in argv: positive allowlist (no
|
|
361
444
|
// leading '-', no control char) — argv-flag-injection defense.
|
|
362
445
|
if (!/^[A-Za-z0-9_.][A-Za-z0-9 _.-]{0,63}$/.test(cmd.template_name)) {
|
|
363
|
-
await report(
|
|
446
|
+
await report(
|
|
447
|
+
auth,
|
|
448
|
+
cmd.id,
|
|
449
|
+
refusalBody(cmd, "unsafe_template_name", "unsafe template_name"),
|
|
450
|
+
fetchImpl,
|
|
451
|
+
);
|
|
364
452
|
return "failed";
|
|
365
453
|
}
|
|
366
454
|
|
|
@@ -378,21 +466,65 @@ export async function executeClaim(
|
|
|
378
466
|
const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
|
|
379
467
|
try {
|
|
380
468
|
// Allowlisted argv (C3): fixed flags + validated values, no free string.
|
|
381
|
-
|
|
469
|
+
// #496: a fresh nonce per launch, echoed back on the outcome marker.
|
|
470
|
+
// Threat model, stated precisely (Codex review): this DE-CORRELATES an
|
|
471
|
+
// accidental look-alike — an agent that prints this contract in its own
|
|
472
|
+
// output on the shared, deliberately-inherited stderr — which is the case
|
|
473
|
+
// that actually happens here. It is NOT authentication: a same-user process
|
|
474
|
+
// can read the parent's command line on Windows and forge the value. Nothing
|
|
475
|
+
// on this path can defend against a hostile local process, which already runs
|
|
476
|
+
// as the user; what protects the RENDERED text is the roster intersection
|
|
477
|
+
// below, not this nonce.
|
|
478
|
+
const nonce = randomBytes(16).toString("hex");
|
|
479
|
+
const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix, "--result-nonce", nonce];
|
|
382
480
|
if (cmd.team_name) args.push("--team", cmd.team_name);
|
|
383
481
|
if (cmd.env) args.push("--env", cmd.env);
|
|
384
482
|
|
|
385
483
|
inflight.mark(cmd.id); // durable "started here" BEFORE launch (C4)
|
|
386
|
-
const { ok, recap } = await runTeamSpawn(args);
|
|
387
|
-
|
|
388
|
-
//
|
|
484
|
+
const { ok, recap, reason, detail } = await runTeamSpawn(args, nonce);
|
|
485
|
+
|
|
486
|
+
// #496: the failure now travels as a REASON plus a sentence built from it —
|
|
487
|
+
// whoever clicked Spawn in a browser can act on what they read. The full
|
|
488
|
+
// recap still goes ONLY to the 0600 local log (no secret leak); what changed
|
|
489
|
+
// is that the local log is no longer the sole place the cause exists. With no
|
|
490
|
+
// marker (older vibe-master, output lost to maxBuffer) we degrade to exactly
|
|
491
|
+
// the previous message rather than inventing a cause.
|
|
389
492
|
const logPath = deps.writeLog ? deps.writeLog(cmd.id, recap) : writeRunnerLog(cmd.id, recap);
|
|
493
|
+
// #506, the issue's second defect: the remote message sent people to "the
|
|
494
|
+
// machine's log", and the log they actually open — the daemon's — only ever
|
|
495
|
+
// said "reconcile: handled 1 command(s)". A failure now states itself where
|
|
496
|
+
// it is read FIRST. This stream is local, so the ABSOLUTE path belongs here;
|
|
497
|
+
// what travels to the server is the relative ref below.
|
|
498
|
+
if (!ok) {
|
|
499
|
+
const writeDaemonLine =
|
|
500
|
+
deps.writeDaemonLine ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
501
|
+
writeDaemonLine(
|
|
502
|
+
`[runner] spawn FAILED command=${cmd.id} reason=${reason ?? "unknown"} log=${logPath} ${firstErrorLine(recap)}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
// Resolved ONCE: three call sites in a ternary could drift apart under a
|
|
506
|
+
// later edit, and they must agree on whether a log exists (Opus review).
|
|
507
|
+
const logRef = remoteLogRef(cmd.id, logPath);
|
|
390
508
|
const finalStatus = await report(
|
|
391
509
|
auth,
|
|
392
510
|
cmd.id,
|
|
393
511
|
ok
|
|
394
512
|
? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
|
|
395
|
-
: {
|
|
513
|
+
: {
|
|
514
|
+
fencing_token: cmd.fencing_token,
|
|
515
|
+
status: "failed",
|
|
516
|
+
error: reason
|
|
517
|
+
? messageForReason(
|
|
518
|
+
reason,
|
|
519
|
+
detail,
|
|
520
|
+
rosterNames(cmd.prefix, cmd.template_spec.roles),
|
|
521
|
+
logRef,
|
|
522
|
+
)
|
|
523
|
+
: logRef
|
|
524
|
+
? `team spawn failed — see ${logRef} on that machine`
|
|
525
|
+
: "team spawn failed, and the local log could not be written on that machine",
|
|
526
|
+
...(reason ? { reason } : {}),
|
|
527
|
+
},
|
|
396
528
|
fetchImpl,
|
|
397
529
|
);
|
|
398
530
|
// Clear the inflight marker ONLY when the TERMINAL report is accepted (2xx)
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
"roster_invalid",
|
|
26
|
+
"codex_unavailable",
|
|
27
|
+
"launch_failed",
|
|
28
|
+
// Emitted by the runner itself
|
|
29
|
+
"policy_invalid",
|
|
30
|
+
"policy_refused",
|
|
31
|
+
"unsafe_template_name",
|
|
32
|
+
"partial_run",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type SpawnReason = (typeof SPAWN_REASONS)[number];
|
|
36
|
+
|
|
37
|
+
const MARKER = "VIBEMASTER_RESULT";
|
|
38
|
+
|
|
39
|
+
/** Detail is diagnostic context, never shown raw to a user (the server bounds it
|
|
40
|
+
* again and the web renders from the REASON). Bounded here too so a runaway
|
|
41
|
+
* child cannot push a megabyte into a report. */
|
|
42
|
+
const DETAIL_MAX = 300;
|
|
43
|
+
|
|
44
|
+
export interface ParsedSpawnResult {
|
|
45
|
+
reason: SpawnReason;
|
|
46
|
+
detail?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Keep only the names that belong to THIS command's roster.
|
|
51
|
+
*
|
|
52
|
+
* A charset check is not enough (Codex review): `session-expired, sign-in, now`
|
|
53
|
+
* satisfies any agent-id pattern and would be rendered as though it listed
|
|
54
|
+
* members. The only trustworthy authority on what a name may be is the roster the
|
|
55
|
+
* SERVER validated and sent — so `detail` is not sanitized, it is INTERSECTED
|
|
56
|
+
* with that roster. A name the server never asked to spawn cannot reach the
|
|
57
|
+
* screen, whatever the runner sent, and prose cannot survive at all.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_NAMES = 10;
|
|
60
|
+
|
|
61
|
+
export function keepRosterNames(
|
|
62
|
+
detail: string | undefined,
|
|
63
|
+
roster: readonly string[],
|
|
64
|
+
): string | undefined {
|
|
65
|
+
if (!detail) return undefined;
|
|
66
|
+
const allowed = new Set(roster);
|
|
67
|
+
const names = detail
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((t) => t.trim())
|
|
70
|
+
.filter((t) => allowed.has(t))
|
|
71
|
+
.slice(0, MAX_NAMES);
|
|
72
|
+
return names.length > 0 ? names.join(", ") : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The agent names a command may talk about: `{prefix}-{role}`, plus
|
|
77
|
+
* `{prefix}-{role}-{n}` for replicated roles. Mirrors vibe-master's naming in
|
|
78
|
+
* `resolveTeamSpawn` — the two must move together.
|
|
79
|
+
*/
|
|
80
|
+
export function rosterNames(
|
|
81
|
+
prefix: string,
|
|
82
|
+
roles: readonly { role: string; count?: number }[],
|
|
83
|
+
): string[] {
|
|
84
|
+
const names: string[] = [];
|
|
85
|
+
for (const r of roles) {
|
|
86
|
+
const count = r.count && r.count > 1 ? r.count : 1;
|
|
87
|
+
if (count === 1) {
|
|
88
|
+
names.push(`${prefix}-${r.role}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (let i = 1; i <= count; i++) names.push(`${prefix}-${r.role}-${i}`);
|
|
92
|
+
}
|
|
93
|
+
return names;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract the outcome marker from RAW child stderr.
|
|
98
|
+
*
|
|
99
|
+
* Read this before the recap is built, never after: the recap is redacted and
|
|
100
|
+
* then cut to 4 000 characters, and the marker is written LAST — exactly the end
|
|
101
|
+
* that the cut removes. A four-member roster overflows that budget easily, so
|
|
102
|
+
* parsing the recap would work in tests and fail on a real spawn.
|
|
103
|
+
*
|
|
104
|
+
* Scans for the LAST line that starts with the marker AND parses AND carries a
|
|
105
|
+
* known reason — not simply the last line. On `launch_failed` the marker is
|
|
106
|
+
* written after child processes have inherited this same stderr, so trailing
|
|
107
|
+
* child output can follow it; and a line quoting the marker inside other text is
|
|
108
|
+
* ignored because the prefix must start the line.
|
|
109
|
+
*
|
|
110
|
+
* Returns null when there is no marker (an older vibe-master, or output lost to
|
|
111
|
+
* a maxBuffer overflow) — the caller then behaves exactly as before.
|
|
112
|
+
*/
|
|
113
|
+
export type MarkerTrust =
|
|
114
|
+
/** Believe only a marker echoing this nonce (the runner path — the default). */
|
|
115
|
+
| { nonce: string }
|
|
116
|
+
/** No emitter check at all. Named so a caller cannot fall into it by omission
|
|
117
|
+
* (Opus review): an optional nonce silently skipped would reopen "last valid
|
|
118
|
+
* marker wins" with no test turning red. Only for reading output nobody
|
|
119
|
+
* else could have written into — a human running the CLI, or a unit test. */
|
|
120
|
+
| { trustUnauthenticated: true };
|
|
121
|
+
|
|
122
|
+
export function parseSpawnResult(
|
|
123
|
+
rawStderr: string,
|
|
124
|
+
trust: MarkerTrust,
|
|
125
|
+
): ParsedSpawnResult | null {
|
|
126
|
+
const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
|
|
127
|
+
if (expectedNonce !== undefined && expectedNonce.length === 0) {
|
|
128
|
+
throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
|
|
129
|
+
}
|
|
130
|
+
const known = new Set<string>(SPAWN_REASONS);
|
|
131
|
+
for (const line of rawStderr.split(/\r?\n/).reverse()) {
|
|
132
|
+
if (!line.startsWith(`${MARKER} `)) continue;
|
|
133
|
+
let parsed: unknown;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(line.slice(MARKER.length + 1));
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // truncated or interleaved — keep looking further back
|
|
138
|
+
}
|
|
139
|
+
if (typeof parsed !== "object" || parsed === null) continue;
|
|
140
|
+
const { reason, detail, nonce } = parsed as {
|
|
141
|
+
reason?: unknown;
|
|
142
|
+
detail?: unknown;
|
|
143
|
+
nonce?: unknown;
|
|
144
|
+
};
|
|
145
|
+
// Emitter proof. Spawned agents inherit this stderr, and an agent working on
|
|
146
|
+
// THIS repo prints this very contract in its own output — "the last valid
|
|
147
|
+
// marker wins" would let such a line overrule the real outcome and turn a
|
|
148
|
+
// launch_failed into a neutral already_running. Only the process we handed
|
|
149
|
+
// the nonce to can echo it.
|
|
150
|
+
if (expectedNonce && nonce !== expectedNonce) continue;
|
|
151
|
+
if (typeof reason !== "string" || !known.has(reason)) continue;
|
|
152
|
+
return {
|
|
153
|
+
reason: reason as SpawnReason,
|
|
154
|
+
...(typeof detail === "string" && detail.length > 0
|
|
155
|
+
? { detail: detail.slice(0, DETAIL_MAX) }
|
|
156
|
+
: {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The sentence the web shows. Built from the REASON. Where agent names carry the
|
|
164
|
+
* actionable part ("which member is missing"), they go through
|
|
165
|
+
* {@link keepRosterNames} first, so what is interpolated is provably a subset of
|
|
166
|
+
* the roster the server itself validated — never a sentence a machine chose.
|
|
167
|
+
* The roster is REQUIRED (Opus review): defaulting it to `[]` would let a caller
|
|
168
|
+
* drop every name in silence — the same "bypass by omission" the explicit marker
|
|
169
|
+
* trust closes, failing safe but failing quietly. A caller with no roster passes
|
|
170
|
+
* `[]` deliberately, and that shows in the diff.
|
|
171
|
+
*
|
|
172
|
+
* `policy_refused` interpolates NOTHING: its detail is free-form policy text, not
|
|
173
|
+
* a name list, so it cannot be shape-checked. The precision stays in the
|
|
174
|
+
* operator-facing `error`, off the screen.
|
|
175
|
+
*/
|
|
176
|
+
export function messageForReason(
|
|
177
|
+
reason: SpawnReason,
|
|
178
|
+
detail?: string,
|
|
179
|
+
roster: readonly string[],
|
|
180
|
+
logRef?: string,
|
|
181
|
+
): string {
|
|
182
|
+
const safe = keepRosterNames(detail, roster);
|
|
183
|
+
const names = safe ? ` (${safe})` : "";
|
|
184
|
+
switch (reason) {
|
|
185
|
+
case "already_running":
|
|
186
|
+
// ATTRIBUTED, not asserted (Opus review). Of the nine reasons this is the
|
|
187
|
+
// ONLY one that renders as reassuring — every other paints red. So it is
|
|
188
|
+
// the one a buggy or compromised runner could use to claim "nothing was
|
|
189
|
+
// launched" while agents are actually running: the exact false green this
|
|
190
|
+
// issue exists to remove, re-entering through the pipe we just built. The
|
|
191
|
+
// server cannot verify the claim, so the copy SOURCES it instead. A user
|
|
192
|
+
// whose screen disagrees with their machine then knows what to doubt.
|
|
193
|
+
return `the runner reports that a team with this prefix was already running on this machine${names} — so nothing was launched`;
|
|
194
|
+
case "partial_team":
|
|
195
|
+
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`;
|
|
196
|
+
case "template_invalid":
|
|
197
|
+
// On the web path this is NOT a user mistake: the runner never passes a
|
|
198
|
+
// template NAME, it writes the server-validated snapshot to a file and
|
|
199
|
+
// passes --spec-file. So this means the server's own snapshot could not be
|
|
200
|
+
// read back — a defect. Telling the user to go read a log they cannot open,
|
|
201
|
+
// for a problem they did not cause, would repeat this issue in miniature.
|
|
202
|
+
// #506, second defect of the issue: this sentence sent the reader to "the
|
|
203
|
+
// machine's log" WITHOUT naming it — and the log they did open, the
|
|
204
|
+
// daemon's, said nothing about the failure. `logRef` is the RELATIVE path
|
|
205
|
+
// (`.viber/runner-logs/<command_id>.log`): it locates the file without
|
|
206
|
+
// shipping an absolute Windows path — hence the machine's user name — to
|
|
207
|
+
// the server and onto someone's screen.
|
|
208
|
+
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 ${logRef ?? "the machine's log"} on that machine`;
|
|
209
|
+
case "roster_invalid":
|
|
210
|
+
// #506: the OTHER half of what template_invalid used to swallow. The
|
|
211
|
+
// snapshot was read fine — it is the ROSTER that cannot be launched as
|
|
212
|
+
// written, and that IS the template author's to fix. Two catches, two
|
|
213
|
+
// reasons: this one says "you can fix this", template_invalid says "this
|
|
214
|
+
// is a defect on our side". Saying the second for the first sends someone
|
|
215
|
+
// to read a log for a mistake they could correct in two clicks.
|
|
216
|
+
return `this team template cannot be launched as written${names} — fix the template, then spawn again`;
|
|
217
|
+
case "codex_unavailable":
|
|
218
|
+
return "codex is not launchable on this machine, so no agent was started — install codex or set VIBER_CODEX_BIN";
|
|
219
|
+
case "launch_failed":
|
|
220
|
+
return `a member failed to start${names}; the rest of the roster was skipped — the team is incomplete`;
|
|
221
|
+
case "policy_invalid":
|
|
222
|
+
return "this machine's local runner policy is present but invalid, so the spawn was refused";
|
|
223
|
+
case "policy_refused":
|
|
224
|
+
return "this machine's local policy refuses this spawn";
|
|
225
|
+
case "unsafe_template_name":
|
|
226
|
+
return "the template name was rejected as unsafe by the runner";
|
|
227
|
+
case "partial_run":
|
|
228
|
+
return "a prior run of this command already started here and was not relaunched — check the machine before retrying";
|
|
229
|
+
}
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.13",
|
|
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": {
|