viber-channel 0.8.10 → 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.
- package/lib/codex_shell_path.ts +119 -0
- package/lib/runner_exec.ts +80 -13
- package/lib/spawn_reason.ts +214 -0
- package/package.json +1 -1
- package/viber-codex-bridge.ts +27 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex_shell_path — #500: let codex spawn processes on Windows again.
|
|
3
|
+
*
|
|
4
|
+
* codex's Windows sandbox launches every command under a RESTRICTED TOKEN
|
|
5
|
+
* (`CreateProcessAsUserW`). Windows refuses to start an MSIX-PACKAGED binary
|
|
6
|
+
* that way, and the shell codex resolves on JP's machine is PowerShell 7 from
|
|
7
|
+
* the Microsoft Store — packaged under `C:\Program Files\WindowsApps\…`. So
|
|
8
|
+
* every command died with:
|
|
9
|
+
*
|
|
10
|
+
* windows sandbox: runner failed during SpawnChild:
|
|
11
|
+
* CreateProcessAsUserW failed: 5 (Access is denied.)
|
|
12
|
+
*
|
|
13
|
+
* Measured, 20 trials per case (success = absence of `CreateProcessAsUserW`),
|
|
14
|
+
* reproduced independently by a second agent:
|
|
15
|
+
*
|
|
16
|
+
* | target | spawn failures |
|
|
17
|
+
* | pwsh MSIX (package dir OR alias dir) | 20/20 |
|
|
18
|
+
* | powershell 5.1 / cmd.exe / git bash | 0/20 |
|
|
19
|
+
*
|
|
20
|
+
* FIX: drop the `WindowsApps` directories from the PATH of the `codex
|
|
21
|
+
* app-server` CHILD ONLY. codex then resolves `powershell.exe` 5.1 and spawns
|
|
22
|
+
* fine (measured end to end: `hello_from_shell`, exit 0, 722 ms).
|
|
23
|
+
*
|
|
24
|
+
* REJECTED — `-c windows.sandbox="unelevated"`. It also fixes the spawn (0/20
|
|
25
|
+
* failures on the MSIX target), but it switches the sandbox to the legacy
|
|
26
|
+
* backend, and that has two measured costs:
|
|
27
|
+
* - PowerShell drops to **ConstrainedLanguage** (5/5, both agents): .NET
|
|
28
|
+
* method calls, `Add-Type` and type casts fail in agent scripts;
|
|
29
|
+
* - codex's own binary states that "Restricted read-only access requires the
|
|
30
|
+
* elevated Windows sandbox backend" — our `read`/`chat` tiers rely on the
|
|
31
|
+
* read-only sandbox, so they would be SILENTLY weakened, and any future
|
|
32
|
+
* deny-read path (`.viber/auth.json`, `~/.ssh`) would be a no-op.
|
|
33
|
+
* Writes outside the workspace stay blocked in both modes, so the tradeoff is
|
|
34
|
+
* not about writes — it is about keeping the backend our tiers are built on.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A PATH entry that resolves to Store/MSIX app-execution aliases.
|
|
39
|
+
*
|
|
40
|
+
* Anchored on a whole path SEGMENT: a substring match would also strip
|
|
41
|
+
* `C:\SDK\WindowsAppsSDK\bin` and nobody would understand why six months later.
|
|
42
|
+
* The two real cases stay covered — the package dir and
|
|
43
|
+
* `%LOCALAPPDATA%\Microsoft\WindowsApps`.
|
|
44
|
+
*/
|
|
45
|
+
function isWindowsAppsEntry(entry: string): boolean {
|
|
46
|
+
return /[\\/]windowsapps([\\/]|$)/i.test(entry.replace(/[\\/]+$/, ""));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SanitizeResult {
|
|
50
|
+
/** The PATH to hand the child. Unchanged when no surgery was needed. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** Entries removed, in their original order. Empty when nothing changed. */
|
|
53
|
+
stripped: string[];
|
|
54
|
+
/** Set when surgery was NEEDED but deliberately skipped — worth logging. */
|
|
55
|
+
skipped?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remove the MSIX alias directories from a PATH — but only on Windows, and only
|
|
60
|
+
* when no unpackaged `pwsh.exe` is REACHABLE, i.e. sits on a surviving PATH
|
|
61
|
+
* entry. An MSI PowerShell 7 that exists on disk but is not on the PATH does
|
|
62
|
+
* NOT count: codex resolves its shell through the PATH, so it would still land
|
|
63
|
+
* on the broken packaged one (raised in review — we do not have a measurement
|
|
64
|
+
* showing codex probes fixed locations, so we must not assume it does).
|
|
65
|
+
*
|
|
66
|
+
* Pure: platform, PATH and filesystem probing are all injected, so this never
|
|
67
|
+
* depends on the machine running the test.
|
|
68
|
+
*/
|
|
69
|
+
export function sanitizeCodexPath(
|
|
70
|
+
pathValue: string | undefined,
|
|
71
|
+
opts: { platform: string; exists: (p: string) => boolean; sep?: string },
|
|
72
|
+
): SanitizeResult {
|
|
73
|
+
const sep = opts.sep ?? ";";
|
|
74
|
+
const original = pathValue ?? "";
|
|
75
|
+
if (opts.platform !== "win32") return { path: original, stripped: [] };
|
|
76
|
+
|
|
77
|
+
const entries = original.split(sep);
|
|
78
|
+
const kept = entries.filter((e) => !isWindowsAppsEntry(e));
|
|
79
|
+
const stripped = entries.filter((e) => isWindowsAppsEntry(e) && e.trim() !== "");
|
|
80
|
+
if (stripped.length === 0) return { path: original, stripped: [] };
|
|
81
|
+
|
|
82
|
+
// An unpackaged pwsh on a surviving entry means codex already has a shell it
|
|
83
|
+
// can spawn — leave the PATH intact so the child keeps its Store aliases
|
|
84
|
+
// (python stub, wt, …).
|
|
85
|
+
const onKeptPath = (exe: string): boolean =>
|
|
86
|
+
kept.some((dir) => dir.trim() !== "" && opts.exists(`${dir.replace(/[\\/]+$/, "")}\\${exe}`));
|
|
87
|
+
if (onKeptPath("pwsh.exe")) return { path: original, stripped: [] };
|
|
88
|
+
|
|
89
|
+
// Floor: if not even cmd.exe survives the surgery, codex would have no shell
|
|
90
|
+
// to fall back on — stripping would cost the Store aliases for nothing.
|
|
91
|
+
// PowerShell 5.1 IS removable as an optional feature on recent Windows 11,
|
|
92
|
+
// so this is not purely theoretical. Do nothing, and say so.
|
|
93
|
+
if (!onKeptPath("cmd.exe") && !onKeptPath("powershell.exe")) {
|
|
94
|
+
return { path: original, stripped: [], skipped: "no unpackaged shell would remain on PATH" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { path: kept.join(sep), stripped };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply {@link sanitizeCodexPath} to a child environment, in place.
|
|
102
|
+
*
|
|
103
|
+
* Windows env keys are case-INSENSITIVE, a JS object's are not: a parent may
|
|
104
|
+
* carry `Path`, `PATH` or even `PaTh`, and handing the child two spellings of
|
|
105
|
+
* the same variable is asking for the wrong one to win. So we read whichever
|
|
106
|
+
* variant exists, then collapse them all into one canonical `PATH`.
|
|
107
|
+
*/
|
|
108
|
+
export function applySanitizedPath(
|
|
109
|
+
env: Record<string, string | undefined>,
|
|
110
|
+
opts: { platform: string; exists: (p: string) => boolean; sep?: string },
|
|
111
|
+
): SanitizeResult {
|
|
112
|
+
const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path");
|
|
113
|
+
const current = keys.map((k) => env[k]).find((v) => v !== undefined);
|
|
114
|
+
const result = sanitizeCodexPath(current, opts);
|
|
115
|
+
if (result.stripped.length === 0) return result;
|
|
116
|
+
for (const k of keys) delete env[k];
|
|
117
|
+
env.PATH = result.path;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
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
|
});
|
|
@@ -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
|
|
335
|
-
await report(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
: {
|
|
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.
|
|
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": {
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
14
14
|
import { CONTRACT_STYLE_NEUTRAL, CONTRACT_STYLE_TOOLS } from "./lib/channel_instructions.ts";
|
|
15
|
+
import { applySanitizedPath } from "./lib/codex_shell_path.ts";
|
|
15
16
|
import {
|
|
16
17
|
ConversationMintError,
|
|
17
18
|
defaultLabel,
|
|
@@ -620,6 +621,32 @@ class CodexAppServer {
|
|
|
620
621
|
const codexBin = process.env.VIBER_CODEX_BIN?.trim() || "codex";
|
|
621
622
|
const args = ["app-server"];
|
|
622
623
|
const childEnv = { ...process.env };
|
|
624
|
+
// #500: codex's Windows sandbox cannot spawn an MSIX-packaged shell (Store
|
|
625
|
+
// PowerShell 7) under a restricted token — every command died with
|
|
626
|
+
// CreateProcessAsUserW failed: 5. Drop the WindowsApps alias dirs from the
|
|
627
|
+
// CHILD's PATH so codex resolves powershell.exe 5.1 instead. The sandbox
|
|
628
|
+
// keeps its `elevated` backend, which our read-only tiers depend on.
|
|
629
|
+
// applySanitizedPath collapses every case-variant of the key into one
|
|
630
|
+
// canonical PATH — handing the child both `Path` and `PATH` would let the
|
|
631
|
+
// stale one win.
|
|
632
|
+
const sanitized = applySanitizedPath(childEnv, {
|
|
633
|
+
platform: process.platform,
|
|
634
|
+
exists: existsSync,
|
|
635
|
+
});
|
|
636
|
+
if (sanitized.stripped.length > 0) {
|
|
637
|
+
process.stderr.write(
|
|
638
|
+
`[viber-codex-bridge] #500: removed ${sanitized.stripped.length} MSIX alias dir(s) from the ` +
|
|
639
|
+
`codex child's PATH so it can spawn processes: ${sanitized.stripped.join(", ")}. ` +
|
|
640
|
+
"The child therefore loses the Store execution aliases — notably WINGET (and wt, ngrok, " +
|
|
641
|
+
"the WSL launchers); node/npm/bun/git/codex/python are unaffected. Installing PowerShell 7 " +
|
|
642
|
+
"from the MSI makes this surgery unnecessary.\n",
|
|
643
|
+
);
|
|
644
|
+
} else if (sanitized.skipped) {
|
|
645
|
+
process.stderr.write(
|
|
646
|
+
`[viber-codex-bridge] #500: MSIX alias dirs left on the codex child's PATH — ${sanitized.skipped}. ` +
|
|
647
|
+
"Commands may fail with CreateProcessAsUserW failed: 5; run scripts/repro-codex-spawn.ps1.\n",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
623
650
|
if (mcp) {
|
|
624
651
|
args.push("-c", `mcp_servers.viber.url="${mcp.url}"`);
|
|
625
652
|
args.push("-c", `mcp_servers.viber.bearer_token_env_var="${mcp.bearerEnvVar}"`);
|