viber-channel 0.8.9 → 0.8.11
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/cli_bow_out.ts +46 -0
- package/lib/codex_shell_path.ts +119 -0
- package/package.json +1 -1
- package/viber-channel.ts +20 -27
- package/viber-codex-bridge.ts +27 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bow-out decision: should this process yield the channel to the PLUGIN copy? (#365, #495)
|
|
3
|
+
*
|
|
4
|
+
* WHY THE GUARD EXISTS (#365 / #280 step-29). In an AGENT session (a vibe-master
|
|
5
|
+
* terminal) the channel must be the PLUGIN copy — only it can inject inbound
|
|
6
|
+
* messages. The manually-registered viber-dev-channel MCP loads in the same
|
|
7
|
+
* session with the same env; rather than race it for the channel lock, it bows
|
|
8
|
+
* out deterministically. Normal sessions (no VIBER_CHANNEL_AGENT_SESSION) are
|
|
9
|
+
* unaffected.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT ONLY CONCERNS THE MCP SERVER MODE (#495). A CLI subcommand
|
|
12
|
+
* (`enroll-runner`, `run-runner`, `connect`, `--help`) serves no tool and takes
|
|
13
|
+
* no channel lock, so it cannot conflict with the plugin copy. Evaluating this
|
|
14
|
+
* guard before the subcommand dispatch made every subcommand exit 0 without
|
|
15
|
+
* doing anything inside an agent session — silent, and falsely successful. The
|
|
16
|
+
* call site therefore sits AFTER the dispatch block; see viber-channel.ts.
|
|
17
|
+
*
|
|
18
|
+
* The dev-backend exclusion is DERIVED from the environment (single source of
|
|
19
|
+
* truth, isDevBackend) rather than trusting VIBER_CHANNEL_AGENT_SESSION not to
|
|
20
|
+
* leak from the parent shell: on the dev backend there is NO plugin to yield to
|
|
21
|
+
* (it is disabled in this repo, #365), so yielding would mean no channel at all
|
|
22
|
+
* (`-32000`, no registration).
|
|
23
|
+
*/
|
|
24
|
+
import { isDevBackend } from "./auth.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pure function of the environment — takes the env explicitly so it is testable
|
|
28
|
+
* without mutating `process.env`.
|
|
29
|
+
*
|
|
30
|
+
* `VIBER_BASE_URL` is defaulted to `""` HERE, deliberately: `isDevBackend()`
|
|
31
|
+
* falls back to `process.env` when its argument is `undefined`, which would let
|
|
32
|
+
* the real environment of a test runner decide the outcome.
|
|
33
|
+
*/
|
|
34
|
+
export function shouldBowOutToPlugin(
|
|
35
|
+
env: Record<string, string | undefined>,
|
|
36
|
+
): boolean {
|
|
37
|
+
return (
|
|
38
|
+
!isDevBackend(env.VIBER_BASE_URL ?? "") &&
|
|
39
|
+
env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
|
|
40
|
+
env.VIBER_CHANNEL_PLUGIN !== "1"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The stderr line emitted when the process bows out. Shared with the tests. */
|
|
45
|
+
export const BOW_OUT_MESSAGE =
|
|
46
|
+
"[viber-channel] agent session: yielding to the plugin channel copy (manual MCP registration exits)\n";
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.11",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/viber-channel.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { buildChannelInstructions, IDENTITY_BUDGET } from "./lib/channel_instruc
|
|
|
61
61
|
import { CLAUDE_TOOL_DEFS } from "./lib/claude_tool_defs.ts";
|
|
62
62
|
import { warnIfStale } from "./lib/version_check.ts";
|
|
63
63
|
import { capabilitiesText } from "./lib/capabilities.ts";
|
|
64
|
+
import { BOW_OUT_MESSAGE, shouldBowOutToPlugin } from "./lib/cli_bow_out.ts";
|
|
64
65
|
|
|
65
66
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
66
67
|
//
|
|
@@ -68,32 +69,6 @@ import { capabilitiesText } from "./lib/capabilities.ts";
|
|
|
68
69
|
// exits — it does not start the MCP server. Any other invocation (no args, or
|
|
69
70
|
// the unknown subcommand) falls through to the MCP server below.
|
|
70
71
|
{
|
|
71
|
-
// #280 step-29: in an AGENT session (vibe-master terminal), the channel must
|
|
72
|
-
// be the PLUGIN copy (`--channels plugin:viber-channel@…` — only it can
|
|
73
|
-
// inject inbound messages). The manually-registered viber-dev-channel MCP
|
|
74
|
-
// loads in the same session with the same env; instead of racing it for the
|
|
75
|
-
// channel lock, it bows out deterministically here. Normal sessions
|
|
76
|
-
// (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
|
|
77
|
-
//
|
|
78
|
-
// BUT the yield is a STAGING/PROD-only behavior: the plugin channel only runs
|
|
79
|
-
// there (it is disabled in the Viber dev repo, #365). On the DEV backend there
|
|
80
|
-
// is NO plugin to yield to, so yielding = no channel at all (`-32000`, no
|
|
81
|
-
// registration). Derive this from the environment (single source of truth,
|
|
82
|
-
// isDevBackend) instead of relying on VIBER_CHANNEL_AGENT_SESSION not leaking
|
|
83
|
-
// from the parent shell into the spawned dev terminal (the real bug: that var
|
|
84
|
-
// leaked and terminal.ts never cleared it, so the dev channel silently bowed
|
|
85
|
-
// out). Default (non-dev) behavior is unchanged.
|
|
86
|
-
if (
|
|
87
|
-
!isDevBackend() &&
|
|
88
|
-
process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
|
|
89
|
-
process.env.VIBER_CHANNEL_PLUGIN !== "1"
|
|
90
|
-
) {
|
|
91
|
-
process.stderr.write(
|
|
92
|
-
"[viber-channel] agent session: yielding to the plugin channel copy (manual MCP registration exits)\n",
|
|
93
|
-
);
|
|
94
|
-
process.exit(0);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
72
|
const sub = process.argv[2];
|
|
98
73
|
if (sub === "--help" || sub === "-h") {
|
|
99
74
|
process.stdout.write(
|
|
@@ -165,6 +140,23 @@ import { capabilitiesText } from "./lib/capabilities.ts";
|
|
|
165
140
|
}
|
|
166
141
|
}
|
|
167
142
|
|
|
143
|
+
// ---- Bow-out guard (#365) — MCP SERVER MODE ONLY ----
|
|
144
|
+
//
|
|
145
|
+
// THIS ORDER IS SIGNIFICANT (#495): the guard must stay AFTER the dispatch block
|
|
146
|
+
// above. Every branch of that block ends in `process.exit` on every path (catch
|
|
147
|
+
// included), so reaching this line means "no subcommand was handled" — i.e. this
|
|
148
|
+
// is an MCP server launch, the only mode where yielding to the plugin copy makes
|
|
149
|
+
// sense. Evaluating it earlier (as it was) made `enroll-runner`, `run-runner` and
|
|
150
|
+
// `connect` exit 0 without doing anything inside an agent session: silent, and
|
|
151
|
+
// falsely successful. Do NOT hoist this back up. Rationale: lib/cli_bow_out.ts.
|
|
152
|
+
//
|
|
153
|
+
// It also sits BEFORE warnIfStale() so the yielding process makes no network
|
|
154
|
+
// call on its way out.
|
|
155
|
+
if (shouldBowOutToPlugin(process.env)) {
|
|
156
|
+
process.stderr.write(BOW_OUT_MESSAGE);
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
168
160
|
// Server path (fell through the dispatch block): warn if this install is behind
|
|
169
161
|
// npm latest, but fire-and-forget so it never delays mcp.connect — the nudge
|
|
170
162
|
// lands on stderr shortly after startup (#330).
|
|
@@ -363,9 +355,10 @@ process.stdin.on("end", () => {
|
|
|
363
355
|
|
|
364
356
|
// ---- Auth from .viber/auth.json ----
|
|
365
357
|
|
|
358
|
+
// isDevBackend is no longer imported here: its only use in this file was the
|
|
359
|
+
// bow-out condition, now owned by lib/cli_bow_out.ts (#495).
|
|
366
360
|
import {
|
|
367
361
|
authFilePath,
|
|
368
|
-
isDevBackend,
|
|
369
362
|
loadAuth,
|
|
370
363
|
persistInstanceCredentials,
|
|
371
364
|
} from "./lib/auth.ts";
|
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}"`);
|