viber-channel 0.5.2 → 0.6.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 +28 -0
- package/lib/auth.ts +69 -21
- package/lib/bridge_spawn.ts +105 -0
- package/lib/channel_errors.ts +2 -2
- package/lib/channel_session.ts +107 -0
- package/lib/connect.ts +34 -9
- package/lib/control_stream.ts +307 -0
- package/lib/conversation.ts +158 -21
- package/lib/instance.ts +137 -0
- package/lib/lockfile.ts +35 -10
- package/lib/message_dedup.ts +47 -0
- package/lib/messages.ts +24 -0
- package/lib/self_echo.ts +17 -0
- package/lib/startup_gate.ts +19 -0
- package/lib/supervisor.ts +292 -0
- package/lib/supervisor_config.ts +121 -0
- package/package.json +7 -2
- package/viber-channel.ts +280 -47
- package/viber-codex-bridge.ts +1247 -0
- package/viber-codex-supervisor.ts +96 -0
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* viber-codex-bridge — Codex runtime bridge for issue #252.
|
|
4
|
+
*
|
|
5
|
+
* Joins an existing Viber conversation as a distinct instance, forwards user
|
|
6
|
+
* messages into a bridge-owned persistent Codex app-server thread, and posts
|
|
7
|
+
* Codex's final assistant reply back into Viber.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
13
|
+
import {
|
|
14
|
+
ConversationMintError,
|
|
15
|
+
RefreshHttpError,
|
|
16
|
+
RefreshNetworkError,
|
|
17
|
+
defaultLabel,
|
|
18
|
+
reattachConversation,
|
|
19
|
+
refreshConversationToken,
|
|
20
|
+
type ConversationMintResponse,
|
|
21
|
+
} from "./lib/conversation.ts";
|
|
22
|
+
import { sessionFilePath, writeHandle } from "./lib/channel_session.ts";
|
|
23
|
+
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
24
|
+
import { acquireInstance, registerInstance } from "./lib/instance.ts";
|
|
25
|
+
import type { AuthJson } from "./lib/auth.ts";
|
|
26
|
+
import { postMessage, fetchMessages, ConversationTokenExpiredError } from "./lib/messages.ts";
|
|
27
|
+
import { buildSseUrl } from "./lib/urls.ts";
|
|
28
|
+
import {
|
|
29
|
+
runPersistentControlStream,
|
|
30
|
+
type PersistentControlStream,
|
|
31
|
+
ControlStreamStopped,
|
|
32
|
+
ControlStreamAuthError,
|
|
33
|
+
} from "./lib/control_stream.ts";
|
|
34
|
+
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
35
|
+
import { createTokenRefreshScheduler, type TokenRefreshScheduler } from "./lib/token_refresh.ts";
|
|
36
|
+
import { lockFilePath } from "./lib/lockfile.ts";
|
|
37
|
+
|
|
38
|
+
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
39
|
+
const LOCK_DIR =
|
|
40
|
+
process.env.APPDATA
|
|
41
|
+
? join(process.env.APPDATA, "viber")
|
|
42
|
+
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
43
|
+
|
|
44
|
+
interface BridgeOptions {
|
|
45
|
+
conversationId: string;
|
|
46
|
+
once: boolean;
|
|
47
|
+
newThread: boolean;
|
|
48
|
+
reuseAuthInstance: boolean;
|
|
49
|
+
includeAgentMessages: boolean;
|
|
50
|
+
echoContent: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* #280 step-03: instead of a CLI/env conversation id, subscribe to the
|
|
53
|
+
* instance control stream and wait for a server-pushed `join` event. The
|
|
54
|
+
* conversation id + token arrive in-band — nothing is hand-carried.
|
|
55
|
+
*/
|
|
56
|
+
awaitInvite: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ConversationMessage {
|
|
60
|
+
id?: string | number | null;
|
|
61
|
+
content?: string;
|
|
62
|
+
role?: string;
|
|
63
|
+
source?: string;
|
|
64
|
+
sender_instance_id?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface JsonRpcMessage {
|
|
68
|
+
id?: string | number;
|
|
69
|
+
method?: string;
|
|
70
|
+
params?: unknown;
|
|
71
|
+
result?: unknown;
|
|
72
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PendingRequest {
|
|
76
|
+
resolve: (value: unknown) => void;
|
|
77
|
+
reject: (err: Error) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface TurnWaiter {
|
|
81
|
+
threadId: string;
|
|
82
|
+
turnId: string;
|
|
83
|
+
deltas: string[];
|
|
84
|
+
resolve: (value: string) => void;
|
|
85
|
+
reject: (err: Error) => void;
|
|
86
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ThreadState {
|
|
90
|
+
schema_version: 1;
|
|
91
|
+
threads: Record<string, {
|
|
92
|
+
thread_id: string;
|
|
93
|
+
conversation_id: string;
|
|
94
|
+
project_id: number;
|
|
95
|
+
client_fingerprint: string;
|
|
96
|
+
cwd: string;
|
|
97
|
+
viber_instance_id?: string;
|
|
98
|
+
updated_at: string;
|
|
99
|
+
}>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ConversationRuntime {
|
|
103
|
+
id: string;
|
|
104
|
+
token: string;
|
|
105
|
+
expiresAt: number;
|
|
106
|
+
scheduler?: TokenRefreshScheduler;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class BridgeShutdownError extends Error {
|
|
110
|
+
constructor(message: string, public readonly exitCode: number) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "BridgeShutdownError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface BridgeLock {
|
|
117
|
+
path: string;
|
|
118
|
+
release: () => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usage(): string {
|
|
122
|
+
return [
|
|
123
|
+
"viber-codex-bridge — join an existing Viber conversation as a Codex bridge",
|
|
124
|
+
"",
|
|
125
|
+
"USAGE",
|
|
126
|
+
" bun run viber-codex-bridge.ts --conversation <conversation_id>",
|
|
127
|
+
" viber-codex-bridge --conversation <conversation_id>",
|
|
128
|
+
"",
|
|
129
|
+
"OPTIONS",
|
|
130
|
+
" --conversation <id> Existing conversation id to join. Wins over VIBER_TARGET_CONVERSATION_ID.",
|
|
131
|
+
" --once Exit after posting the first fixed response.",
|
|
132
|
+
" --new-thread Start a fresh bridge-owned Codex thread and persist it for this conversation.",
|
|
133
|
+
" --include-agent-messages",
|
|
134
|
+
" React to non-user messages from other instances too.",
|
|
135
|
+
" --echo-content Reply by echoing the exact received message content.",
|
|
136
|
+
" --reuse-auth-instance",
|
|
137
|
+
" Reuse auth.json's persisted instance_token. Off by default because it",
|
|
138
|
+
" can rotate another live agent's membership token.",
|
|
139
|
+
" -h, --help Show this help.",
|
|
140
|
+
"",
|
|
141
|
+
"ENV",
|
|
142
|
+
" VIBER_TARGET_CONVERSATION_ID Fallback conversation id.",
|
|
143
|
+
" VIBER_CODEX_BRIDGE_REPLY Fixed reply text for the canal-nu test.",
|
|
144
|
+
" VIBER_CODEX_BRIDGE_STATE Optional path for the bridge-owned Codex thread state file.",
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): BridgeOptions {
|
|
149
|
+
let conversationId = env.VIBER_TARGET_CONVERSATION_ID?.trim() ?? "";
|
|
150
|
+
let once = false;
|
|
151
|
+
let newThread = false;
|
|
152
|
+
let reuseAuthInstance = false;
|
|
153
|
+
let includeAgentMessages = false;
|
|
154
|
+
let echoContent = false;
|
|
155
|
+
let awaitInvite = false;
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < argv.length; i++) {
|
|
158
|
+
const arg = argv[i];
|
|
159
|
+
if (arg === "-h" || arg === "--help") {
|
|
160
|
+
process.stdout.write(`${usage()}\n`);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
if (arg === "--once") {
|
|
164
|
+
once = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (arg === "--new-thread") {
|
|
168
|
+
newThread = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (arg === "--reuse-auth-instance") {
|
|
172
|
+
reuseAuthInstance = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (arg === "--include-agent-messages") {
|
|
176
|
+
includeAgentMessages = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (arg === "--echo-content") {
|
|
180
|
+
echoContent = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (arg === "--await-invite") {
|
|
184
|
+
awaitInvite = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (arg === "--conversation") {
|
|
188
|
+
const value = argv[i + 1];
|
|
189
|
+
if (value === undefined || value.startsWith("-")) {
|
|
190
|
+
throw new Error("Missing value for --conversation");
|
|
191
|
+
}
|
|
192
|
+
conversationId = value.trim();
|
|
193
|
+
i += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (arg.startsWith("--conversation=")) {
|
|
197
|
+
conversationId = arg.slice("--conversation=".length).trim();
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// In await-invite mode the conversation id is delivered by the server's pushed
|
|
204
|
+
// `join`, so it must NOT be required up front (#280 step-03).
|
|
205
|
+
if (conversationId.length === 0 && !awaitInvite) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"Missing target conversation id. Pass --conversation <id>, set VIBER_TARGET_CONVERSATION_ID, or use --await-invite.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { conversationId, once, newThread, reuseAuthInstance, includeAgentMessages, echoContent, awaitInvite };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sleep(ms: number): Promise<void> {
|
|
215
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function headers(conversationToken: string, fingerprint: string): Record<string, string> {
|
|
219
|
+
return {
|
|
220
|
+
Authorization: `Bearer ${conversationToken}`,
|
|
221
|
+
"X-Client-Fingerprint": fingerprint,
|
|
222
|
+
...cfAccessHeaders(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isProcessAlive(pid: number): boolean {
|
|
227
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 0);
|
|
230
|
+
return true;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `sessionId` is the lock identity axis. #280 step-12 keys it per AGENT
|
|
238
|
+
// (`codex-agent:<instanceId>`) so multiple agents share one conversation; before
|
|
239
|
+
// that it was per conversation. Callers pass the full namespaced string.
|
|
240
|
+
function acquireBridgeLock(baseUrl: string, fingerprint: string, sessionId: string): BridgeLock {
|
|
241
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
242
|
+
const path = lockFilePath(baseUrl, fingerprint, sessionId, LOCK_DIR);
|
|
243
|
+
const release = (): void => {
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(path);
|
|
246
|
+
} catch {
|
|
247
|
+
// Best effort: the file may already be gone.
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
252
|
+
try {
|
|
253
|
+
writeFileSync(path, `${process.pid}\n`, { flag: "wx" });
|
|
254
|
+
process.stderr.write(`[viber-codex-bridge] lock acquired: ${path}\n`);
|
|
255
|
+
return { path, release };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (typeof err !== "object" || err === null || !("code" in err) || err.code !== "EEXIST") {
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let existingPid = Number.NaN;
|
|
262
|
+
try {
|
|
263
|
+
existingPid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
264
|
+
} catch {
|
|
265
|
+
release();
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!Number.isFinite(existingPid) || !isProcessAlive(existingPid)) {
|
|
270
|
+
release();
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw new BridgeShutdownError(
|
|
275
|
+
`Another viber-codex-bridge already holds the lock ${sessionId} (PID ${existingPid})`,
|
|
276
|
+
1,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
throw new BridgeShutdownError(`Failed to acquire viber-codex-bridge lock ${sessionId}`, 1);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function shouldHandleMessage(
|
|
285
|
+
msg: ConversationMessage,
|
|
286
|
+
ownInstanceKey: string,
|
|
287
|
+
ownPostedIds: Set<string>,
|
|
288
|
+
options: BridgeOptions,
|
|
289
|
+
): boolean {
|
|
290
|
+
if (msg.id !== undefined && msg.id !== null && ownPostedIds.has(String(msg.id))) return false;
|
|
291
|
+
if (msg.sender_instance_id && msg.sender_instance_id === ownInstanceKey) return false;
|
|
292
|
+
if (typeof msg.content !== "string" || msg.content.trim().length === 0) return false;
|
|
293
|
+
|
|
294
|
+
// Human browser text is role=user; persisted voice transcripts are
|
|
295
|
+
// role=user_voice; other agents (role=channel, etc.) only when enabled.
|
|
296
|
+
if (msg.role === "user" || msg.role === "user_voice") return true;
|
|
297
|
+
return options.includeAgentMessages;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function senderLabel(msg: ConversationMessage): string {
|
|
301
|
+
if (msg.sender_instance_id) return `instance ${msg.sender_instance_id.slice(0, 8)}`;
|
|
302
|
+
if (msg.role) return msg.role;
|
|
303
|
+
if (msg.source) return msg.source;
|
|
304
|
+
return "unknown";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildReply(receivedContent: string, msg: ConversationMessage | undefined, options: BridgeOptions): string {
|
|
308
|
+
if (!options.echoContent) {
|
|
309
|
+
return process.env.VIBER_CODEX_BRIDGE_REPLY ??
|
|
310
|
+
"Codex bridge received the message and can reply through Viber.";
|
|
311
|
+
}
|
|
312
|
+
const sender = msg ? senderLabel(msg) : "transcription";
|
|
313
|
+
return `Codex received from ${sender}: "${receivedContent}"`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function safeErrorMessage(err: unknown): string {
|
|
317
|
+
return err instanceof Error ? err.message : String(err);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function refreshLeadSeconds(): number | undefined {
|
|
321
|
+
const raw = process.env.VIBER_REFRESH_LEAD_SECONDS;
|
|
322
|
+
if (raw === undefined) return undefined;
|
|
323
|
+
const parsed = Number.parseInt(raw, 10);
|
|
324
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
325
|
+
process.stderr.write(`[viber-codex-bridge] invalid VIBER_REFRESH_LEAD_SECONDS=${raw}, ignoring\n`);
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
process.stderr.write(`[viber-codex-bridge] token refresh lead overridden to ${parsed}s\n`);
|
|
329
|
+
return parsed;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function startTokenRefresh(
|
|
333
|
+
runtime: ConversationRuntime,
|
|
334
|
+
fingerprint: string,
|
|
335
|
+
requestShutdown: (err: BridgeShutdownError) => void,
|
|
336
|
+
): TokenRefreshScheduler {
|
|
337
|
+
const leadSeconds = refreshLeadSeconds();
|
|
338
|
+
const scheduler = createTokenRefreshScheduler(
|
|
339
|
+
async () => {
|
|
340
|
+
const maxAttempts = 5;
|
|
341
|
+
const headroomSeconds = 10;
|
|
342
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
343
|
+
try {
|
|
344
|
+
const refreshed = await refreshConversationToken(BASE_URL, runtime.id, runtime.token, fingerprint);
|
|
345
|
+
runtime.token = refreshed.conversation_token;
|
|
346
|
+
runtime.expiresAt = refreshed.expires_at;
|
|
347
|
+
process.stderr.write(`[viber-codex-bridge] token refreshed for conversation ${runtime.id}\n`);
|
|
348
|
+
return { expiresAt: refreshed.expires_at };
|
|
349
|
+
} catch (err) {
|
|
350
|
+
const retryable =
|
|
351
|
+
(err instanceof RefreshHttpError && err.retryable) ||
|
|
352
|
+
err instanceof RefreshNetworkError;
|
|
353
|
+
if (!retryable || attempt === maxAttempts) throw err;
|
|
354
|
+
|
|
355
|
+
const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
|
|
356
|
+
const remaining = runtime.expiresAt - Date.now() / 1000;
|
|
357
|
+
if (remaining - proposedDelay < headroomSeconds) throw err;
|
|
358
|
+
process.stderr.write(
|
|
359
|
+
`[viber-codex-bridge] token refresh attempt ${attempt} failed (${safeErrorMessage(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
|
|
360
|
+
);
|
|
361
|
+
await sleep(proposedDelay * 1000);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw new Error("token refresh: retry loop exhausted without resolution");
|
|
365
|
+
},
|
|
366
|
+
async (err) => {
|
|
367
|
+
process.stderr.write(`[viber-codex-bridge] token refresh failed: ${safeErrorMessage(err)}\n`);
|
|
368
|
+
requestShutdown(new BridgeShutdownError("conversation token refresh failed", 3));
|
|
369
|
+
},
|
|
370
|
+
leadSeconds !== undefined ? { leadSeconds, log: (line) => process.stderr.write(`${line}\n`) } : {
|
|
371
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
scheduler.start(runtime.expiresAt);
|
|
375
|
+
runtime.scheduler = scheduler;
|
|
376
|
+
return scheduler;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// #280 step-12: the state file is per AGENT. Several agents on one machine each own
|
|
380
|
+
// their own file, so concurrent read-modify-write of a single shared JSON can't drop
|
|
381
|
+
// another agent's thread entry (Codex review P2-A). An explicit
|
|
382
|
+
// VIBER_CODEX_BRIDGE_STATE override still wins (single file, caller's responsibility).
|
|
383
|
+
function stateFilePath(instanceId?: string, cwd: string = process.cwd()): string {
|
|
384
|
+
const override = process.env.VIBER_CODEX_BRIDGE_STATE;
|
|
385
|
+
if (override !== undefined && override.trim() !== "") return override;
|
|
386
|
+
// Full instance id in the filename (server-issued opaque hex): a truncated id
|
|
387
|
+
// could collide and reintroduce the shared-file write race this split avoids.
|
|
388
|
+
const name =
|
|
389
|
+
instanceId && instanceId.trim() !== ""
|
|
390
|
+
? `codex-bridge-threads-${instanceId}.json`
|
|
391
|
+
: "codex-bridge-threads.json";
|
|
392
|
+
return join(dirname(authFilePath(cwd)), name);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// #280 step-12: the key is per (conversation, AGENT), not per conversation. With
|
|
396
|
+
// several agents sharing one conversation, a conversation-only key would make them
|
|
397
|
+
// resume/clobber the same Codex thread. Including the instance id gives each agent
|
|
398
|
+
// its own persisted thread.
|
|
399
|
+
export function stateKey(
|
|
400
|
+
projectId: number,
|
|
401
|
+
fingerprint: string,
|
|
402
|
+
conversationId: string,
|
|
403
|
+
instanceId: string,
|
|
404
|
+
): string {
|
|
405
|
+
return `${projectId}:${fingerprint}:${conversationId}:${instanceId}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function readThreadState(path: string): ThreadState {
|
|
409
|
+
if (!existsSync(path)) return { schema_version: 1, threads: {} };
|
|
410
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as ThreadState;
|
|
411
|
+
if (parsed.schema_version !== 1 || typeof parsed.threads !== "object" || parsed.threads === null) {
|
|
412
|
+
throw new Error(`Unsupported Codex bridge state file at ${path}`);
|
|
413
|
+
}
|
|
414
|
+
return parsed;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function writeThreadState(path: string, state: ThreadState): void {
|
|
418
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
419
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function extractThreadId(response: unknown): string {
|
|
423
|
+
if (
|
|
424
|
+
typeof response === "object" &&
|
|
425
|
+
response !== null &&
|
|
426
|
+
"thread" in response &&
|
|
427
|
+
typeof (response as { thread?: { id?: unknown } }).thread?.id === "string"
|
|
428
|
+
) {
|
|
429
|
+
return (response as { thread: { id: string } }).thread.id;
|
|
430
|
+
}
|
|
431
|
+
throw new Error("Codex app-server response did not include thread.id");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Agent permission tiers (#280 step-11) -------------------------------
|
|
435
|
+
//
|
|
436
|
+
// An agent's capability level is chosen at launch via VIBER_AGENT_TIER, not
|
|
437
|
+
// hardcoded. One knob maps to the (sandbox, instructions) pair so the three
|
|
438
|
+
// Codex call sites (thread/start, thread/resume, turn/start) cannot drift.
|
|
439
|
+
//
|
|
440
|
+
// chat (default) — answer only; no file read, no write, no shell.
|
|
441
|
+
// read — may READ the workspace (review plans/code); no write, no shell.
|
|
442
|
+
// write — read + write; DEFERRED behind the #252 safe-mode gate.
|
|
443
|
+
//
|
|
444
|
+
// A `read-only` Codex sandbox already permits reads — only the instructions
|
|
445
|
+
// forbade them — so `read` keeps the read-only sandbox and just relaxes the
|
|
446
|
+
// instructions. `write` would additionally need a workspace-write sandbox; it is
|
|
447
|
+
// intentionally not enabled yet (falls back to `chat` with a warning).
|
|
448
|
+
type AgentTier = "chat" | "read" | "write";
|
|
449
|
+
|
|
450
|
+
function resolveAgentTier(): AgentTier {
|
|
451
|
+
const raw = (process.env.VIBER_AGENT_TIER ?? "chat").trim().toLowerCase();
|
|
452
|
+
if (raw === "chat" || raw === "read") return raw;
|
|
453
|
+
if (raw === "write") {
|
|
454
|
+
// #280 step-17: write enables a workspace-write sandbox — the agent can edit
|
|
455
|
+
// files and run state-changing commands WITHOUT approval. Enabled by explicit
|
|
456
|
+
// operator choice at launch (the #252 read-only gate is lifted deliberately).
|
|
457
|
+
process.stderr.write(
|
|
458
|
+
"[viber-codex-bridge] VIBER_AGENT_TIER=write — workspace-write sandbox ACTIVE: this agent can " +
|
|
459
|
+
"edit files and run state-changing commands WITHOUT approval (operator-authorized at launch).\n",
|
|
460
|
+
);
|
|
461
|
+
return "write";
|
|
462
|
+
}
|
|
463
|
+
process.stderr.write(
|
|
464
|
+
`[viber-codex-bridge] unknown VIBER_AGENT_TIER="${raw}"; falling back to "chat"\n`,
|
|
465
|
+
);
|
|
466
|
+
return "chat";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const VOICE_CONCISE_LINE =
|
|
470
|
+
"Keep replies concise because they are returned to a voice/text conversation.";
|
|
471
|
+
|
|
472
|
+
function instructionsForTier(tier: AgentTier): string {
|
|
473
|
+
if (tier === "write") {
|
|
474
|
+
return [
|
|
475
|
+
"You are a bridge-owned Codex agent connected to Viber, in READ-WRITE mode.",
|
|
476
|
+
"You MAY read files, create/modify files within the workspace, and run commands (including state-changing ones) to carry out the user's requests.",
|
|
477
|
+
"The sandbox is workspace-write: writes are confined to the workspace and network access is blocked at the OS level.",
|
|
478
|
+
"There is NO human approval step, so be deliberate: make only the changes the user asked for, and avoid destructive commands unless explicitly requested.",
|
|
479
|
+
VOICE_CONCISE_LINE,
|
|
480
|
+
].join("\n");
|
|
481
|
+
}
|
|
482
|
+
if (tier === "read") {
|
|
483
|
+
return [
|
|
484
|
+
"You are a bridge-owned Codex agent connected to Viber, in READ collaboration mode.",
|
|
485
|
+
"You MAY read files and run read-only commands (e.g. cat, ls, grep, sed -n) to inspect code, plans, and docs, and reason about them.",
|
|
486
|
+
"The sandbox is read-only: file writes and network access are blocked at the OS level, so any modification or network call will simply fail — do not attempt to write or modify files, and do not run state-changing or destructive commands.",
|
|
487
|
+
"If the user asks for workspace changes, explain that the bridge is in read-only mode.",
|
|
488
|
+
VOICE_CONCISE_LINE,
|
|
489
|
+
].join("\n");
|
|
490
|
+
}
|
|
491
|
+
// chat — most restrictive (default)
|
|
492
|
+
return [
|
|
493
|
+
"You are a bridge-owned Codex agent connected to Viber for an initial persistence validation.",
|
|
494
|
+
"For now, answer and reason only. Do not run shell commands. Do not read files. Do not write or modify files.",
|
|
495
|
+
"If the user asks for workspace changes, explain that the bridge is currently in read-only validation mode.",
|
|
496
|
+
VOICE_CONCISE_LINE,
|
|
497
|
+
].join("\n");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Approval policy per tier. `read`/`write` use "never" so Codex runs its commands
|
|
501
|
+
// WITHOUT a human approver (there is none on the bridge) — the SANDBOX is the real
|
|
502
|
+
// guardrail (read-only blocks writes/network; workspace-write confines writes to
|
|
503
|
+
// the workspace and still blocks network). `chat` keeps "on-request" (no exec anyway).
|
|
504
|
+
function approvalForTier(tier: AgentTier): "never" | "on-request" {
|
|
505
|
+
return tier === "chat" ? "on-request" : "never";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Codex OS sandbox per tier. `write` widens to workspace-write (edits confined to
|
|
509
|
+
// the workspace, network still blocked); chat/read stay read-only.
|
|
510
|
+
function sandboxForTier(tier: AgentTier): "read-only" | "workspace-write" {
|
|
511
|
+
return tier === "write" ? "workspace-write" : "read-only";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const AGENT_TIER: AgentTier = resolveAgentTier();
|
|
515
|
+
const AGENT_INSTRUCTIONS: string = instructionsForTier(AGENT_TIER);
|
|
516
|
+
const AGENT_APPROVAL_POLICY: "never" | "on-request" = approvalForTier(AGENT_TIER);
|
|
517
|
+
const AGENT_SANDBOX: "read-only" | "workspace-write" = sandboxForTier(AGENT_TIER);
|
|
518
|
+
|
|
519
|
+
class CodexAppServer {
|
|
520
|
+
private child: ChildProcessWithoutNullStreams;
|
|
521
|
+
private nextId = 1;
|
|
522
|
+
private stdoutBuffer = "";
|
|
523
|
+
private pending = new Map<string, PendingRequest>();
|
|
524
|
+
private turnWaiters = new Map<string, TurnWaiter>();
|
|
525
|
+
|
|
526
|
+
constructor() {
|
|
527
|
+
const codexBin = process.env.VIBER_CODEX_BIN?.trim() || "codex";
|
|
528
|
+
this.child = spawn(codexBin, ["app-server"], {
|
|
529
|
+
cwd: process.cwd(),
|
|
530
|
+
env: process.env,
|
|
531
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
532
|
+
});
|
|
533
|
+
this.child.stdout.setEncoding("utf-8");
|
|
534
|
+
this.child.stderr.setEncoding("utf-8");
|
|
535
|
+
this.child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
|
|
536
|
+
this.child.stderr.on("data", (chunk: string) => {
|
|
537
|
+
process.stderr.write(`[codex-app-server] ${chunk}`);
|
|
538
|
+
});
|
|
539
|
+
this.child.on("exit", (code, signal) => {
|
|
540
|
+
const err = new Error(`Codex app-server exited (${code ?? signal ?? "unknown"})`);
|
|
541
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
542
|
+
this.pending.clear();
|
|
543
|
+
for (const waiter of this.turnWaiters.values()) {
|
|
544
|
+
clearTimeout(waiter.timeout);
|
|
545
|
+
waiter.reject(err);
|
|
546
|
+
}
|
|
547
|
+
this.turnWaiters.clear();
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async initialize(): Promise<void> {
|
|
552
|
+
await this.request("initialize", {
|
|
553
|
+
clientInfo: { name: "viber-codex-bridge", title: "Viber Codex Bridge", version: "0.1.0" },
|
|
554
|
+
capabilities: { experimentalApi: true },
|
|
555
|
+
});
|
|
556
|
+
this.notify("initialized");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async ensureThread(options: {
|
|
560
|
+
auth: AuthJson;
|
|
561
|
+
fingerprint: string;
|
|
562
|
+
conversationId: string;
|
|
563
|
+
instanceKey: string;
|
|
564
|
+
newThread: boolean;
|
|
565
|
+
}): Promise<string> {
|
|
566
|
+
const path = stateFilePath(options.instanceKey);
|
|
567
|
+
const state = readThreadState(path);
|
|
568
|
+
const key = stateKey(
|
|
569
|
+
options.auth.project_id,
|
|
570
|
+
options.fingerprint,
|
|
571
|
+
options.conversationId,
|
|
572
|
+
options.instanceKey,
|
|
573
|
+
);
|
|
574
|
+
const existing = state.threads[key]?.thread_id;
|
|
575
|
+
|
|
576
|
+
if (existing && !options.newThread) {
|
|
577
|
+
try {
|
|
578
|
+
process.stderr.write(`[viber-codex-bridge] codex: resuming persisted thread ${existing}\n`);
|
|
579
|
+
const resumed = await this.request("thread/resume", {
|
|
580
|
+
threadId: existing,
|
|
581
|
+
cwd: process.cwd(),
|
|
582
|
+
runtimeWorkspaceRoots: [process.cwd()],
|
|
583
|
+
approvalPolicy: AGENT_APPROVAL_POLICY,
|
|
584
|
+
sandbox: AGENT_SANDBOX,
|
|
585
|
+
developerInstructions: AGENT_INSTRUCTIONS,
|
|
586
|
+
excludeTurns: true,
|
|
587
|
+
});
|
|
588
|
+
return extractThreadId(resumed);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
process.stderr.write(
|
|
591
|
+
`[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
process.stderr.write("[viber-codex-bridge] codex: starting fresh bridge-owned thread\n");
|
|
597
|
+
const started = await this.request("thread/start", {
|
|
598
|
+
cwd: process.cwd(),
|
|
599
|
+
runtimeWorkspaceRoots: [process.cwd()],
|
|
600
|
+
approvalPolicy: AGENT_APPROVAL_POLICY,
|
|
601
|
+
sandbox: AGENT_SANDBOX,
|
|
602
|
+
developerInstructions: AGENT_INSTRUCTIONS,
|
|
603
|
+
ephemeral: false,
|
|
604
|
+
threadSource: "user",
|
|
605
|
+
sessionStartSource: options.newThread ? "clear" : "startup",
|
|
606
|
+
});
|
|
607
|
+
const threadId = extractThreadId(started);
|
|
608
|
+
state.threads[key] = {
|
|
609
|
+
thread_id: threadId,
|
|
610
|
+
conversation_id: options.conversationId,
|
|
611
|
+
project_id: options.auth.project_id,
|
|
612
|
+
client_fingerprint: options.fingerprint,
|
|
613
|
+
cwd: process.cwd(),
|
|
614
|
+
viber_instance_id: options.instanceKey,
|
|
615
|
+
updated_at: new Date().toISOString(),
|
|
616
|
+
};
|
|
617
|
+
writeThreadState(path, state);
|
|
618
|
+
process.stderr.write(`[viber-codex-bridge] codex: persisted thread ${threadId} in ${path}\n`);
|
|
619
|
+
return threadId;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async runTurn(threadId: string, content: string): Promise<string> {
|
|
623
|
+
const response = await this.request("turn/start", {
|
|
624
|
+
threadId,
|
|
625
|
+
input: [{ type: "text", text: content, text_elements: [] }],
|
|
626
|
+
cwd: process.cwd(),
|
|
627
|
+
runtimeWorkspaceRoots: [process.cwd()],
|
|
628
|
+
approvalPolicy: AGENT_APPROVAL_POLICY,
|
|
629
|
+
sandboxPolicy:
|
|
630
|
+
AGENT_SANDBOX === "workspace-write"
|
|
631
|
+
? { type: "workspaceWrite", networkAccess: false }
|
|
632
|
+
: { type: "readOnly", networkAccess: false },
|
|
633
|
+
developerInstructions: AGENT_INSTRUCTIONS,
|
|
634
|
+
responsesapiClientMetadata: { source: "viber-codex-bridge" },
|
|
635
|
+
});
|
|
636
|
+
const turnId = this.extractTurnId(response);
|
|
637
|
+
return await this.waitForTurn(threadId, turnId);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
close(): void {
|
|
641
|
+
this.child.kill();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private request(method: string, params: unknown): Promise<unknown> {
|
|
645
|
+
const id = this.nextId++;
|
|
646
|
+
const payload = JSON.stringify({ id, method, params });
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
this.pending.set(String(id), { resolve, reject });
|
|
649
|
+
this.child.stdin.write(`${payload}\n`, (err) => {
|
|
650
|
+
if (err) {
|
|
651
|
+
this.pending.delete(String(id));
|
|
652
|
+
reject(err);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private notify(method: string, params?: unknown): void {
|
|
659
|
+
const msg = params === undefined ? { method } : { method, params };
|
|
660
|
+
this.child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private handleStdout(chunk: string): void {
|
|
664
|
+
this.stdoutBuffer += chunk;
|
|
665
|
+
while (true) {
|
|
666
|
+
const newline = this.stdoutBuffer.indexOf("\n");
|
|
667
|
+
if (newline === -1) return;
|
|
668
|
+
const line = this.stdoutBuffer.slice(0, newline).trim();
|
|
669
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
|
670
|
+
if (line.length === 0) continue;
|
|
671
|
+
|
|
672
|
+
let msg: JsonRpcMessage;
|
|
673
|
+
try {
|
|
674
|
+
msg = JSON.parse(line) as JsonRpcMessage;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
process.stderr.write(`[viber-codex-bridge] codex: ignored malformed JSON line: ${safeErrorMessage(err)}\n`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
this.handleMessage(msg);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private handleMessage(msg: JsonRpcMessage): void {
|
|
684
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
685
|
+
const pending = this.pending.get(String(msg.id));
|
|
686
|
+
if (!pending) return;
|
|
687
|
+
this.pending.delete(String(msg.id));
|
|
688
|
+
if (msg.error) pending.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
|
|
689
|
+
else pending.resolve(msg.result);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (msg.id !== undefined && msg.method) {
|
|
694
|
+
this.handleServerRequest(msg);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (msg.method === "item/agentMessage/delta") {
|
|
699
|
+
const params = msg.params as { threadId?: string; turnId?: string; delta?: string };
|
|
700
|
+
const waiter = this.turnWaiters.get(`${params.threadId}:${params.turnId}`);
|
|
701
|
+
if (waiter && typeof params.delta === "string") waiter.deltas.push(params.delta);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (msg.method === "turn/completed") {
|
|
706
|
+
const params = msg.params as { threadId?: string; turn?: { id?: string; status?: unknown; error?: unknown } };
|
|
707
|
+
const turnId = params.turn?.id;
|
|
708
|
+
const waiter = this.turnWaiters.get(`${params.threadId}:${turnId}`);
|
|
709
|
+
if (!waiter) return;
|
|
710
|
+
clearTimeout(waiter.timeout);
|
|
711
|
+
this.turnWaiters.delete(`${params.threadId}:${turnId}`);
|
|
712
|
+
const status = params.turn?.status;
|
|
713
|
+
if (
|
|
714
|
+
status === "failed" ||
|
|
715
|
+
(typeof status === "object" && status !== null && "type" in status && status.type === "failed")
|
|
716
|
+
) {
|
|
717
|
+
waiter.reject(new Error(`Codex turn failed: ${JSON.stringify(params.turn?.error ?? status)}`));
|
|
718
|
+
} else {
|
|
719
|
+
waiter.resolve(waiter.deltas.join("").trim());
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (msg.method === "error" || msg.method === "warning" || msg.method === "configWarning") {
|
|
725
|
+
process.stderr.write(`[viber-codex-bridge] codex ${msg.method}: ${JSON.stringify(msg.params)}\n`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private handleServerRequest(msg: JsonRpcMessage): void {
|
|
730
|
+
process.stderr.write(`[viber-codex-bridge] codex: auto-declining server request ${msg.method}\n`);
|
|
731
|
+
let result: unknown = { decision: "decline" };
|
|
732
|
+
if (msg.method === "item/commandExecution/requestApproval") result = { decision: "decline" };
|
|
733
|
+
else if (msg.method === "item/fileChange/requestApproval") result = { decision: "decline" };
|
|
734
|
+
else if (msg.method === "execCommandApproval" || msg.method === "applyPatchApproval") {
|
|
735
|
+
result = { decision: "denied" };
|
|
736
|
+
}
|
|
737
|
+
this.child.stdin.write(`${JSON.stringify({ id: msg.id, result })}\n`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private extractTurnId(response: unknown): string {
|
|
741
|
+
if (
|
|
742
|
+
typeof response === "object" &&
|
|
743
|
+
response !== null &&
|
|
744
|
+
"turn" in response &&
|
|
745
|
+
typeof (response as { turn?: { id?: unknown } }).turn?.id === "string"
|
|
746
|
+
) {
|
|
747
|
+
return (response as { turn: { id: string } }).turn.id;
|
|
748
|
+
}
|
|
749
|
+
throw new Error("Codex app-server response did not include turn.id");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private waitForTurn(threadId: string, turnId: string): Promise<string> {
|
|
753
|
+
const key = `${threadId}:${turnId}`;
|
|
754
|
+
return new Promise((resolve, reject) => {
|
|
755
|
+
const timeout = setTimeout(() => {
|
|
756
|
+
this.turnWaiters.delete(key);
|
|
757
|
+
reject(new Error("Codex turn timed out after 10 minutes"));
|
|
758
|
+
}, 10 * 60 * 1000);
|
|
759
|
+
this.turnWaiters.set(key, { threadId, turnId, deltas: [], resolve, reject, timeout });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function postBridgeReply(
|
|
765
|
+
runtime: ConversationRuntime,
|
|
766
|
+
text: string,
|
|
767
|
+
ownPostedIds: Set<string>,
|
|
768
|
+
): Promise<void> {
|
|
769
|
+
let result;
|
|
770
|
+
try {
|
|
771
|
+
result = await postMessage(BASE_URL, runtime.id, runtime.token, text);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
// A 401 here means the conversation token expired and the refresh path
|
|
774
|
+
// failed; surface it as a controlled shutdown (exit code 3) so the
|
|
775
|
+
// per-message catch in sseLoop does not swallow it as a generic failure.
|
|
776
|
+
if (err instanceof ConversationTokenExpiredError) {
|
|
777
|
+
throw new BridgeShutdownError("conversation token expired", 3);
|
|
778
|
+
}
|
|
779
|
+
throw err;
|
|
780
|
+
}
|
|
781
|
+
if (!result.ok) {
|
|
782
|
+
throw new Error(`postMessage failed: ${result.detail}`);
|
|
783
|
+
}
|
|
784
|
+
ownPostedIds.add(String(result.message_id));
|
|
785
|
+
process.stderr.write(`[viber-codex-bridge] posted reply: ${result.message_id}\n`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function acquireBridgeInstance(
|
|
789
|
+
baseUrl: string,
|
|
790
|
+
auth: AuthJson,
|
|
791
|
+
fingerprint: string,
|
|
792
|
+
label: string,
|
|
793
|
+
options: BridgeOptions,
|
|
794
|
+
): Promise<{ instance_token: string; instance_key: string }> {
|
|
795
|
+
if (process.env.VIBER_INSTANCE_TOKEN !== undefined && process.env.VIBER_INSTANCE_TOKEN.trim() !== "") {
|
|
796
|
+
process.stderr.write("[viber-codex-bridge] instance: using VIBER_INSTANCE_TOKEN from env\n");
|
|
797
|
+
return acquireInstance(baseUrl, auth, fingerprint);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (options.reuseAuthInstance) {
|
|
801
|
+
process.stderr.write(
|
|
802
|
+
"[viber-codex-bridge] instance: reusing auth file instance because --reuse-auth-instance was set\n",
|
|
803
|
+
);
|
|
804
|
+
return acquireInstance(baseUrl, auth, fingerprint);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
process.stderr.write("[viber-codex-bridge] instance: registering fresh bridge instance\n");
|
|
808
|
+
const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, "codex");
|
|
809
|
+
process.stderr.write(`[viber-codex-bridge] instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
|
|
810
|
+
return { instance_token: reg.instance_token, instance_key: reg.instance_id };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function loadBridgeAuthAndFingerprint(): { auth: AuthJson; fingerprint: string } {
|
|
814
|
+
const auth = loadAuth();
|
|
815
|
+
const fingerprint = clientFingerprint(process.cwd());
|
|
816
|
+
if (fingerprint !== auth.client_fingerprint) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`Folder fingerprint mismatch — ${authFilePath()} was issued for a different machine/folder.`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
return { auth, fingerprint };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function acquireTargetConversation(
|
|
825
|
+
targetConversationId: string,
|
|
826
|
+
options: BridgeOptions,
|
|
827
|
+
auth: AuthJson,
|
|
828
|
+
fingerprint: string,
|
|
829
|
+
): Promise<{
|
|
830
|
+
minted: ConversationMintResponse;
|
|
831
|
+
fingerprint: string;
|
|
832
|
+
instanceKey: string;
|
|
833
|
+
auth: AuthJson;
|
|
834
|
+
bridgeLock: BridgeLock;
|
|
835
|
+
}> {
|
|
836
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
837
|
+
|
|
838
|
+
const label = process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Codex bridge • ${defaultLabel(process.cwd())}`;
|
|
839
|
+
const acquired = await acquireBridgeInstance(BASE_URL, auth, fingerprint, label, options);
|
|
840
|
+
let instanceToken = acquired.instance_token;
|
|
841
|
+
let instanceKey = acquired.instance_key;
|
|
842
|
+
let sessionPath = sessionFilePath(BASE_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR);
|
|
843
|
+
// #280 step-12: take the per-agent lock BEFORE reattach. reattach rotates the
|
|
844
|
+
// per-membership token, so a losing duplicate launch of the SAME identity must be
|
|
845
|
+
// stopped before it can rotate the winner's token (Codex review P2-B).
|
|
846
|
+
let bridgeLock = acquireBridgeLock(BASE_URL, fingerprint, `codex-agent:${instanceKey}`);
|
|
847
|
+
|
|
848
|
+
process.stderr.write(
|
|
849
|
+
`[viber-codex-bridge] joining conversation ${targetConversationId} (project=${auth.project_id}, session=${sessionPath})\n`,
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
// Release the lock on ANY failure after acquisition — success/401-recovery return
|
|
853
|
+
// it to main() (which releases it in its finally); only thrown escapes leak it.
|
|
854
|
+
try {
|
|
855
|
+
try {
|
|
856
|
+
const minted = await reattachConversation(
|
|
857
|
+
BASE_URL,
|
|
858
|
+
auth.project_id,
|
|
859
|
+
instanceToken,
|
|
860
|
+
fingerprint,
|
|
861
|
+
targetConversationId,
|
|
862
|
+
);
|
|
863
|
+
writeHandle(sessionPath, minted.conversation_id);
|
|
864
|
+
process.stderr.write(`[viber-codex-bridge] join OK, conv_id=${minted.conversation_id}\n`);
|
|
865
|
+
return { minted, fingerprint, instanceKey, auth, bridgeLock };
|
|
866
|
+
} catch (err) {
|
|
867
|
+
if (err instanceof ConversationMintError && err.status === 401) {
|
|
868
|
+
process.stderr.write(
|
|
869
|
+
"[viber-codex-bridge] instance token rejected (401 revoked) — re-registering a fresh instance once\n",
|
|
870
|
+
);
|
|
871
|
+
const reg = await registerInstance(BASE_URL, auth.project_id, auth.project_token, fingerprint, label);
|
|
872
|
+
instanceToken = reg.instance_token;
|
|
873
|
+
instanceKey = reg.instance_id;
|
|
874
|
+
// Identity changed → re-key the lock onto the new instance. Acquire the new
|
|
875
|
+
// lock BEFORE releasing the old one: if acquisition throws, the old lock is
|
|
876
|
+
// still held and the outer catch releases exactly it (no dangling reference).
|
|
877
|
+
const reLock = acquireBridgeLock(BASE_URL, fingerprint, `codex-agent:${instanceKey}`);
|
|
878
|
+
bridgeLock.release();
|
|
879
|
+
bridgeLock = reLock;
|
|
880
|
+
sessionPath = sessionFilePath(BASE_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR);
|
|
881
|
+
const minted = await reattachConversation(
|
|
882
|
+
BASE_URL,
|
|
883
|
+
auth.project_id,
|
|
884
|
+
instanceToken,
|
|
885
|
+
fingerprint,
|
|
886
|
+
targetConversationId,
|
|
887
|
+
);
|
|
888
|
+
writeHandle(sessionPath, minted.conversation_id);
|
|
889
|
+
process.stderr.write(`[viber-codex-bridge] join OK, conv_id=${minted.conversation_id}\n`);
|
|
890
|
+
return { minted, fingerprint, instanceKey, auth, bridgeLock };
|
|
891
|
+
}
|
|
892
|
+
throw err;
|
|
893
|
+
}
|
|
894
|
+
} catch (fatal) {
|
|
895
|
+
bridgeLock.release();
|
|
896
|
+
throw fatal;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function sseLoop(
|
|
901
|
+
minted: ConversationMintResponse,
|
|
902
|
+
runtime: ConversationRuntime,
|
|
903
|
+
fingerprint: string,
|
|
904
|
+
ownInstanceKey: string,
|
|
905
|
+
codex: CodexAppServer,
|
|
906
|
+
codexThreadId: string,
|
|
907
|
+
options: BridgeOptions,
|
|
908
|
+
signal: AbortSignal,
|
|
909
|
+
): Promise<void> {
|
|
910
|
+
const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
|
|
911
|
+
const ownPostedIds = new Set<string>();
|
|
912
|
+
const handledIds = new Set<string>();
|
|
913
|
+
// First reconcile pass only SEEDS (marks existing messages as seen without
|
|
914
|
+
// replying) so the bridge doesn't answer the whole history when it joins.
|
|
915
|
+
let primed = false;
|
|
916
|
+
|
|
917
|
+
// Reply to one inbound message exactly once. Shared by the live SSE handler
|
|
918
|
+
// and the reconnect catch-up; `handledIds` makes it idempotent. Returns true
|
|
919
|
+
// when options.once is satisfied (caller should stop).
|
|
920
|
+
async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
|
|
921
|
+
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
922
|
+
if (idStr && handledIds.has(idStr)) return false;
|
|
923
|
+
if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
|
|
924
|
+
if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
if (idStr) handledIds.add(idStr);
|
|
928
|
+
process.stderr.write(`[viber-codex-bridge] message received: ${msg.content!.slice(0, 80)}\n`);
|
|
929
|
+
try {
|
|
930
|
+
const reply = options.echoContent
|
|
931
|
+
? buildReply(msg.content!, msg, options)
|
|
932
|
+
: await codexReply(codex, codexThreadId, msg.content!);
|
|
933
|
+
await postBridgeReply(runtime, reply, ownPostedIds);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
if (err instanceof BridgeShutdownError) throw err;
|
|
936
|
+
process.stderr.write(`[viber-codex-bridge] failed to handle message: ${safeErrorMessage(err)}\n`);
|
|
937
|
+
}
|
|
938
|
+
return options.once === true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// On every (re)connect the conversation SSE has NO replay, so re-read the
|
|
942
|
+
// message list and process anything missed while disconnected (#280 step-06).
|
|
943
|
+
// The first pass seeds handledIds without replying (don't answer history).
|
|
944
|
+
async function reconcileMessages(): Promise<boolean> {
|
|
945
|
+
let msgs: Array<Record<string, unknown>>;
|
|
946
|
+
try {
|
|
947
|
+
msgs = await fetchMessages(minted.ws_url, minted.conversation_id, runtime.token);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
if (err instanceof ConversationTokenExpiredError) throw err;
|
|
950
|
+
process.stderr.write(`[viber-codex-bridge] catch-up fetch failed: ${safeErrorMessage(err)}\n`);
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
for (const raw of msgs) {
|
|
954
|
+
const msg = raw as unknown as ConversationMessage;
|
|
955
|
+
if (!primed) {
|
|
956
|
+
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
957
|
+
if (idStr) handledIds.add(idStr);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (await handleInboundMessage(msg)) return true;
|
|
961
|
+
}
|
|
962
|
+
if (!primed) {
|
|
963
|
+
primed = true;
|
|
964
|
+
process.stderr.write(`[viber-codex-bridge] primed: ${handledIds.size} existing message(s) marked seen\n`);
|
|
965
|
+
}
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
while (true) {
|
|
970
|
+
try {
|
|
971
|
+
if (signal.aborted) throw signal.reason;
|
|
972
|
+
const requestHeaders = headers(runtime.token, fingerprint);
|
|
973
|
+
requestHeaders["Accept"] = "text/event-stream";
|
|
974
|
+
process.stderr.write(`[viber-codex-bridge] connecting to SSE: ${sseUrl}\n`);
|
|
975
|
+
|
|
976
|
+
const resp = await fetch(sseUrl, { headers: requestHeaders, signal });
|
|
977
|
+
if (resp.status === 401) throw new ConversationTokenExpiredError();
|
|
978
|
+
if (!resp.ok || !resp.body) throw new Error(`SSE connection failed: HTTP ${resp.status}`);
|
|
979
|
+
|
|
980
|
+
process.stderr.write("[viber-codex-bridge] SSE connected\n");
|
|
981
|
+
// Catch up on anything published while we were (re)connecting before we
|
|
982
|
+
// start reading live events — the SSE stream has no replay.
|
|
983
|
+
if (await reconcileMessages()) return;
|
|
984
|
+
const reader = resp.body.getReader();
|
|
985
|
+
const decoder = new TextDecoder();
|
|
986
|
+
let buffer = "";
|
|
987
|
+
|
|
988
|
+
while (true) {
|
|
989
|
+
const { done, value } = await reader.read();
|
|
990
|
+
if (done) break;
|
|
991
|
+
buffer += decoder.decode(value, { stream: true });
|
|
992
|
+
|
|
993
|
+
while (true) {
|
|
994
|
+
const eventEnd = buffer.indexOf("\n\n");
|
|
995
|
+
if (eventEnd === -1) break;
|
|
996
|
+
|
|
997
|
+
const eventBlock = buffer.slice(0, eventEnd);
|
|
998
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
999
|
+
if (eventBlock.startsWith(":")) continue;
|
|
1000
|
+
|
|
1001
|
+
let eventType = "";
|
|
1002
|
+
let data = "";
|
|
1003
|
+
for (const line of eventBlock.split("\n")) {
|
|
1004
|
+
if (line.startsWith("event: ")) eventType = line.slice(7);
|
|
1005
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (eventType === "connected") {
|
|
1009
|
+
process.stderr.write("[viber-codex-bridge] SSE stream ready\n");
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (eventType === "stop") {
|
|
1014
|
+
process.stderr.write("[viber-codex-bridge] received stop signal, exiting\n");
|
|
1015
|
+
throw new BridgeShutdownError("received stop signal", 0);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (eventType === "transcription" && data) {
|
|
1019
|
+
let item: { text?: string };
|
|
1020
|
+
try {
|
|
1021
|
+
item = JSON.parse(data) as { text?: string };
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
process.stderr.write(
|
|
1024
|
+
`[viber-codex-bridge] ignored malformed transcription SSE payload: ${safeErrorMessage(err)}\n`,
|
|
1025
|
+
);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof item.text === "string" && item.text.trim().length > 0) {
|
|
1029
|
+
process.stderr.write(`[viber-codex-bridge] transcription received: ${item.text.slice(0, 80)}\n`);
|
|
1030
|
+
try {
|
|
1031
|
+
const reply = options.echoContent
|
|
1032
|
+
? buildReply(item.text, undefined, options)
|
|
1033
|
+
: await codexReply(codex, codexThreadId, item.text);
|
|
1034
|
+
await postBridgeReply(runtime, reply, ownPostedIds);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
if (err instanceof BridgeShutdownError) throw err;
|
|
1037
|
+
process.stderr.write(`[viber-codex-bridge] failed to handle transcription: ${safeErrorMessage(err)}\n`);
|
|
1038
|
+
}
|
|
1039
|
+
if (options.once) return;
|
|
1040
|
+
}
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (eventType === "message" && data) {
|
|
1045
|
+
let msg: ConversationMessage;
|
|
1046
|
+
try {
|
|
1047
|
+
msg = JSON.parse(data) as ConversationMessage;
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
process.stderr.write(
|
|
1050
|
+
`[viber-codex-bridge] ignored malformed message SSE payload: ${safeErrorMessage(err)}\n`,
|
|
1051
|
+
);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (await handleInboundMessage(msg)) return;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
process.stderr.write("[viber-codex-bridge] SSE stream ended, reconnecting...\n");
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
if (err instanceof BridgeShutdownError) throw err;
|
|
1062
|
+
if (signal.aborted) {
|
|
1063
|
+
const reason = signal.reason;
|
|
1064
|
+
if (reason instanceof BridgeShutdownError) throw reason;
|
|
1065
|
+
throw new BridgeShutdownError("bridge shutdown requested", 0);
|
|
1066
|
+
}
|
|
1067
|
+
if (err instanceof ConversationTokenExpiredError) {
|
|
1068
|
+
process.stderr.write("[viber-codex-bridge] conversation token expired, exiting\n");
|
|
1069
|
+
throw new BridgeShutdownError("conversation token expired", 3);
|
|
1070
|
+
}
|
|
1071
|
+
process.stderr.write(`[viber-codex-bridge] SSE failed: ${String(err)}, retrying in 2s...\n`);
|
|
1072
|
+
await sleep(2000);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function codexReply(codex: CodexAppServer, threadId: string, content: string): Promise<string> {
|
|
1078
|
+
const reply = await codex.runTurn(threadId, content);
|
|
1079
|
+
if (reply.length > 0) return reply;
|
|
1080
|
+
throw new Error("Codex completed the turn but returned an empty reply.");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Await-invite mode (#280 step-03/step-20): acquire the instance, then run a
|
|
1085
|
+
* PERSISTENT control stream and resolve on the first server-pushed `join`. The
|
|
1086
|
+
* stream is kept open for the agent's whole life (returned as `controlStream`)
|
|
1087
|
+
* so server-side presence keeps seeing it online and re-invitable — closing it
|
|
1088
|
+
* after the first join is the bug step-20 fixes (Codex P1-3). A `stop` (revoke)
|
|
1089
|
+
* or 401 aborts the bridge via the shutdown controller; transient drops reconnect
|
|
1090
|
+
* with backoff inside the runner.
|
|
1091
|
+
*/
|
|
1092
|
+
export async function acquireConversationViaInvite(
|
|
1093
|
+
options: BridgeOptions,
|
|
1094
|
+
auth: AuthJson,
|
|
1095
|
+
fingerprint: string,
|
|
1096
|
+
shutdown: AbortController,
|
|
1097
|
+
): Promise<{
|
|
1098
|
+
minted: ConversationMintResponse;
|
|
1099
|
+
instanceKey: string;
|
|
1100
|
+
bridgeLock: BridgeLock;
|
|
1101
|
+
controlStream: PersistentControlStream;
|
|
1102
|
+
}> {
|
|
1103
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
1104
|
+
const label = process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Codex bridge • ${defaultLabel(process.cwd())}`;
|
|
1105
|
+
const acquired = await acquireBridgeInstance(BASE_URL, auth, fingerprint, label, options);
|
|
1106
|
+
const instanceKey = acquired.instance_key;
|
|
1107
|
+
// #280 step-12: per-agent lock, held for the whole wait + lifetime (returned to
|
|
1108
|
+
// main()). Two processes with the SAME identity can't both await an invite.
|
|
1109
|
+
const bridgeLock = acquireBridgeLock(BASE_URL, fingerprint, `codex-agent:${instanceKey}`);
|
|
1110
|
+
process.stderr.write(
|
|
1111
|
+
`[viber-codex-bridge] await-invite: waiting for a pushed join on control stream (instance ${instanceKey.slice(0, 8)})\n`,
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
const controlStream = runPersistentControlStream(
|
|
1115
|
+
{
|
|
1116
|
+
baseUrl: BASE_URL,
|
|
1117
|
+
instanceId: instanceKey,
|
|
1118
|
+
instanceToken: acquired.instance_token,
|
|
1119
|
+
signal: shutdown.signal,
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
log: (m) => process.stderr.write(m),
|
|
1123
|
+
onJoin: (minted) => {
|
|
1124
|
+
const sessionPath = sessionFilePath(BASE_URL, `${instanceKey}:codex:${minted.conversation_id}`, LOCK_DIR);
|
|
1125
|
+
writeHandle(sessionPath, minted.conversation_id);
|
|
1126
|
+
},
|
|
1127
|
+
// A revoke / 401 that arrives AFTER the first join (mid-conversation) must
|
|
1128
|
+
// still stop the bridge — abort the shutdown controller the sseLoop watches.
|
|
1129
|
+
onStop: (reason) => {
|
|
1130
|
+
if (shutdown.signal.aborted) return;
|
|
1131
|
+
shutdown.abort(
|
|
1132
|
+
reason === "unauthorized"
|
|
1133
|
+
? new BridgeShutdownError("instance token revoked or invalid", 3)
|
|
1134
|
+
: new BridgeShutdownError("instance revoked", 0),
|
|
1135
|
+
);
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
try {
|
|
1141
|
+
const minted = await controlStream.firstJoin;
|
|
1142
|
+
process.stderr.write(
|
|
1143
|
+
`[viber-codex-bridge] await-invite: joined ${minted.conversation_id} via pushed invite (control stream stays open)\n`,
|
|
1144
|
+
);
|
|
1145
|
+
return { minted, instanceKey, bridgeLock, controlStream };
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
// First-join never arrived: revoke / 401 / abort. The runner's loop has
|
|
1148
|
+
// already returned for each of these (it settles firstJoin's rejection at the
|
|
1149
|
+
// same point it exits), so observe `done` to avoid an unhandled background
|
|
1150
|
+
// promise, then release the lock (the success path returns it to main()).
|
|
1151
|
+
await controlStream.done.catch(() => {});
|
|
1152
|
+
bridgeLock.release();
|
|
1153
|
+
if (err instanceof ControlStreamStopped) throw new BridgeShutdownError("instance revoked", 0);
|
|
1154
|
+
if (err instanceof ControlStreamAuthError) {
|
|
1155
|
+
throw new BridgeShutdownError("instance token revoked or invalid", 3);
|
|
1156
|
+
}
|
|
1157
|
+
if (shutdown.signal.aborted) throw new BridgeShutdownError("bridge shutdown requested", 0);
|
|
1158
|
+
throw err;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
1163
|
+
const options = parseArgs(argv);
|
|
1164
|
+
let runtime: ConversationRuntime | null = null;
|
|
1165
|
+
let codex: CodexAppServer | null = null;
|
|
1166
|
+
let bridgeLock: BridgeLock | null = null;
|
|
1167
|
+
let controlStream: PersistentControlStream | null = null;
|
|
1168
|
+
const shutdown = new AbortController();
|
|
1169
|
+
|
|
1170
|
+
try {
|
|
1171
|
+
const { auth, fingerprint } = loadBridgeAuthAndFingerprint();
|
|
1172
|
+
let minted: ConversationMintResponse;
|
|
1173
|
+
let instanceKey: string;
|
|
1174
|
+
if (options.awaitInvite) {
|
|
1175
|
+
// #280 step-03: no hand-carried conversation id — the server pushes it.
|
|
1176
|
+
// The control stream stays open for life (step-20) → keep its handle.
|
|
1177
|
+
const res = await acquireConversationViaInvite(options, auth, fingerprint, shutdown);
|
|
1178
|
+
minted = res.minted;
|
|
1179
|
+
instanceKey = res.instanceKey;
|
|
1180
|
+
bridgeLock = res.bridgeLock;
|
|
1181
|
+
controlStream = res.controlStream;
|
|
1182
|
+
} else {
|
|
1183
|
+
const res = await acquireTargetConversation(
|
|
1184
|
+
options.conversationId,
|
|
1185
|
+
options,
|
|
1186
|
+
auth,
|
|
1187
|
+
fingerprint,
|
|
1188
|
+
);
|
|
1189
|
+
minted = res.minted;
|
|
1190
|
+
instanceKey = res.instanceKey;
|
|
1191
|
+
bridgeLock = res.bridgeLock;
|
|
1192
|
+
}
|
|
1193
|
+
// #280 step-12: the lock (acquired inside the helpers above, per AGENT / instance
|
|
1194
|
+
// id, BEFORE any token-rotating reattach) lets multiple agents share one
|
|
1195
|
+
// conversation while still blocking a double-launch of the same identity.
|
|
1196
|
+
runtime = {
|
|
1197
|
+
id: minted.conversation_id,
|
|
1198
|
+
token: minted.conversation_token,
|
|
1199
|
+
expiresAt: minted.expires_at,
|
|
1200
|
+
};
|
|
1201
|
+
startTokenRefresh(runtime, fingerprint, (err) => {
|
|
1202
|
+
if (!shutdown.signal.aborted) shutdown.abort(err);
|
|
1203
|
+
});
|
|
1204
|
+
process.stderr.write(
|
|
1205
|
+
`[viber-codex-bridge] ready: joined ${minted.conversation_id} as instance ${instanceKey.slice(0, 8)}\n`,
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
codex = new CodexAppServer();
|
|
1209
|
+
await codex.initialize();
|
|
1210
|
+
const codexThreadId = await codex.ensureThread({
|
|
1211
|
+
auth,
|
|
1212
|
+
fingerprint,
|
|
1213
|
+
conversationId: minted.conversation_id,
|
|
1214
|
+
instanceKey,
|
|
1215
|
+
newThread: options.newThread,
|
|
1216
|
+
});
|
|
1217
|
+
process.stderr.write(
|
|
1218
|
+
`[viber-codex-bridge] codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=false, ` +
|
|
1219
|
+
`instructions=${
|
|
1220
|
+
AGENT_TIER === "write"
|
|
1221
|
+
? "read+write/run commands (workspace)"
|
|
1222
|
+
: AGENT_TIER === "read"
|
|
1223
|
+
? "read+read-only commands/no write"
|
|
1224
|
+
: "no shell/no file read/write"
|
|
1225
|
+
}\n`,
|
|
1226
|
+
);
|
|
1227
|
+
process.stderr.write(`[viber-codex-bridge] codex ready: thread ${codexThreadId}\n`);
|
|
1228
|
+
await sseLoop(minted, runtime, fingerprint, instanceKey, codex, codexThreadId, options, shutdown.signal);
|
|
1229
|
+
} finally {
|
|
1230
|
+
runtime?.scheduler?.cancel();
|
|
1231
|
+
// Stop the persistent control stream and wait for its loop to exit so it
|
|
1232
|
+
// doesn't outlive the process (step-20). Abort is idempotent.
|
|
1233
|
+
if (!shutdown.signal.aborted) shutdown.abort();
|
|
1234
|
+
await controlStream?.done.catch(() => {});
|
|
1235
|
+
codex?.close();
|
|
1236
|
+
bridgeLock?.release();
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (import.meta.main) {
|
|
1241
|
+
try {
|
|
1242
|
+
await main();
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
process.stderr.write(`[viber-codex-bridge] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1245
|
+
process.exit(err instanceof BridgeShutdownError ? err.exitCode : 1);
|
|
1246
|
+
}
|
|
1247
|
+
}
|