viber-channel 0.5.3 → 0.7.0

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Bridge spawn adapter (#280 step-12, slice 3).
3
+ *
4
+ * Turns an `AgentSpec` into a real launched `viber-codex-bridge` child process and
5
+ * adapts it to the Supervisor's `ChildHandle`. The node `spawn` primitive is
6
+ * injected so the mapping (command, args, env, lifecycle wiring) is unit-testable
7
+ * without launching anything.
8
+ *
9
+ * Per-agent identity (critical): each child must register its OWN Viber instance.
10
+ * The supervisor process may itself have been launched with VIBER_INSTANCE_TOKEN /
11
+ * VIBER_INSTANCE_ID in its environment; if those leaked into every child, all
12
+ * agents would share one instance identity and evict each other's membership (the
13
+ * known shared-auth gotcha). So the adapter strips them from the child env, forcing
14
+ * the bridge's default "register a fresh instance" path.
15
+ */
16
+
17
+ import { spawn as nodeSpawn } from "node:child_process";
18
+ import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
19
+
20
+ /** Minimal child surface the adapter consumes (matches node's ChildProcess). */
21
+ export interface SpawnedProcess {
22
+ readonly pid?: number;
23
+ kill(signal?: NodeJS.Signals): boolean;
24
+ once(
25
+ event: "exit",
26
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
27
+ ): unknown;
28
+ /**
29
+ * Launch failures (ENOENT for a missing command, EACCES, …) arrive here as an
30
+ * async event — NOT a synchronous throw — so the adapter must listen for it or
31
+ * a failed launch would leave the agent "running" with a dead child.
32
+ */
33
+ once(event: "error", listener: (err: Error) => void): unknown;
34
+ }
35
+
36
+ /** The spawn primitive (injected; defaults to node's child_process.spawn). */
37
+ export type NodeSpawnLike = (
38
+ command: string,
39
+ args: string[],
40
+ options: {
41
+ env: NodeJS.ProcessEnv;
42
+ stdio: ("ignore" | "inherit" | "pipe")[];
43
+ },
44
+ ) => SpawnedProcess;
45
+
46
+ export interface BridgeSpawnOptions {
47
+ /** Launcher command. Default: the `viber-codex-bridge` bin on PATH. */
48
+ command?: string;
49
+ /** Args prepended before each agent's own args (e.g. a script path for `bun`). */
50
+ baseArgs?: string[];
51
+ /** Spawn primitive (injected for tests). Default: node child_process.spawn. */
52
+ spawnImpl?: NodeSpawnLike;
53
+ }
54
+
55
+ /**
56
+ * Build a `SpawnFn` for the Supervisor that launches the Codex bridge per agent.
57
+ *
58
+ * `AgentSpec.tier` → `VIBER_AGENT_TIER`; `AgentSpec.args` → bridge CLI flags;
59
+ * `AgentSpec.env` is merged last (so a spec can override). `VIBER_INSTANCE_TOKEN`
60
+ * / `VIBER_INSTANCE_ID` are stripped unless the spec explicitly re-supplies them.
61
+ */
62
+ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
63
+ const command = opts.command ?? "viber-codex-bridge";
64
+ const baseArgs = opts.baseArgs ?? [];
65
+ const spawnImpl = opts.spawnImpl ?? (nodeSpawn as unknown as NodeSpawnLike);
66
+
67
+ return (spec: AgentSpec): ChildHandle => {
68
+ const env: NodeJS.ProcessEnv = { ...process.env };
69
+ // Force a fresh per-agent instance (see file header).
70
+ delete env.VIBER_INSTANCE_TOKEN;
71
+ delete env.VIBER_INSTANCE_ID;
72
+ // #309 step-17: NEVER inherit the identity flag/role from the parent
73
+ // supervisor — a leaked VIBER_AGENT_NAME_EXPLICIT would make an auto id
74
+ // (agent-1) claim itself as an explicit name. Strip them here and re-derive
75
+ // authoritatively below (codex-309-review blocking #1).
76
+ delete env.VIBER_AGENT_NAME_EXPLICIT;
77
+ delete env.VIBER_AGENT_ROLE;
78
+ env.VIBER_AGENT_TIER = spec.tier;
79
+ if (spec.env) Object.assign(env, spec.env);
80
+ // Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
81
+ // config `env` block can spoof the explicit-name flag or the role — the
82
+ // spec fields are the only source of truth (codex-309-review blocking #1).
83
+ // Surface the agent id as the bridge's Viber instance label so distinct
84
+ // agents are identifiable in the control plane (participants strip / recipient
85
+ // picker) instead of all showing the same generic "Codex bridge" label.
86
+ env.VIBER_CODEX_BRIDGE_LABEL = spec.agentId;
87
+ // Only an EXPLICIT name is claimed as the agent's name (the label is always
88
+ // set, even for auto ids); the separate flag is the safe signal. `else delete`
89
+ // closes the spoof path even if spec.env smuggled the flag in.
90
+ if (spec.nameExplicit) env.VIBER_AGENT_NAME_EXPLICIT = "1";
91
+ else delete env.VIBER_AGENT_NAME_EXPLICIT;
92
+ // The role is untrusted text the bridge sanitizes before injecting it.
93
+ if (spec.role !== undefined) env.VIBER_AGENT_ROLE = spec.role;
94
+ else delete env.VIBER_AGENT_ROLE;
95
+
96
+ const child = spawnImpl(command, [...baseArgs, ...spec.args], {
97
+ env,
98
+ // stdin + stdout ignored, stderr inherited. stdout must NOT be inherited:
99
+ // if viber-channel runs over stdio MCP transport, the parent's stdout is
100
+ // the protocol wire and a child's stray stdout line would corrupt it.
101
+ stdio: ["ignore", "ignore", "inherit"],
102
+ });
103
+
104
+ return {
105
+ pid: child.pid,
106
+ kill: (signal?: NodeJS.Signals) => child.kill(signal),
107
+ onExit: (cb) => {
108
+ // Deliver exactly one terminal callback whether the child exits or
109
+ // fails to launch ("error"). A launch failure maps to a crash
110
+ // (null code/signal) so the supervisor runs its backoff path.
111
+ let fired = false;
112
+ const fire = (code: number | null, signal: NodeJS.Signals | null): void => {
113
+ if (fired) return;
114
+ fired = true;
115
+ cb(code, signal);
116
+ };
117
+ child.once("exit", (code, signal) => fire(code, signal));
118
+ child.once("error", () => fire(null, null));
119
+ },
120
+ };
121
+ };
122
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * bridge_tool_host.ts — in-process MCP tool host for the Codex bridge (#317 iii).
3
+ *
4
+ * The bridge hosts a streamable-HTTP MCP server on loopback; `codex app-server`
5
+ * connects to it BY URL (mcp_servers.<name>.url). Because the server runs INSIDE
6
+ * the bridge process (no child spawn), the tools read the bridge's LIVE context
7
+ * (rotating token, current-turn conversation) by reference — never a snapshot.
8
+ * This is the same `agent_tools.ts` the Claude MCP uses: one source, no double
9
+ * maintenance.
10
+ *
11
+ * Security (viber-dev-315): the server binds to 127.0.0.1 only and requires an
12
+ * ephemeral bearer token, so no other local process can post into the channel
13
+ * through it.
14
+ *
15
+ * Anti-double-post (#317 #D): the bridge auto-posts a turn's final text only when
16
+ * NO outbound tool succeeded that turn. The host reports a SUCCESSFUL
17
+ * send_message / message_agent via `onOutboundSuccess` so the caller can suppress
18
+ * the auto-post. A FAILED outbound does NOT report success → the auto-post
19
+ * fallback still fires (no silent void). list_agents is read-only and never
20
+ * reports outbound.
21
+ */
22
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
23
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
24
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
25
+ import {
26
+ listAgents,
27
+ messageAgent,
28
+ sendMessage,
29
+ type AgentToolsContext,
30
+ type AgentToolResult,
31
+ } from "./agent_tools.js";
32
+
33
+ /** The three channel tools, named so codex (and the elicitation whitelist) match. */
34
+ export const BRIDGE_TOOL_NAMES = ["send_message", "list_agents", "message_agent"] as const;
35
+ export type BridgeToolName = (typeof BRIDGE_TOOL_NAMES)[number];
36
+
37
+ /** Outbound tools whose SUCCESS suppresses the bridge's final auto-post. */
38
+ const OUTBOUND_TOOLS: ReadonlySet<string> = new Set(["send_message", "message_agent"]);
39
+
40
+ const TOOL_DEFS = [
41
+ {
42
+ name: "send_message",
43
+ description:
44
+ "Post your reply into the current Viber conversation. `text` is the conversational reply — " +
45
+ "read aloud AND shown as Markdown, so keep it short and natural but use light Markdown (short " +
46
+ "lists, numbered steps, **bold**) for readability; put actions/questions here, visibly. " +
47
+ "`artifact` is optional HEAVY/LONG content (big code, large tables, long analyses, json, " +
48
+ "sanitized html) shown in a side viewer; when used, keep `text` a brief summary pointing to it. " +
49
+ "Calling this IS your reply for the turn.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ text: { type: "string", description: "Spoken/conversational reply. Plain sentences." },
54
+ artifact: {
55
+ type: "object",
56
+ properties: {
57
+ content: { type: "string" },
58
+ format: { type: "string", enum: ["markdown", "code", "json", "html"] },
59
+ },
60
+ required: ["content"],
61
+ additionalProperties: false,
62
+ },
63
+ },
64
+ required: ["text"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ {
69
+ name: "list_agents",
70
+ description:
71
+ "List the OTHER agents (instances) in this project you can DM. Returns each agent's id, " +
72
+ "label, runtime kind, and whether it is online. Use it to find an id before message_agent.",
73
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
74
+ },
75
+ {
76
+ name: "message_agent",
77
+ description:
78
+ "Send a direct message to another agent in this project. Pass the target agent's instance id " +
79
+ "(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
80
+ "the agent's reply arrives back on this channel. Fails if the target agent is offline.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ instance_id: { type: "string", description: "Target agent's instance id (from list_agents)." },
85
+ text: { type: "string", description: "Message to send to the target agent." },
86
+ },
87
+ required: ["instance_id", "text"],
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ ] as const;
92
+
93
+ export interface BridgeToolHost {
94
+ /** URL codex connects to: http://127.0.0.1:<port>/mcp */
95
+ url: string;
96
+ /** Ephemeral bearer token value the host requires (pass to codex via env). */
97
+ bearerToken: string;
98
+ /** Stop the HTTP server. */
99
+ stop(): void;
100
+ }
101
+
102
+ export interface BridgeToolHostOptions {
103
+ /** Live bridge context (per-turn conversation, rotating token, DM streaming). */
104
+ ctx: AgentToolsContext;
105
+ /** Called with the tool name when an OUTBOUND tool (send_message/message_agent) SUCCEEDS. */
106
+ onOutboundSuccess: (tool: BridgeToolName) => void;
107
+ /** stderr-style logger. */
108
+ log: (line: string) => void;
109
+ /** Generate the ephemeral bearer token (injected for testability). */
110
+ makeToken: () => string;
111
+ }
112
+
113
+ /**
114
+ * Dispatch a single tool call to the shared lib and, on a successful outbound,
115
+ * report it. Exported for unit testing without spinning up the HTTP server.
116
+ */
117
+ export async function dispatchBridgeTool(
118
+ name: string,
119
+ args: Record<string, unknown>,
120
+ opts: Pick<BridgeToolHostOptions, "ctx" | "onOutboundSuccess">,
121
+ ): Promise<AgentToolResult> {
122
+ let result: AgentToolResult;
123
+ if (name === "list_agents") {
124
+ result = await listAgents(opts.ctx);
125
+ } else if (name === "message_agent") {
126
+ result = await messageAgent(opts.ctx, args);
127
+ } else if (name === "send_message") {
128
+ result = await sendMessage(opts.ctx, args);
129
+ } else {
130
+ return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
131
+ }
132
+ // Only a SUCCESSFUL outbound suppresses the auto-post (#317 #D: success, not attempt).
133
+ if (OUTBOUND_TOOLS.has(name) && result.isError !== true) {
134
+ opts.onOutboundSuccess(name as BridgeToolName);
135
+ }
136
+ return result;
137
+ }
138
+
139
+ function buildServer(opts: BridgeToolHostOptions): Server {
140
+ const mcp = new Server({ name: "viber-channel-tools", version: "0.1.0" }, { capabilities: { tools: {} } });
141
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS as unknown as object[] }));
142
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
143
+ const args = (request.params.arguments ?? {}) as Record<string, unknown>;
144
+ try {
145
+ return await dispatchBridgeTool(request.params.name, args, opts);
146
+ } catch (err) {
147
+ // The shared lib rethrows ConversationTokenExpiredError (host-specific
148
+ // teardown). In the bridge we surface it as a tool error rather than
149
+ // killing the turn mid-flight: the stream's own next op hits 401 and the
150
+ // refresh/abort path handles renewal. NOT reported as outbound success.
151
+ opts.log(`[bridge-tool-host] tool ${request.params.name} threw: ${String(err)}\n`);
152
+ return { isError: true, content: [{ type: "text", text: `Tool error: ${String(err)}` }] };
153
+ }
154
+ });
155
+ return mcp;
156
+ }
157
+
158
+ /**
159
+ * Start the loopback MCP HTTP host. Returns the URL + bearer for codex config.
160
+ * Stateless transport → a fresh Server+transport per request (SDK requirement).
161
+ */
162
+ export function startBridgeToolHost(opts: BridgeToolHostOptions): BridgeToolHost {
163
+ const bearerToken = opts.makeToken();
164
+ const server = Bun.serve({
165
+ hostname: "127.0.0.1",
166
+ port: 0,
167
+ async fetch(req) {
168
+ const auth = req.headers.get("authorization") ?? "";
169
+ if (auth !== `Bearer ${bearerToken}`) {
170
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
171
+ status: 401,
172
+ headers: { "content-type": "application/json" },
173
+ });
174
+ }
175
+ const mcp = buildServer(opts);
176
+ const transport = new WebStandardStreamableHTTPServerTransport({
177
+ sessionIdGenerator: undefined,
178
+ enableJsonResponse: true,
179
+ });
180
+ await mcp.connect(transport);
181
+ return transport.handleRequest(req);
182
+ },
183
+ });
184
+ const url = `http://127.0.0.1:${server.port}/mcp`;
185
+ opts.log(`[bridge-tool-host] MCP tools on ${url} (loopback, bearer-guarded)\n`);
186
+ return {
187
+ url,
188
+ bearerToken,
189
+ stop: () => server.stop(true),
190
+ };
191
+ }
@@ -5,16 +5,17 @@
5
5
  * conversation_id here. A respawned channel process reads this file to find the
