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.
package/README.md CHANGED
@@ -37,12 +37,40 @@ claude mcp add viber-channel --scope user \
37
37
 
38
38
  Restart Claude Code in the project directory. The channel acquires a single-instance lock under `%APPDATA%/viber/` (or `~/.config/viber/` on Linux/macOS), mints a conversation against the Worker, and subscribes to the conversation-scoped SSE stream.
39
39
 
40
+ ## Codex bridge experiment
41
+
42
+ Issue #252 adds a Codex bridge as a sibling process to the Claude Code MCP channel. The bridge joins an existing Viber conversation as its own fresh Viber instance, listens to SSE events, forwards user/user_voice messages into a bridge-owned persistent `codex app-server` thread, and posts Codex's final reply back to Viber.
43
+
44
+ Launch it against a conversation that already exists:
45
+
46
+ ```bash
47
+ bun run viber-codex-bridge.ts --conversation <conversation_id>
48
+ ```
49
+
50
+ Or via env fallback:
51
+
52
+ ```bash
53
+ VIBER_TARGET_CONVERSATION_ID=<conversation_id> bun run viber-codex-bridge.ts
54
+ ```
55
+
56
+ For one-shot validation, add `--once`; the bridge exits after the first Codex reply.
57
+
58
+ The bridge persists its Codex thread id in `.viber/codex-bridge-threads.json` by default, keyed by project/fingerprint/conversation. Restarting the bridge resumes that thread, so Codex context carries across Viber turns. To reset/respawn the bridge-owned Codex session, start with `--new-thread`.
59
+
60
+ Initial validation runs conservatively: `sandbox=read-only`, `approvalPolicy=on-request`, network disabled for turns, and developer instructions tell Codex not to run shell commands or read/write files. This is a validation posture, not a complete security boundary yet; hard permission enforcement must be designed before widening the bridge beyond read-only. Widen permissions only after the voice round-trip and memory test are validated.
61
+
62
+ By default the bridge registers a fresh instance and does not persist it into the shared auth file. This avoids rotating another live agent's per-membership conversation token. For diagnostics only, `--reuse-auth-instance` restores the shared-auth behavior.
63
+
64
+ For an agent-to-agent proof, add `--include-agent-messages --echo-content`. The bridge then reacts to non-user messages from other instances and replies with the exact content it received.
65
+
40
66
  ## Configuration
41
67
 
42
68
  | Env var | Default | Purpose |
43
69
  |---|---|---|
44
70
  | `VIBER_BASE_URL` | `https://viber.dgypx.dev` | Worker base URL (staging today; switch later if a separate prod hostname appears). |
45
71
  | `VIBER_CHANNEL_LABEL` | folder basename | Human-readable name for the conversation in the UI. |
72
+ | `VIBER_CODEX_BIN` | `codex` | Codex executable used by `viber-codex-bridge` to spawn `codex app-server`. |
73
+ | `VIBER_CODEX_BRIDGE_STATE` | `.viber/codex-bridge-threads.json` | Optional path for the bridge-owned Codex thread state file. |
46
74
  | `VIBER_CF_CLIENT_ID` / `VIBER_CF_CLIENT_SECRET` | unset | CF Access service-token headers (only needed when targeting a CF Access-protected hostname without a public bypass policy). |
47
75
 
48
76
  ## Source
