viber-channel 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/auth.ts CHANGED
@@ -33,21 +33,38 @@ export interface AuthJson {
33
33
  instance_id?: string;
34
34
  }
35
35
 
36
+ /**
37
+ * The environment signal — SINGLE source of truth for every env-dependent
38
+ * behavior in the channel (auth file, agent-session yield, …). The DEV backend
39
+ * is the only non-default: anything else (unset / staging / prod) uses the
40
+ * STAGING defaults, so a missing or ambiguous signal can never accidentally
41
+ * pull dev behavior into staging. Derived from `VIBER_BASE_URL`, which the
42
+ * launcher always sets per env.
43
+ */
44
+ export function isDevBackend(
45
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
46
+ ): boolean {
47
+ return baseUrl.includes("viber-dev");
48
+ }
49
+
36
50
  /**
37
51
  * Resolve the auth file path.
38
52
  *
39
- * Defaults to `<cwd>/.viber/auth.json`. The optional `VIBER_AUTH_FILE` env var
40
- * overrides it — absolute paths are used verbatim, relative paths are resolved
41
- * against `cwd`. This lets a local launcher point the channel at an alternate
42
- * credential file (e.g. `.viber/dev.auth.json`) WITHOUT swapping `auth.json`,
43
- * so the default consumer flow (env var unset) is completely unaffected.
53
+ * The optional `VIBER_AUTH_FILE` env var overrides everything — absolute paths
54
+ * verbatim, relative paths resolved against `cwd`. With no override the file is
55
+ * DERIVED from the environment (single source of truth, {@link isDevBackend}):
56
+ * the dev backend uses `.viber/dev.auth.json`, everything else defaults to
57
+ * `.viber/auth.json` (staging). This kills the class of bugs where a dev channel
58
+ * silently authenticated against the dev backend with STAGING creds (`-32000`)
59
+ * because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
44
60
  */
45
61
  export function authFilePath(cwd: string = process.cwd()): string {
46
62
  const override = process.env.VIBER_AUTH_FILE;
47
63
  if (override !== undefined && override.trim() !== "") {
48
64
  return isAbsolute(override) ? override : join(cwd, override);
49
65
  }
50
- return join(cwd, ".viber", "auth.json");
66
+ const file = isDevBackend() ? "dev.auth.json" : "auth.json";
67
+ return join(cwd, ".viber", file);
51
68
  }
52
69
 
53
70
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
@@ -27,7 +27,7 @@ import { buildSseUrl } from "./urls.js";
27
27
  import { cfAccessHeaders } from "./cfAccess.js";
28
28
  import { sessionFilePath, writeHandle } from "./channel_session.js";
29
29
  import type { AuthJson } from "./auth.js";
