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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * control_stream — subscribe to an instance-scoped server→agent control stream
3
+ * (#280 step-01/03).
4
+ *
5
+ * The control stream (`GET /api/instances/<id>/events`, authenticated by the
6
+ * instance_token) is how the server pushes commands to an agent BEFORE it is in
7
+ * any conversation. Today it carries:
8
+ * - `connected` — open marker
9
+ * - `ping` — keepalive
10
+ * - `stop` — the instance was revoked; the agent should exit
11
+ * - `join` — admit this agent to a conversation; the payload IS a
12
+ * ConversationMintResponse (conversation_id, conversation_token,
13
+ * ws_url, expires_at), so it feeds the conversation SSE loop
14
+ * directly with no copy-pasted ids or tokens (the step-03 payoff).
15
+ *
16
+ * This module is intentionally side-effect-free and runtime-agnostic (no Codex /
17
+ * Claude specifics) so both bridges can consume it and it can be unit-tested with
18
+ * a mock fetch.
19
+ */
20
+ import { cfAccessHeaders } from "./cfAccess.js";
21
+ import type { ConversationMintResponse } from "./conversation.js";
22
+
23
+ /** One parsed Server-Sent Event from the control stream. */
24
+ export interface ControlEvent {
25
+ event: string;
26
+ data: string;
27
+ }
28
+
29
+ /** Build the instance control-stream SSE URL. */
30
+ export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
31
+ return `${baseUrl}/api/instances/${instanceId}/events`;
32
+ }
33
+
34
+ export interface ControlStreamOptions {
35
+ baseUrl: string;
36
+ instanceId: string;
37
+ instanceToken: string;
38
+ signal?: AbortSignal;
39
+ /** Injectable for tests; defaults to global fetch. */
40
+ fetchImpl?: typeof fetch;
41
+ }
42
+
43
+ /**
44
+ * Thrown when the control stream is rejected with 401 — the instance_token is
45
+ * revoked or invalid, so the agent must re-register (a fresh instance_id).
46
+ */
47
+ export class ControlStreamAuthError extends Error {
48
+ constructor() {
49
+ super("control stream unauthorized (instance token revoked or invalid)");
50
+ this.name = "ControlStreamAuthError";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Async generator that connects to the instance control stream and yields each
56
+ * parsed `{ event, data }`. One connection attempt — the caller decides whether
57
+ * to reconnect (the server keeps the stream open with `ping` keepalives). Throws
58
+ * `ControlStreamAuthError` on 401; the AbortSignal ends it cleanly.
59
+ */
60
+ export async function* subscribeControlStream(
61
+ opts: ControlStreamOptions,
62
+ ): AsyncGenerator<ControlEvent> {
63
+ const doFetch = opts.fetchImpl ?? fetch;
64
+ const url = buildControlStreamUrl(opts.baseUrl, opts.instanceId);
65
+
66
+ const resp = await doFetch(url, {
67
+ headers: {
68
+ Authorization: `Bearer ${opts.instanceToken}`,
69
+ Accept: "text/event-stream",
70
+ "Cache-Control": "no-cache",
71
+ ...cfAccessHeaders(),
72
+ },
73
+ signal: opts.signal,
74
+ });
75
+ if (resp.status === 401) throw new ControlStreamAuthError();
76
+ if (!resp.ok || !resp.body) {
77
+ throw new Error(`control stream connection failed: HTTP ${resp.status}`);
78
+ }
79
+
80
+ const reader = resp.body.getReader();
81
+ const decoder = new TextDecoder();
82
+ let buffer = "";
83
+
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ // Normalise CRLF → LF: the SSE spec allows \r\n line/record separators and
89
+ // some servers (incl. Cloudflare) emit them; without this the `\n\n` split
90
+ // never matches and a trailing `\r` corrupts every field value.
91
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
92
+
93
+ while (true) {
94
+ const eventEnd = buffer.indexOf("\n\n");
95
+ if (eventEnd === -1) break;
96
+ const block = buffer.slice(0, eventEnd);
97
+ buffer = buffer.slice(eventEnd + 2);
98
+
99
+ let event = "";
100
+ let data = "";
101
+ for (const line of block.split("\n")) {
102
+ if (line.startsWith(":")) continue; // comment line (keepalive)
103
+ if (line.startsWith("event: ")) event = line.slice(7);
104
+ else if (line.startsWith("data: ")) data = line.slice(6);
105
+ }
106
+ if (event) yield { event, data };
107
+ }
108
+ }
109
+ } finally {
110
+ // Release the reader (and cancel the body) so an early `break`/`return` by
111
+ // the consumer — e.g. waitForJoin resolving on the first join — or an abort
112
+ // doesn't leak the underlying connection until GC.
113
+ try {
114
+ await reader.cancel();
115
+ } catch {
116
+ /* already closed / aborted */
117
+ }
118
+ }
119
+ }
120
+
121
+ /** Raised when the control stream is signalled `stop` (instance revoked). */
122
+ export class ControlStreamStopped extends Error {
123
+ constructor() {
124
+ super("control stream received stop (instance revoked)");
125
+ this.name = "ControlStreamStopped";
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Parse a `join` event's data into a validated `ConversationMintResponse`, or
131
+ * null if it is malformed / missing fields (the caller skips it and waits for
132
+ * the next). Shared by waitForJoin and runPersistentControlStream.
133
+ */
134
+ function parseJoinPayload(
135
+ data: string,
136
+ log: (msg: string) => void,
137
+ ): ConversationMintResponse | null {
138
+ let payload: Partial<ConversationMintResponse>;
139
+ try {
140
+ payload = JSON.parse(data) as Partial<ConversationMintResponse>;
141
+ } catch {
142
+ log("[control-stream] ignored malformed join payload\n");
143
+ return null;
144
+ }
145
+ if (
146
+ typeof payload.conversation_id === "string" &&
147
+ typeof payload.conversation_token === "string" &&
148
+ typeof payload.ws_url === "string" &&
149
+ typeof payload.expires_at === "number"
150
+ ) {
151
+ return payload as ConversationMintResponse;
152
+ }
153
+ log("[control-stream] ignored join payload with missing fields\n");
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Consume the control stream until the first `join` event and return its payload
159
+ * as a `ConversationMintResponse` — ready to feed the conversation SSE loop. A
160
+ * `stop` event before any join throws `ControlStreamStopped`. `connected`/`ping`
161
+ * are ignored (keepalive). Malformed `join` data is skipped (waits for the next).
162
+ *
163
+ * NOTE: this CLOSES the stream on the first join (the generator returns). For the
164
+ * control-plane DM model — where presence must survive after joining — use
165
+ * runPersistentControlStream instead, which keeps the stream open for life.
166
+ */
167
+ export async function waitForJoin(
168
+ opts: ControlStreamOptions,
169
+ log: (msg: string) => void = () => {},
170
+ ): Promise<ConversationMintResponse> {
171
+ for await (const ev of subscribeControlStream(opts)) {
172
+ if (ev.event === "stop") throw new ControlStreamStopped();
173
+ if (ev.event !== "join") continue; // connected / ping / unknown → ignore
174
+ const minted = parseJoinPayload(ev.data, log);
175
+ if (minted) return minted;
176
+ }
177
+ // Stream ended without a join — the caller reconnects.
178
+ throw new Error("control stream ended before a join event");
179
+ }
180
+
181
+ /** Resolve after `ms`, or early when `signal` aborts. */
182
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
183
+ return new Promise((resolve) => {
184
+ if (signal?.aborted) return resolve();
185
+ const onAbort = () => {
186
+ clearTimeout(timer);
187
+ resolve();
188
+ };
189
+ const timer = setTimeout(() => {
190
+ signal?.removeEventListener("abort", onAbort);
191
+ resolve();
192
+ }, ms);
193
+ signal?.addEventListener("abort", onAbort, { once: true });
194
+ });
195
+ }
196
+
197
+ export interface PersistentControlStreamHandlers {
198
+ /** Called for EACH pushed join (the conversation to admit this agent into). */
199
+ onJoin?: (minted: ConversationMintResponse) => void;
200
+ /**
201
+ * Called once when the stream terminates for a NON-transient reason:
202
+ * "stopped" — a `stop` event arrived (the instance was revoked)
203
+ * "unauthorized" — the control stream returned 401 (token revoked/invalid)
204
+ * The agent should shut down. NOT called on transient drops (those reconnect).
205
+ */
206
+ onStop?: (reason: "stopped" | "unauthorized") => void;
207
+ log?: (msg: string) => void;
208
+ /** Backoff before reconnecting after a drop / clean end. Default 2000ms. */
209
+ reconnectDelayMs?: number;
210
+ }
211
+
212
+ export interface PersistentControlStream {
213
+ /**
214
+ * Resolves with the FIRST pushed join — the caller starts the conversation
215
+ * from it. Rejects with ControlStreamStopped / ControlStreamAuthError if the
216
+ * stream ends (revoke / 401) before any join, or a generic Error on abort.
217
+ */
218
+ firstJoin: Promise<ConversationMintResponse>;
219
+ /** Resolves when the run loop exits (abort, stop, or auth error). */
220
+ done: Promise<void>;
221
+ }
222
+
223
+ /**
224
+ * Run the instance control stream for the agent's WHOLE lifetime (hub-and-spoke:
225
+ * ONE persistent server→agent link). Unlike waitForJoin — which returns on the
226
+ * first join and CLOSES the stream — this keeps the stream open AFTER the first
227
+ * join so server-side presence (#280 step-02) keeps seeing the agent online and
228
+ * re-invitable (step-20, Codex P1-3). Reconnects with backoff on transient drops;
229
+ * a `stop` (revoke) or 401 ends the loop via onStop. The AbortSignal ends it.
230
+ */
231
+ export function runPersistentControlStream(
232
+ opts: ControlStreamOptions,
233
+ handlers: PersistentControlStreamHandlers = {},
234
+ ): PersistentControlStream {
235
+ const log = handlers.log ?? (() => {});
236
+ const delayMs = handlers.reconnectDelayMs ?? 2000;
237
+ const signal = opts.signal;
238
+
239
+ let firstSettled = false;
240
+ let resolveFirst!: (m: ConversationMintResponse) => void;
241
+ let rejectFirst!: (err: unknown) => void;
242
+ const firstJoin = new Promise<ConversationMintResponse>((res, rej) => {
243
+ resolveFirst = res;
244
+ rejectFirst = rej;
245
+ });
246
+ // Mark settled synchronously so the rejection on abort never fires after a
247
+ // resolve (and vice-versa) — and so an unobserved firstJoin still rejects once.
248
+ const settleFirstResolve = (m: ConversationMintResponse) => {
249
+ if (firstSettled) return;
250
+ firstSettled = true;
251
+ resolveFirst(m);
252
+ };
253
+ const settleFirstReject = (err: unknown) => {
254
+ if (firstSettled) return;
255
+ firstSettled = true;
256
+ rejectFirst(err);
257
+ };
258
+
259
+ const done = (async () => {
260
+ try {
261
+ while (true) {
262
+ if (signal?.aborted) {
263
+ settleFirstReject(new Error("control stream aborted before join"));
264
+ return;
265
+ }
266
+ try {
267
+ for await (const ev of subscribeControlStream(opts)) {
268
+ if (ev.event === "stop") {
269
+ settleFirstReject(new ControlStreamStopped());
270
+ handlers.onStop?.("stopped");
271
+ return;
272
+ }
273
+ if (ev.event !== "join") continue; // connected / ping / unknown
274
+ const minted = parseJoinPayload(ev.data, log);
275
+ if (!minted) continue;
276
+ handlers.onJoin?.(minted);
277
+ settleFirstResolve(minted);
278
+ }
279
+ // Clean end (server closed the SSE) → reconnect after backoff.
280
+ } catch (err) {
281
+ if (err instanceof ControlStreamAuthError) {
282
+ settleFirstReject(err);
283
+ handlers.onStop?.("unauthorized");
284
+ return;
285
+ }
286
+ if (signal?.aborted) {
287
+ settleFirstReject(new Error("control stream aborted before join"));
288
+ return;
289
+ }
290
+ log(
291
+ `[control-stream] ${err instanceof Error ? err.message : String(err)}, reconnecting...\n`,
292
+ );
293
+ }
294
+ if (signal?.aborted) {
295
+ settleFirstReject(new Error("control stream aborted before join"));
296
+ return;
297
+ }
298
+ await abortableDelay(delayMs, signal);
299
+ }
300
+ } finally {
301
+ // Defensive: never leave an awaiter of firstJoin hanging if the loop exits.
302
+ settleFirstReject(new Error("control stream ended before a join event"));
303
+ }
304
+ })();
305
+
306
+ return { firstJoin, done };
307
+ }
@@ -1,31 +1,49 @@
1
1
  import { basename } from "node:path";
2
2
  import { cfAccessHeaders } from "./cfAccess.js";
3
3
  import { ConversationTokenExpiredError } from "./messages.js";
4
+ import { readHandle, writeHandle } from "./channel_session.js";
4
5
 
5
6
  export interface ConversationMintResponse {
6
7
  conversation_id: string;
7
8
  conversation_token: string;
8
9
  ws_url: string;
9
10
  expires_at: number;
10
- new_project_token?: string;
11
- new_project_token_expires_at?: number | null;
12
11
  }
13
12
 
14
- export class ReverifyRequiredError extends Error {
15
- url: string;
16
- reason: string;
17
- constructor(url: string, reason: string) {
18
- super(`Re-verification required: ${reason}`);
19
- this.name = "ReverifyRequiredError";
20
- this.url = url;
21
- this.reason = reason;
13
+ /**
14
+ * Thrown by `reattachConversation` when the server returns a 409 indicating
15
+ * the requested conversation cannot be reattached (not found, wrong owner,
16
+ * wrong project, fingerprint mismatch, revoked, or race condition).
17
+ * The caller should fall back to `mintConversation`.
18
+ */
19
+ export class ReattachFailedError extends Error {
20
+ /** The `detail` code from the 409 body, e.g. `reattach_not_found` */
21
+ code: string;
22
+ constructor(code: string) {
23
+ super(`Reattach failed: ${code}`);
24
+ this.name = "ReattachFailedError";
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Non-2xx from the conversation mint/join endpoint. `.status` lets the caller
31
+ * react to a 401 (the instance_token was revoked → re-acquire a new instance
32
+ * and retry, #269) distinctly from other failures.
33
+ */
34
+ export class ConversationMintError extends Error {
35
+ status: number;
36
+ constructor(status: number, detail: string) {
37
+ super(`Failed to mint conversation token (HTTP ${status}): ${detail}`);
38
+ this.name = "ConversationMintError";
39
+ this.status = status;
22
40
  }
23
41
  }
24
42
 
25
43
  export async function mintConversation(
26
44
  baseUrl: string,
27
45
  projectId: number,
28
- projectToken: string,
46
+ instanceToken: string,
29
47
  fingerprint: string,
30
48
  label: string,
31
49
  /**
@@ -51,7 +69,7 @@ export async function mintConversation(
51
69
  const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
52
70
  method: "POST",
53
71
  headers: {
54
- Authorization: `Bearer ${projectToken}`,
72
+ Authorization: `Bearer ${instanceToken}`,
55
73
  "X-Client-Fingerprint": fingerprint,
56
74
  "Content-Type": "application/json",
57
75
  ...cfAccessHeaders(),
@@ -59,21 +77,54 @@ export async function mintConversation(
59
77
  body: JSON.stringify(requestBody),
60
78
  });
61
79
  if (!resp.ok) {
62
- if (resp.status === 403) {
63
- let body: { type?: string; reverify_url?: string; reason?: string };
80
+ const detail = await resp.text();
81
+ throw new ConversationMintError(resp.status, detail);
82
+ }
83
+ return (await resp.json()) as ConversationMintResponse;
84
+ }
85
+
86
+ /**
87
+ * POST /api/projects/:id/conversations with `reattach_conversation_id` body field.
88
+ *
89
+ * On 201 returns the same `ConversationMintResponse` as `mintConversation`.
90
+ * On 409 throws `ReattachFailedError` with the `detail` code from the body.
91
+ * On any other non-2xx throws a generic Error.
92
+ *
93
+ * Reattach failure (409) is NEVER fatal — the caller should fall back to mint.
94
+ */
95
+ export async function reattachConversation(
96
+ baseUrl: string,
97
+ projectId: number,
98
+ instanceToken: string,
99
+ fingerprint: string,
100
+ conversationId: string,
101
+ ): Promise<ConversationMintResponse> {
102
+ const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
103
+ method: "POST",
104
+ headers: {
105
+ Authorization: `Bearer ${instanceToken}`,
106
+ "X-Client-Fingerprint": fingerprint,
107
+ "Content-Type": "application/json",
108
+ ...cfAccessHeaders(),
109
+ },
110
+ body: JSON.stringify({ reattach_conversation_id: conversationId }),
111
+ });
112
+
113
+ if (!resp.ok) {
114
+ if (resp.status === 409) {
115
+ let code = "unknown";
64
116
  try {
65
- body = (await resp.json()) as typeof body;
117
+ const body = (await resp.json()) as { detail?: string };
118
+ if (typeof body.detail === "string") code = body.detail;
66
119
  } catch {
67
- body = {};
120
+ // ignore — code stays "unknown"
68
121
  }
69
- if (body.type === "reverify_required" && typeof body.reverify_url === "string") {
70
- throw new ReverifyRequiredError(body.reverify_url, body.reason ?? "unknown");
71
- }
72
- throw new Error(`Failed to mint conversation token (HTTP 403): ${JSON.stringify(body)}`);
122
+ throw new ReattachFailedError(code);
73
123
  }
74
124
  const detail = await resp.text();
75
- throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
125
+ throw new Error(`Failed to reattach conversation (HTTP ${resp.status}): ${detail}`);
76
126
  }
127
+
77
128
  return (await resp.json()) as ConversationMintResponse;
78
129
  }
79
130
 
@@ -179,3 +230,89 @@ export function defaultLabel(folderPath: string): string {
179
230
  const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
180
231
  return `${folder} • ${ts}`;
181
232
  }
233
+
234
+ /**
235
+ * Default reattach window in seconds — matches the server's CONVERSATION_TTL_SECONDS.
236
+ * A handle older than this points at an expired conversation that cannot be reattached.
237
+ */
238
+ export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
239
+
240
+ /**
241
+ * Decide whether to reattach to the persisted conversation or mint a fresh one.
242
+ *
243
+ * Decision logic:
244
+ * 1. Read the handle from `sessionPath`. If present AND `now - saved_at < reattachWindowSeconds`,
245
+ * attempt `reattachConversation`.
246
+ * 2. On reattach success: use it.
247
+ * 3. On `ReattachFailedError` (409): log and fall back to `mintConversation`.
248
+ * 4. On no/stale handle: `mintConversation` directly.
249
+ * 5. After either success: write the (possibly new) conversation_id back to `sessionPath`.
250
+ *
251
+ * @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
252
+ * @param baseUrl Backend base URL
253
+ * @param projectId Project ID from auth.json
254
+ * @param instanceToken Bearer token from auth.json
255
+ * @param fingerprint Client fingerprint from auth.json
256
+ * @param label Conversation label (used only when minting)
257
+ * @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
258
+ * @param reattachWindowSeconds Override the default TTL-based window (for testing / env override)
259
+ * @param log Optional stderr writer, defaults to process.stderr.write
260
+ */
261
+ export async function acquireConversation(
262
+ sessionPath: string,
263
+ baseUrl: string,
264
+ projectId: number,
265
+ instanceToken: string,
266
+ fingerprint: string,
267
+ label: string,
268
+ targetConversationId?: string | null,
269
+ reattachWindowSeconds: number = DEFAULT_REATTACH_WINDOW_SECONDS,
270
+ log: (msg: string) => void = (msg) => process.stderr.write(msg),
271
+ ): Promise<ConversationMintResponse> {
272
+ const nowSeconds = Math.floor(Date.now() / 1000);
273
+ const handle = readHandle(sessionPath);
274
+
275
+ // Branch on `handle` directly so TS narrows it to non-null inside — readHandle
276
+ // guarantees a finite saved_at, so handleAge is always a number here.
277
+ if (handle !== null) {
278
+ const handleAge = nowSeconds - handle.saved_at;
279
+ if (handleAge < reattachWindowSeconds) {
280
+ log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
281
+ try {
282
+ const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, handle.conversation_id);
283
+ log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
284
+ try {
285
+ writeHandle(sessionPath, result.conversation_id);
286
+ } catch (writeErr) {
287
+ // Best-effort — a failed write degrades to "next startup mints fresh"
288
+ // rather than crashing the channel. Disk full, AV lock, read-only mount.
289
+ log(`[viber-channel] Warning: failed to write session handle after reattach: ${String(writeErr)}\n`);
290
+ }
291
+ return result;
292
+ } catch (err) {
293
+ // Reattach failure is NEVER fatal. Everything (ReattachFailedError 409,
294
+ // unexpected 5xx, network errors from a thrown fetch) falls through to
295
+ // mint below, which surfaces a 401 to the caller if the token is dead.
296
+ const reason = err instanceof ReattachFailedError ? err.code : String(err);
297
+ log(`[viber-channel] startup: reattach failed (${reason}), falling back to mint\n`);
298
+ // Fall through to mint below
299
+ }
300
+ } else {
301
+ log(`[viber-channel] startup: session handle stale (conv_id=${handle.conversation_id}, age=${handleAge}s >= ${reattachWindowSeconds}s window), minting fresh\n`);
302
+ }
303
+ } else {
304
+ log(`[viber-channel] startup: no session handle found, minting fresh\n`);
305
+ }
306
+
307
+ // Mint a new conversation
308
+ const result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, targetConversationId ?? null);
309
+ log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
310
+ try {
311
+ writeHandle(sessionPath, result.conversation_id);
312
+ } catch (writeErr) {
313
+ // Best-effort — a failed write degrades to "next startup mints fresh"
314
+ // rather than crashing the channel. Disk full, AV lock, read-only mount.
315
+ log(`[viber-channel] Warning: failed to write session handle after mint: ${String(writeErr)}\n`);
316
+ }
317
+ return result;
318
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * instance.ts — acquire and use the server-issued per-instance identity (#269).
3
+ *
4
+ * The instance_token is the durable, opaque per-agent credential (D4) the channel
5
+ * presents to JOIN a conversation. It replaces the locally-computed
6
+ * client_fingerprint as the identity source. Acquisition is self-healing: an
7
+ * existing install with no instance_token registers one on first run with its
8
+ * stable project_token (no forced manual reconnect).
9
+ */
10
+ import { cfAccessHeaders } from "./cfAccess.js";
11
+ import { authFilePath, persistInstanceCredentials, type AuthJson } from "./auth.js";
12
+
13
+ export interface RegisterInstanceResponse {
14
+ instance_id: string;
15
+ instance_token: string;
16
+ }
17
+
18
+ /**
19
+ * Non-2xx from POST /api/projects/:id/instances. `.status` lets the caller
20
+ * distinguish a revoked/invalid project_token (401 → reconnect) from a transient
21
+ * server error.
22
+ */
23
+ export class InstanceRegisterError extends Error {
24
+ status: number;
25
+ constructor(status: number, detail: string) {
26
+ super(`Failed to register instance (HTTP ${status}): ${detail}`);
27
+ this.name = "InstanceRegisterError";
28
+ this.status = status;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * POST /api/projects/:id/instances — mint a durable per-instance identity.
34
+ * Authenticated by the stable project_token (the orchestrator credential);
35
+ * the worker also wants X-Client-Fingerprint as a stored signal. Returns the
36
+ * opaque instance_token + its server id.
37
+ */
38
+ export async function registerInstance(
39
+ baseUrl: string,
40
+ projectId: number,
41
+ projectToken: string,
42
+ fingerprint: string,
43
+ label?: string,
44
+ kind?: string,
45
+ ): Promise<RegisterInstanceResponse> {
46
+ const body: Record<string, string> = {};
47
+ if (label !== undefined) body.label = label;
48
+ if (kind !== undefined) body.kind = kind; // runtime: "claude-code" | "codex" | … (#280)
49
+ const resp = await fetch(`${baseUrl}/api/projects/${projectId}/instances`, {
50
+ method: "POST",
51
+ headers: {
52
+ Authorization: `Bearer ${projectToken}`,
53
+ "X-Client-Fingerprint": fingerprint,
54
+ "Content-Type": "application/json",
55
+ ...cfAccessHeaders(),
56
+ },
57
+ body: JSON.stringify(body),
58
+ });
59
+ if (!resp.ok) {
60
+ const detail = await resp.text().catch(() => "");
61
+ throw new InstanceRegisterError(resp.status, detail);
62
+ }
63
+ return (await resp.json()) as RegisterInstanceResponse;
64
+ }
65
+
66
+ export interface AcquiredInstance {
67
+ instance_token: string;
68
+ /**
69
+ * Stable per-instance key used to namespace the conversation handle. The
70
+ * server `instance_id` when known; otherwise the `instance_token` itself
71
+ * (env-injected agents may not carry the id). Both are 1:1 with the instance
72
+ * and durable, so either isolates the handle correctly.
73
+ */
74
+ instance_key: string;
75
+ /**
76
+ * The real server-issued `instance_id`, or undefined when only a bare token is
77
+ * known (env-injected token without VIBER_INSTANCE_ID, or a persisted token with
78
+ * no persisted id). Distinct from `instance_key` — which may be the TOKEN as a
79
+ * namespacing fallback. Use THIS for the self-echo guard + sender attribution:
80
+ * the token must never be compared against server instance ids in
81
+ * `sender_instance_id`; undefined → the self-echo guard fails open (delivers everything).
82
+ */
83
+ instance_id?: string;
84
+ }
85
+
86
+ /**
87
+ * Resolve this channel's instance identity (#269), in priority order:
88
+ * 1. `VIBER_INSTANCE_TOKEN` env (orchestrator-injected, folder-less agents) —
89
+ * used verbatim; `VIBER_INSTANCE_ID` keys the handle if also provided, else
90
+ * the token does.
91
+ * 2. `VIBER_CHANNEL_LABEL` env (#280 step-25 — launcher-named agent session):
92
+ * register a FRESH instance carrying that label, NOT persisted — reusing or
93
+ * overwriting the shared auth.json identity would evict the user's main
94
+ * channel session (the #252 instance-collision gotcha).
95
+ * 3. `auth.json` `instance_token` — reused (durable identity, D4 → same id).
96
+ * 4. register a NEW instance with the stable project_token, persisted back to
97
+ * auth.json (self-healing migration — no forced manual reconnect).
98
+ */
99
+ export async function acquireInstance(
100
+ baseUrl: string,
101
+ auth: AuthJson,
102
+ fingerprint: string,
103
+ log: (msg: string) => void = (m) => process.stderr.write(m),
104
+ cwd: string = process.cwd(),
105
+ ): Promise<AcquiredInstance> {
106
+ const envToken = process.env.VIBER_INSTANCE_TOKEN;
107
+ if (envToken !== undefined && envToken.trim() !== "") {
108
+ const token = envToken.trim();
109
+ const envId = process.env.VIBER_INSTANCE_ID;
110
+ const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
111
+ const key = realId ?? token;
112
+ log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
113
+ return { instance_token: token, instance_key: key, instance_id: realId };
114
+ }
115
+
116
+ const envLabel = process.env.VIBER_CHANNEL_LABEL;
117
+ if (envLabel !== undefined && envLabel.trim() !== "") {
118
+ const label = envLabel.trim();
119
+ log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
120
+ const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label);
121
+ return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
122
+ }
123
+
124
+ if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
125
+ const realId =
126
+ auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
127
+ const key = realId ?? auth.instance_token;
128
+ log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
129
+ return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
130
+ }
131
+
132
+ log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
133
+ const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint);
134
+ persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
135
+ log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
136
+ return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
137
+ }