@@ -0,0 +1,201 @@
1
+ /**
2
+ * agent_tools.ts — shared logic for the three channel tools (#317).
3
+ *
4
+ * The Claude MCP server (viber-channel.ts) and the Codex bridge
5
+ * (viber-codex-bridge.ts) must expose the SAME tools with the SAME behaviour —
6
+ * one source, no double maintenance. This module holds that behaviour as pure,
7
+ * context-injected functions; each host wraps them in a thin adapter (an MCP
8
+ * CallTool handler for Claude, an in-process MCP server for Codex).
9
+ *
10
+ * The context is read through LIVE getters, never a snapshot: the host's
11
+ * conversation token rotates (token refresh / re-mint) and these functions must
12
+ * always read the current value at call time. That is why the context exposes
13
+ * functions, not fields.
14
+ *
15
+ * messages.ts (postMessage/parseArtifact) and peers.ts (listPeers/openDm) stay
16
+ * pure HTTP clients — this module orchestrates them and maps results to the
17
+ * MCP tool-result shape. It does NOT hold state.
18
+ */
19
+ import { postMessage, parseArtifact, type Artifact } from "./messages.js";
20
+ import { listPeers, openDm, type OpenDmResult } from "./peers.js";
21
+ import { ConversationTokenExpiredError } from "./messages.js";
22
+
23
+ /** MCP tool-result shape (text content + optional error flag). */
24
+ export interface AgentToolResult {
25
+ isError?: boolean;
26
+ content: { type: "text"; text: string }[];
27
+ }
28
+
29
+ /**
30
+ * Live, by-reference access to the host channel's runtime state. Implemented by
31
+ * both hosts so the shared tool logic never reads a stale snapshot. Every getter
32
+ * is called at tool-invocation time.
33
+ */
34
+ export interface AgentToolsContext {
35
+ /** API base URL (e.g. https://viber-dev.dgypx.dev). */
36
+ baseUrl(): string;
37
+ /** This instance's durable instance_token (Bearer) for the agent endpoints. */
38
+ instanceToken(): string;
39
+ /** WebSocket base URL used to stream an opened DM (empty until ready). */
40
+ voiceBaseUrl(): string;
41
+ /** Conversation id send_message posts into (main conv, or the active DM). */
42
+ sendConversationId(): string;
43
+ /** Conversation token paired with sendConversationId(). */
44
+ sendConversationToken(): string;
45
+ /**
46
+ * Start streaming a freshly opened DM so the peer's reply surfaces back on the
47
+ * host channel. The host supplies its own ws base URL; the lib only forwards
48
+ * the DM coordinates. Fire-and-forget (parity with the Claude channel).
49
+ */
50
+ startDmStream(dm: OpenDmResult): void;
51
+ /**
52
+ * Optional: the current stream's AbortSignal, passed to postMessage so a post
53
+ * in flight is cancelled atomically if the stream is torn down (re-join /
54
+ * revoke / shutdown). The Claude channel returns undefined (it never passed a
55
+ * signal); the Codex bridge returns its per-turn stream signal (#303 parity).
56
+ */
57
+ abortSignal?(): AbortSignal | undefined;
58
+ }
59
+
60
+ function text(s: string): AgentToolResult {
61
+ return { content: [{ type: "text" as const, text: s }] };
62
+ }
63
+
64
+ function errorText(s: string): AgentToolResult {
65
+ return { isError: true, content: [{ type: "text" as const, text: s }] };
66
+ }
67
+
68
+ /**
69
+ * list_agents — return this project's other agents so the model can pick one to
70
+ * message. Guards the startup window: an empty instance token means "channel not
71
+ * ready" rather than hitting the peers endpoint with no credential.
72
+ */
73
+ export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResult> {
74
+ if (!ctx.instanceToken()) {
75
+ return errorText("Channel not ready: no instance identity yet.");
76
+ }
77
+ try {
78
+ const peers = await listPeers(ctx.baseUrl(), ctx.instanceToken());
79
+ // Project only what the model needs to pick a peer (drop last_seen /
80
+ // active_conversation_id — available over the wire if a future tool needs them).
81
+ const summary = peers.map((p) => ({ id: p.id, label: p.label, kind: p.kind, online: p.online }));
82
+ const body =
83
+ summary.length === 0
84
+ ? "No other agents are currently registered in this project."
85
+ : JSON.stringify(summary, null, 2);
86
+ return text(body);
87
+ } catch (err) {
88
+ return errorText(`list_agents failed: ${String(err)}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * message_agent — open/reuse a DM with a peer and post a message, then start
94
+ * streaming that DM so the peer's reply arrives back on this channel. Mirrors
95
+ * the Claude MCP behaviour exactly: structured outcomes for offline / transient
96
+ * conflict, a clean retry hint on an expired DM token, and the DM stream started
97
+ * BEFORE the post so we are subscribed before the peer can reply.
98
+ */
99
+ export async function messageAgent(
100
+ ctx: AgentToolsContext,
101
+ args: Record<string, unknown>,
102
+ ): Promise<AgentToolResult> {
103
+ if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
104
+ return errorText("Channel not ready: no instance identity yet.");
105
+ }
106
+ const peerId = args.instance_id;
107
+ const body = args.text;
108
+ if (typeof peerId !== "string" || peerId.trim().length === 0) {
109
+ return errorText("Invalid input: instance_id must be a non-empty string");
110
+ }
111
+ if (typeof body !== "string" || body.trim().length === 0) {
112
+ return errorText("Invalid input: text must be a non-empty string");
113
+ }
114
+ const trimmedPeerId = peerId.trim();
115
+
116
+ let outcome;
117
+ try {
118
+ outcome = await openDm(ctx.baseUrl(), ctx.instanceToken(), trimmedPeerId);
119
+ } catch (err) {
120
+ return errorText(`message_agent failed: ${String(err)}`);
121
+ }
122
+ if (!outcome.ok) {
123
+ let friendly: string;
124
+ if (outcome.detail === "target_offline") {
125
+ friendly = "That agent is offline right now — it must be running to receive a DM.";
126
+ } else if (outcome.detail === "dm_unavailable_retry") {
127
+ friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
128
+ } else {
129
+ friendly = `Could not open a DM: ${outcome.detail}`;
130
+ }
131
+ return errorText(friendly);
132
+ }
133
+
134
+ // Subscribe before posting so the peer's reply (which requires the peer agent
135
+ // to process the message, i.e. seconds) always loses the race to our subscribe.
136
+ ctx.startDmStream(outcome.dm);
137
+
138
+ let result;
139
+ try {
140
+ result = await postMessage(
141
+ ctx.baseUrl(),
142
+ outcome.dm.conversation_id,
143
+ outcome.dm.conversation_token,
144
+ body,
145
+ undefined,
146
+ ctx.abortSignal?.(),
147
+ );
148
+ } catch (err) {
149
+ // A DM membership token expired — unlike send_message we do NOT tear down the
150
+ // host here (the main conversation is unaffected). Tell the model to retry;
151
+ // a retry re-opens the DM with a fresh token.
152
+ if (err instanceof ConversationTokenExpiredError) {
153
+ return errorText("The DM session token expired — call message_agent again to re-open and resend.");
154
+ }
155
+ return errorText(`message_agent post failed: ${String(err)}`);
156
+ }
157
+ if (!result.ok) {
158
+ return errorText(result.detail);
159
+ }
160
+ return text(`Message sent to agent (DM ${outcome.dm.conversation_id}).`);
161
+ }
162
+
163
+ /**
164
+ * send_message — post text (+ optional artifact) into the conversation this turn
165
+ * belongs to. Guards the startup window (empty conversation token/id) and
166
+ * validates input identically to the Claude MCP.
167
+ *
168
+ * NB: a ConversationTokenExpiredError from postMessage is NOT caught here — the
169
+ * host adapter handles it (Claude tears down the channel; the bridge surfaces a
170
+ * controlled shutdown), because that policy differs per host.
171
+ */
172
+ export async function sendMessage(
173
+ ctx: AgentToolsContext,
174
+ args: Record<string, unknown>,
175
+ ): Promise<AgentToolResult> {
176
+ if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
177
+ return errorText("Channel not ready: no conversation yet.");
178
+ }
179
+ const body = args.text;
180
+ if (typeof body !== "string" || body.trim().length === 0) {
181
+ return errorText("Invalid input: text must be a non-empty string");
182
+ }
183
+ const parsed = parseArtifact(args.artifact);
184
+ if (!parsed.ok) {
185
+ return errorText(`Invalid input: ${parsed.error}`);
186
+ }
187
+ const artifact: Artifact | undefined = parsed.artifact;
188
+
189
+ const result = await postMessage(
190
+ ctx.baseUrl(),
191
+ ctx.sendConversationId(),
192
+ ctx.sendConversationToken(),
193
+ body,
194
+ artifact,
195
+ ctx.abortSignal?.(),
196
+ );
197
+ if (!result.ok) {
198
+ return errorText(result.detail);
199
+ }
200
+ return text(`Message sent: ${result.message_id}`);
201
+ }
package/lib/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
@@ -20,6 +20,17 @@ export interface AuthJson {
20
20
  * the first successful mint, but doing so is not required for correctness.
21
21
  */
22
22
  target_conversation_id?: string | null;
23
+ /**
24
+ * Server-issued per-instance identity (#269). Both optional + additive, so
25
+ * `schema_version` stays **1**: an existing auth.json with neither field is
26
+ * valid and self-heals on first run (the channel registers an instance with
27
+ * its stable project_token and writes these back). `instance_token` is the
28
+ * durable, opaque per-agent credential (D4); `instance_id` is its server id,
29
+ * used to namespace the conversation handle (replacing the local fingerprint
30
+ * + VIBER_CHANNEL_SESSION_ID axes, #259).
31
+ */
32
+ instance_token?: string;
33
+ instance_id?: string;
23
34
  }
24
35
 
25
36
  /**
@@ -68,3 +79,41 @@ export function loadAuth(cwd: string = process.cwd()): AuthJson {
68
79
  }
69
80
  return auth;
70
81
  }
82
+
83
+ /**
84
+ * Merge server-issued instance credentials into the auth file in place (#269
85
+ * self-healing migration). Reads the current JSON, sets `instance_id` +
86
+ * `instance_token`, and writes it back — preserving every other field and the
87
+ * `schema_version` (still 1; the fields are additive + optional).
88
+ *
89
+ * Best-effort: a write failure is logged, not fatal. The channel keeps the
90
+ * in-memory token for this run and simply re-registers on the next run (which
91
+ * yields a new instance — acceptable, the old one is just never reused).
92
+ */
93
+ export function persistInstanceCredentials(
94
+ path: string,
95
+ instanceId: string,
96
+ instanceToken: string,
97
+ log: (msg: string) => void = (m) => process.stderr.write(m),
98
+ ): void {
99
+ try {
100
+ const raw = readFileSync(path, "utf-8");
101
+ const obj = JSON.parse(raw) as AuthJson;
102
+ obj.instance_id = instanceId;
103
+ obj.instance_token = instanceToken;
104
+ // mode 0o600: the file holds long-lived secrets (project_token + now the
105
+ // instance_token) — owner-read-only on POSIX (no-op on Windows). `mode` only
106
+ // applies when CREATING the file, so chmod afterwards too, to tighten an
107
+ // already-loose auth.json from an older version (best-effort).
108
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
109
+ try {
110
+ chmodSync(path, 0o600);
111
+ } catch {
112
+ /* best-effort — some filesystems (or Windows) reject chmod */
113
+ }
114
+ } catch (err) {
115
+ log(
116
+ `[viber-channel] Warning: failed to persist instance credentials to \`${path}\`: ${String(err)}\n`,
117
+ );
118
+ }
119
+ }