30
- import { acquireInstance, registerInstance, instanceKindFromEnv, type AcquiredInstance } from "./instance.js";
30
+ import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
31
31
  import {
32
32
  runPersistentControlStream,
33
33
  sendInstanceHeartbeat,
@@ -897,6 +897,9 @@ export async function acquireBridgeInstance(opts: {
897
897
  process.stderr.write(`${logPrefix} instance: registering fresh bridge instance\n`);
898
898
  const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? kind);
899
899
  process.stderr.write(`${logPrefix} instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
900
+ // #398: best-effort self-attach to VIBER_INSTANCE_TEAM (codex/gemma path; the
901
+ // two branches above go through acquireInstance which already self-attaches).
902
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id, (m) => process.stderr.write(m));
900
903
  return { instance_token: reg.instance_token, instance_key: reg.instance_id };
901
904
  }
902
905
 
package/lib/instance.ts CHANGED
@@ -76,6 +76,77 @@ export function instanceKindFromEnv(): string | undefined {
76
76
  return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
77
77
  }
78
78
 
79
+ /**
80
+ * The DB-backed team this process should join (#398), from `VIBER_INSTANCE_TEAM`
81
+ * (set by the spawn path — vibe-master's agent_spec). undefined when unset → the
82
+ * agent joins no team. Mirrors `instanceKindFromEnv`.
83
+ */
84
+ export function instanceTeamFromEnv(): string | undefined {
85
+ const raw = process.env.VIBER_INSTANCE_TEAM;
86
+ return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
87
+ }
88
+
89
+ /**
90
+ * POST /api/projects/:id/teams/members — attach this instance to a team by name
91
+ * (#398). A SEPARATE authorized call AFTER register (the register payload never
92
+ * carries a client-declared team — same discipline as the orchestrator flag).
93
+ * Team is project-scoped metadata; the worker upserts it and verifies the
94
+ * instance belongs to the token's project. Throws InstanceRegisterError on !ok.
95
+ */
96
+ export async function attachInstanceTeam(
97
+ baseUrl: string,
98
+ projectId: number,
99
+ projectToken: string,
100
+ fingerprint: string,
101
+ instanceId: string,
102
+ team: string,
103
+ ): Promise<void> {
104
+ const resp = await fetch(
105
+ `${baseUrl}/api/projects/${projectId}/teams/members`,
106
+ {
107
+ method: "POST",
108
+ headers: {
109
+ Authorization: `Bearer ${projectToken}`,
110
+ "X-Client-Fingerprint": fingerprint,
111
+ "Content-Type": "application/json",
112
+ ...cfAccessHeaders(),
113
+ },
114
+ body: JSON.stringify({ team, instance_id: instanceId }),
115
+ },
116
+ );
117
+ if (!resp.ok) {
118
+ const detail = await resp.text().catch(() => "");
119
+ throw new InstanceRegisterError(resp.status, detail);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Best-effort self-attach to `VIBER_INSTANCE_TEAM`, run AFTER a successful
125
+ * register/acquire (#398, R-CLAUDE: called from BOTH acquireInstance — claude/
126
+ * plain — and acquireBridgeInstance — codex/gemma). No-op when no team is
127
+ * requested or no server instance_id is known. A failure (legacy worker 404,
128
+ * validation, network) is logged and swallowed — the agent still works,
129
+ * un-teamed (forward-compatible). NOT retried.
130
+ */
131
+ export async function maybeAttachTeam(
132
+ baseUrl: string,
133
+ projectId: number,
134
+ projectToken: string,
135
+ fingerprint: string,
136
+ instanceId: string | undefined,
137
+ log: (msg: string) => void = (m) => process.stderr.write(m),
138
+ ): Promise<void> {
139
+ const team = instanceTeamFromEnv();
140
+ if (team === undefined || instanceId === undefined) return;
141
+ try {
142
+ await attachInstanceTeam(baseUrl, projectId, projectToken, fingerprint, instanceId, team);
143
+ log(`[viber-channel] team: attached instance ${instanceId.slice(0, 8)} to team "${team}"\n`);
144
+ } catch (err) {
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ log(`[viber-channel] team: self-attach to "${team}" failed (non-fatal): ${msg}\n`);
147
+ }
148
+ }
149
+
79
150
  export interface AcquiredInstance {
80
151
  instance_token: string;
81
152
  /**
@@ -116,6 +187,10 @@ export async function acquireInstance(
116
187
  log: (msg: string) => void = (m) => process.stderr.write(m),
117
188
  cwd: string = process.cwd(),
118
189
  ): Promise<AcquiredInstance> {
190
+ // Resolve the identity into `result` (one of the four paths), then run the
191
+ // #398 team self-attach ONCE at the single exit — covers every path (incl. the
192
+ // claude/plain register paths) without scattering the call (R-CLAUDE).
193
+ let result: AcquiredInstance;
119
194
  const envToken = process.env.VIBER_INSTANCE_TOKEN;
120
195
  if (envToken !== undefined && envToken.trim() !== "") {
121
196
  const token = envToken.trim();
@@ -123,42 +198,44 @@ export async function acquireInstance(
123
198
  const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
124
199
  const key = realId ?? token;
125
200
  log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
126
- return { instance_token: token, instance_key: key, instance_id: realId };
127
- }
128
-
129
- const envLabel = process.env.VIBER_CHANNEL_LABEL;
130
- if (envLabel !== undefined && envLabel.trim() !== "") {
131
- const label = envLabel.trim();
132
- log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
133
- const reg = await registerInstance(
134
- baseUrl,
135
- auth.project_id,
136
- auth.project_token,
137
- fingerprint,
138
- label,
139
- instanceKindFromEnv(),
140
- );
141
- return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
142
- }
143
-
144
- if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
145
- const realId =
146
- auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
147
- const key = realId ?? auth.instance_token;
148
- log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
149
- return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
201
+ result = { instance_token: token, instance_key: key, instance_id: realId };
202
+ } else {
203
+ const envLabel = process.env.VIBER_CHANNEL_LABEL;
204
+ if (envLabel !== undefined && envLabel.trim() !== "") {
205
+ const label = envLabel.trim();
206
+ log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
207
+ const reg = await registerInstance(
208
+ baseUrl,
209
+ auth.project_id,
210
+ auth.project_token,
211
+ fingerprint,
212
+ label,
213
+ instanceKindFromEnv(),
214
+ );
215
+ result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
216
+ } else if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
217
+ const realId =
218
+ auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
219
+ const key = realId ?? auth.instance_token;
220
+ log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
221
+ result = { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
222
+ } else {
223
+ log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
224
+ const reg = await registerInstance(
225
+ baseUrl,
226
+ auth.project_id,
227
+ auth.project_token,
228
+ fingerprint,
229
+ undefined,
230
+ instanceKindFromEnv(),
231
+ );
232
+ persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
233
+ log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
234
+ result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
235
+ }
150
236
  }
151
237
 
152
- log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
153
- const reg = await registerInstance(
154
- baseUrl,
155
- auth.project_id,
156
- auth.project_token,
157
- fingerprint,
158
- undefined,
159
- instanceKindFromEnv(),
160
- );
161
- persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
162
- log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
163
- return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
238
+ // #398: best-effort self-attach to VIBER_INSTANCE_TEAM (no-op when unset).
239
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, result.instance_id, log);
240
+ return result;
164
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  } from "./lib/conversation.ts";
43
43
  import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
44
44
  import { startParentWatchdog } from "./lib/parent_watchdog.ts";
45
- import { acquireInstance, registerInstance } from "./lib/instance.ts";
45
+ import { acquireInstance, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
46
46
  import {
47
47
  runPersistentControlStream,
48
48
  sendInstanceHeartbeat,
@@ -71,7 +71,17 @@ import { capabilitiesText } from "./lib/capabilities.ts";
71
71
  // loads in the same session with the same env; instead of racing it for the
72
72
  // channel lock, it bows out deterministically here. Normal sessions
73
73
  // (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
74
+ //
75
+ // BUT the yield is a STAGING/PROD-only behavior: the plugin channel only runs
76
+ // there (it is disabled in the Viber dev repo, #365). On the DEV backend there
77
+ // is NO plugin to yield to, so yielding = no channel at all (`-32000`, no
78
+ // registration). Derive this from the environment (single source of truth,
79
+ // isDevBackend) instead of relying on VIBER_CHANNEL_AGENT_SESSION not leaking
80
+ // from the parent shell into the spawned dev terminal (the real bug: that var
81
+ // leaked and terminal.ts never cleared it, so the dev channel silently bowed
82
+ // out). Default (non-dev) behavior is unchanged.
74
83
  if (
84
+ !isDevBackend() &&
75
85
  process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
76
86
  process.env.VIBER_CHANNEL_PLUGIN !== "1"
77
87
  ) {
@@ -307,7 +317,12 @@ process.stdin.on("end", () => {
307
317
 
308
318
  // ---- Auth from .viber/auth.json ----
309
319
 
310
- import { authFilePath, loadAuth, persistInstanceCredentials } from "./lib/auth.ts";
320
+ import {
321
+ authFilePath,
322
+ isDevBackend,
323
+ loadAuth,
324
+ persistInstanceCredentials,
325
+ } from "./lib/auth.ts";
311
326
  import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
312
327
  import { buildSseUrl } from "./lib/urls.ts";
313
328
 
@@ -678,6 +693,8 @@ try {
678
693
  persistInstanceCredentials(authFilePath(), reg.instance_id, reg.instance_token);
679
694
  instanceToken = reg.instance_token;
680
695
  OWN_INSTANCE_ID = reg.instance_id;
696
+ // #398: re-attach the fresh (post-revocation) instance to its team.
697
+ await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, auth.client_fingerprint, reg.instance_id);
681
698
  SESSION_FILE = sessionFilePath(LOCK_BASE_URL, reg.instance_id, LOCK_DIR);
682
699
  minted = await acquireConversation(
683
700
  SESSION_FILE,
@@ -18,7 +18,7 @@ import {
18
18
  } from "./lib/conversation.ts";
19
19
  import { sessionFilePath, writeHandle } from "./lib/channel_session.ts";
20
20
  import { clientFingerprint } from "./lib/fingerprint.ts";
21
- import { acquireInstance, instanceKindFromEnv, registerInstance } from "./lib/instance.ts";
21
+ import { acquireInstance, instanceKindFromEnv, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
22
22
  import {
23
23
  acquireBridgeLock as coreAcquireBridgeLock,
24
24
  agentIdentityFromEnv,
@@ -820,6 +820,9 @@ async function acquireBridgeInstance(
820
820
  process.stderr.write("[viber-codex-bridge] instance: registering fresh bridge instance\n");
821
821
  const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
822
822
  process.stderr.write(`[viber-codex-bridge] instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
823
+ // #398 (R-CODEX-FRESH): self-attach here too so codex is teamed structurally,
824
+ // not only via the VIBER_INSTANCE_TOKEN branch above (parity with claude).
825
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
823
826
  return { instance_token: reg.instance_token, instance_key: reg.instance_id };
824
827
  }
825
828
 
@@ -885,6 +888,8 @@ async function acquireTargetConversation(
885
888
  const reg = await registerInstance(BASE_URL, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
886
889
  instanceToken = reg.instance_token;
887
890
  instanceKey = reg.instance_id;
891
+ // #398: re-attach the fresh (post-revocation) codex instance to its team.
892
+ await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
888
893
  // Identity changed → re-key the lock onto the new instance. Acquire the new
889
894
  // lock BEFORE releasing the old one: if acquisition throws, the old lock is
890
895
  // still held and the outer catch releases exactly it (no dangling reference).