6
6
  * existing conversation instead of minting a new one — avoiding duplicates (#257).
7
7
  *
8
- * Namespaced by `(VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID)` so:
8
+ * Namespaced by `(VIBER_BASE_URL, instance_key)` since #269, where `instance_key`
9
+ * is the server-issued `instance_id` (or the `instance_token` when the id is
10
+ * unknown, e.g. an env-injected agent). This replaces the old
11
+ * `(client_fingerprint, VIBER_CHANNEL_SESSION_ID)` namespacing so:
9
12
  * - dev and staging channels coexist on the same machine without colliding;
10
- * - two *different* projects on the same backend each have their own handle and
11
- * never reattach to each other's conversation — the fingerprint axis is the
12
- * only thing that isolates them for a normal bunx client (#267), where the
13
- * sessionId is always "";
14
- * - two concurrent launches of the *same* project (each with its own session id
15
- * set by viber-dev.ps1 / viber.ps1) each have their own handle (#259);
16
- * - channel respawns within one launch inherit the same session id and find
17
- * the same handle — reattach still works (#257).
13
+ * - two *different* instances (different projects, OR two parallel agents of the
14
+ * same project) each have their own handle and never reattach to each other's
15
+ * conversation — the instance is the isolation axis now, no fingerprint or
16
+ * session id required (#259);
17
+ * - channel respawns reusing the same persisted instance_token inherit the same
18
+ * instance_id and find the same handle — reattach still works (#257).
18
19
  */
19
20
  import { createHash } from "node:crypto";
20
21
  import { join } from "node:path";
@@ -26,22 +27,24 @@ export interface ChannelHandle {
26
27
  }
27
28
 
28
29
  /**
29
- * Return the session file path for a given base URL, fingerprint, and session
30
- * id, under `dir`.
30
+ * Return the session file path for a given base URL and instance key, under `dir`.
31
31
  *
32
32
  * The filename is `channel-session-{8-hex}.json` where the hex is a stable
33
- * SHA-256 prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` — the same input
34
- * shape as `lockFilePath`, so the lock and session files sit side by side for
35
- * the same `(baseUrl, fingerprint, sessionId)` triple.
33
+ * SHA-256 prefix of `${baseUrl}\n${instanceKey}`. Since #269 the handle is
34
+ * namespaced by the **server-issued instance** (its `instance_id`, or the
35
+ * `instance_token` when the id is unknown) instead of the local
36
+ * `(fingerprint, sessionId)` pair: one durable instance ⇒ one handle. Two
37
+ * parallel agents of the same project hold distinct instances, so they get
38
+ * distinct handles and never reattach to each other's conversation — without
39
+ * relying on `VIBER_CHANNEL_SESSION_ID`.
36
40
  */
37
41
  export function sessionFilePath(
38
42
  baseUrl: string,
39
- fingerprint: string,
40
- sessionId: string,
43
+ instanceKey: string,
41
44
  dir: string,
42
45
  ): string {
43
46
  const suffix = createHash("sha256")
44
- .update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
47
+ .update(`${baseUrl}\n${instanceKey}`)
45
48
  .digest("hex")
46
49
  .slice(0, 8);
47
50
  return join(dir, `channel-session-${suffix}.json`);
package/lib/connect.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import {
14
14
  appendFileSync,
15
+ chmodSync,
15
16
  existsSync,
16
17
  mkdirSync,
17
18
  readFileSync,
@@ -106,7 +107,15 @@ function writeAuthJson(
106
107
  auth.target_conversation_id = data.target_conversation_id;
107
108
  }
108
109
 
109
- writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf-8");
110
+ // mode 0o600: auth.json holds the project_token (and later the instance_token)
111
+ // — owner-read-only on POSIX (no-op on Windows). chmod afterwards too, since
112
+ // `mode` only applies on file creation (tightens an older loose auth.json).
113
+ writeFileSync(authPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 0o600 });
114
+ try {
115
+ chmodSync(authPath, 0o600);
116
+ } catch {
117
+ /* best-effort — some filesystems (or Windows) reject chmod */
118
+ }
110
119
  writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
111
120
 
112
121
  // Append `.viber/` to .gitignore if not already present.