viber-channel 0.8.16 → 0.8.19

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.
@@ -17,7 +17,7 @@
17
17
  * MCP tool-result shape. It does NOT hold state.
18
18
  */
19
19
  import { postMessage, parseArtifact, type Artifact } from "./messages.js";
20
- import { listPeersAuto, openDm, type OpenDmResult } from "./peers.js";
20
+ import { listPeersAuto, openDm, type OpenDmResult, type PeerFilters } from "./peers.js";
21
21
  import { ConversationTokenExpiredError } from "./messages.js";
22
22
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
23
23
 
@@ -98,12 +98,27 @@ function errorText(s: string): AgentToolResult {
98
98
  * entry then carries a `project` field); a regular instance transparently
99
99
  * falls back to its own project (unchanged behaviour, no `project` field).
100
100
  */
101
- export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResult> {
101
+ export async function listAgents(
102
+ ctx: AgentToolsContext,
103
+ args: Record<string, unknown> = {},
104
+ ): Promise<AgentToolResult> {
102
105
  if (!ctx.instanceToken()) {
103
106
  return errorText("Channel not ready: no instance identity yet.");
104
107
  }
108
+ // #501 step-05 — optional filters, applied SERVER-side. Without them a
109
+ // dev-lead looking for its two reviewers pulls every agent the project ever
110
+ // created (30 rows for 5 live ones, measured), in a loop.
111
+ const filters: PeerFilters = {};
112
+ if (args.online === true) filters.online = true;
113
+ if (typeof args.label_prefix === "string" && args.label_prefix !== "") {
114
+ filters.labelPrefix = args.label_prefix;
115
+ }
105
116
  try {
106
- const { peers, scope } = await listPeersAuto(ctx.baseUrl(), ctx.instanceToken());
117
+ const { peers, scope, presenceStatus } = await listPeersAuto(
118
+ ctx.baseUrl(),
119
+ ctx.instanceToken(),
120
+ filters,
121
+ );
107
122
  // Project only what the model needs to pick a peer (drop last_seen /
108
123
  // active_conversation_id — available over the wire if a future tool needs them).
109
124
  const summary = peers.map((p) => ({
@@ -114,13 +129,22 @@ export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResul
114
129
  // Present only in the user-scoped (orchestrator) listing.
115
130
  ...(p.project_name !== undefined ? { project: p.project_name } : {}),
116
131
  }));
132
+ // An `online` filter under an unreadable presence source would come back
133
+ // empty and read as "nobody is alive" — the false green in listing form.
134
+ // The server drops the filter in that case; say so rather than let the
135
+ // caller believe it was applied.
136
+ const presenceWarning =
137
+ filters.online === true && presenceStatus === "unavailable"
138
+ ? "WARNING: the presence source is unavailable, so the `online` filter was NOT applied — " +
139
+ "`online` values below are unknown, not observed.\n\n"
140
+ : "";
117
141
  const body =
118
142
  summary.length === 0
119
143
  ? scope === "user"
120
144
  ? "No other agents are currently registered in any of your projects."
121
145
  : "No other agents are currently registered in this project."
122
146
  : JSON.stringify(summary, null, 2);
123
- return text(body);
147
+ return text(`${presenceWarning}${body}`);
124
148
  } catch (err) {
125
149
  return errorText(`list_agents failed: ${String(err)}`);
126
150
  }
package/lib/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
2
+ import { basename, isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
5
5
  schema_version: number;
@@ -63,15 +63,81 @@ export function isDevBackend(
63
63
  * silently authenticated against the dev backend with STAGING creds (`-32000`)
64
64
  * because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
65
65
  */
66
- export function authFilePath(cwd: string = process.cwd()): string {
66
+ export function authFilePath(
67
+ cwd: string = process.cwd(),
68
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
69
+ ): string {
70
+ const qa = isQaBackend(baseUrl);
67
71
  const override = process.env.VIBER_AUTH_FILE;
68
72
  if (override !== undefined && override.trim() !== "") {
73
+ assertAuthFileMatchesBackend(basename(override), qa);
69
74
  return isAbsolute(override) ? override : join(cwd, override);
70
75
  }
71
- const file = isDevBackend() ? "dev.auth.json" : "auth.json";
76
+ const file = isDevBackend(baseUrl) ? "dev.auth.json" : qa ? QA_AUTH_FILE : "auth.json";
72
77
  return join(cwd, ".viber", file);
73
78
  }
74
79
 
80
+ /**
81
+ * #613: the auth file `connect` must write for a claim URL. The CLAIM decides
82
+ * for qa: `connect https://viber-qa.dgypx.dev/connect/<id>` run with no
83
+ * VIBER_BASE_URL would otherwise derive the file from the (empty) env and write
84
+ * qa credentials into `auth.json`, overwriting STAGING's. A set env that
85
+ * contradicts the claim (qa vs not qa) is refused. Non-qa claims keep the
86
+ * pre-#613 behavior (file derived from the env) unchanged.
87
+ */
88
+ export function connectAuthPath(
89
+ claimBaseUrl: string,
90
+ cwd: string = process.cwd(),
91
+ envBaseUrl: string = process.env.VIBER_BASE_URL ?? "",
92
+ ): string {
93
+ const claimQa = isQaBackend(claimBaseUrl);
94
+ if (envBaseUrl.trim() !== "" && claimQa !== isQaBackend(envBaseUrl)) {
95
+ throw new Error(
96
+ `claim URL ${claimBaseUrl} and VIBER_BASE_URL=${envBaseUrl} disagree on the qa backend; refusing to write credentials`,
97
+ );
98
+ }
99
+ return authFilePath(cwd, claimQa ? claimBaseUrl : envBaseUrl);
100
+ }
101
+
102
+ /** #613: the qa web host. Matched EXACTLY (hostname), never by substring. */
103
+ export const QA_WEB_HOST = "viber-qa.dgypx.dev";
104
+ export const QA_AUTH_FILE = "qa.auth.json";
105
+
106
+ /**
107
+ * #613: true only when `VIBER_BASE_URL` is the qa web host. Same raw-env
108
+ * classification rule as {@link isDevBackend} (never the API transport host),
109
+ * but an exact hostname match: a substring test would let any host containing
110
+ * "qa" read qa credentials, or a qa host fall through to STAGING's auth.json.
111
+ */
112
+ export function isQaBackend(
113
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
114
+ ): boolean {
115
+ try {
116
+ return new URL(baseUrl).hostname === QA_WEB_HOST;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * #613: a `VIBER_AUTH_FILE` override still wins, but a CONTRADICTORY one is
124
+ * refused instead of resolved in silence: qa credentials against another
125
+ * backend, or another env's well-known file against the qa backend. Custom
126
+ * file names stay allowed on both sides.
127
+ */
128
+ function assertAuthFileMatchesBackend(file: string, qa: boolean): void {
129
+ if (!qa && file === QA_AUTH_FILE) {
130
+ throw new Error(
131
+ `VIBER_AUTH_FILE points at ${QA_AUTH_FILE} but VIBER_BASE_URL is not https://${QA_WEB_HOST}`,
132
+ );
133
+ }
134
+ if (qa && (file === "auth.json" || file === "dev.auth.json")) {
135
+ throw new Error(
136
+ `VIBER_BASE_URL is the qa backend but VIBER_AUTH_FILE points at ${file} (another env's credentials)`,
137
+ );
138
+ }
139
+ }
140
+
75
141
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
76
142
  const path = authFilePath(cwd);
77
143
  let raw: string;
@@ -306,11 +306,13 @@ export async function postBridgeReply(opts: {
306
306
  ownPostedIds: Set<string>;
307
307
  signal?: AbortSignal;
308
308
  logPrefix: string;
309
+ /** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
310
+ fetchImpl?: typeof fetch;
309
311
  }): Promise<void> {
310
312
  const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
311
313
  let result;
312
314
  try {
313
- result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal);
315
+ result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
314
316
  } catch (err) {
315
317
  if (err instanceof ConversationTokenExpiredError) {
316
318
  throw new BridgeShutdownError("conversation token expired", 3);
@@ -345,6 +347,93 @@ export function decideTurnPostAction(opts: {
345
347
  return "auto-post";
346
348
  }
347
349
 
350
+ /** Max length of the runtime cause quoted verbatim in a channel failure notice. */
351
+ const FAILURE_CAUSE_MAX = 400;
352
+
353
+ /** Transport degradation observed during a turn (#606 step-02, codex only). */
354
+ export type TurnDegradation = { events: number; lastStatus?: number };
355
+
356
+ /**
357
+ * #606 — the text a bridge posts on the CHANNEL when a turn fails.
358
+ *
359
+ * The whole point of the issue is that a failed turn was written to stderr only,
360
+ * so the model filled the silence with an INVENTED cause. This text therefore
361
+ * quotes what the runtime said, VERBATIM and truncated, and adds nothing of its
362
+ * own: no classification, no advice, no guess. Pure (no clock, no network) so it
363
+ * is testable without a harness.
364
+ */
365
+ export function failureNoticeText(err: unknown, degradation?: TurnDegradation): string {
366
+ const raw = safeErrorMessage(err).replace(/\s+/g, " ").trim();
367
+ const cause = raw.length > FAILURE_CAUSE_MAX ? `${raw.slice(0, FAILURE_CAUSE_MAX)}...` : raw || "(aucun detail)";
368
+ let text = `Tour echoue, aucune reponse produite. Cause rapportee par le runtime: ${cause}`;
369
+ if (degradation && degradation.events > 0) {
370
+ const status = degradation.lastStatus ? `, dernier statut HTTP ${degradation.lastStatus}` : "";
371
+ text += ` (transport degrade: ${degradation.events} evenement(s) avant l'echec${status})`;
372
+ }
373
+ return text;
374
+ }
375
+
376
+ /**
377
+ * #606 — the dedup signature of a failure cause.
378
+ *
379
+ * A blown quota fails EVERY subsequent turn: without dedup the fix that makes a
380
+ * failure speak would flood the channel. Two failures of the same cause must
381
+ * therefore yield the SAME key, so the volatile identifiers a runtime stamps on
382
+ * each attempt (turnId, threadId, cf-ray, timestamps, long hex ids) are stripped.
383
+ *
384
+ * The degradation COUNT is deliberately reduced to its presence, never its value:
385
+ * keying on the number would make every different N a "new cause" and re-open the
386
+ * flood the dedup exists to close.
387
+ */
388
+ export function failureCauseKey(err: unknown, degradation?: TurnDegradation): string {
389
+ const normalized = safeErrorMessage(err)
390
+ .replace(/"(?:turnId|threadId|conversationId|requestId)"\s*:\s*"[^"]*"/g, "")
391
+ // STOPS at a JSON delimiter (review codex P2a). `\S+` here crossed the
392
+ // closing quote and ate everything up to the next SPACE — measured: two
393
+ // errors differing only by httpStatusCode 503 vs 429 collapsed onto
394
+ // `Codex turn failed: {"message":"Request failed,` because the status sat
395
+ // AFTER the cf-ray. A normalisation that eats the discriminator gags the
396
+ // second outage: the exact defect this key exists to avoid.
397
+ .replace(/cf-ray:\s*[^\s",}\]]+/gi, "")
398
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, "")
399
+ // The RESET INSTANT of a quota, in the two shapes our own log carries for
400
+ // the same outage (review codex, .raw l.95 vs l.111): with the date and
401
+ // without. Stripping only the clock left them on different keys, so the
402
+ // same cause announced twice.
403
+ .replace(
404
+ /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}(?:st|nd|rd|th)?,?\s*\d{4}\b/gi,
405
+ "",
406
+ )
407
+ .replace(/\b\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?\b/gi, "")
408
+ .replace(/\b[0-9a-f]{12,}\b/gi, "")
409
+ .replace(/\s+/g, " ")
410
+ .trim();
411
+ const degraded = degradation && degradation.events > 0 ? "|degraded" : "";
412
+ // WHOLE, neither truncated nor hashed (review codex, both passes). Slicing to a
413
+ // display bound made the key lose identity past that bound — two errors sharing
414
+ // a long prefix became one cause and the second was silenced. A digest fixed
415
+ // that but kept a residual collision, which costs a SUPPRESSED notice: the
416
+ // defect this key exists to prevent, made rare instead of impossible.
417
+ // There is no size constraint to trade against: the key is one closure variable
418
+ // holding one cause at a time. Keeping the normalised string entire removes the
419
+ // class rather than shrinking it — a display concern must not decide identity,
420
+ // which is the whole lesson of P2b.
421
+ return `${normalized}${degraded}`;
422
+ }
423
+
424
+ /**
425
+ * #606 step-02 — read the transport degradation a runtime attached to the error
426
+ * it threw. The failing turn never returns a TurnResult, so the count cannot ride
427
+ * on one; the codex bridge stamps it on the Error itself. Any runtime that stamps
428
+ * nothing (gemma: a single fetch, no intermediate transport events to observe)
429
+ * simply yields undefined, and the notice omits the clause.
430
+ */
431
+ export function readTurnDegradation(err: unknown): TurnDegradation | undefined {
432
+ const d = (err as { degradation?: unknown })?.degradation as TurnDegradation | undefined;
433
+ if (!d || typeof d.events !== "number" || d.events <= 0) return undefined;
434
+ return { events: d.events, lastStatus: typeof d.lastStatus === "number" ? d.lastStatus : undefined };
435
+ }
436
+
348
437
  /**
349
438
  * #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
350
439
  * True when this id was already DELIVERED in a prior run and must be skipped on a
@@ -570,6 +659,34 @@ export async function sseLoop(opts: {
570
659
  const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
571
660
  const ownPostedIds = new Set<string>();
572
661
 
662
+ // #606 anti-flood state: the cause key of the LAST failure actually announced
663
+ // on the channel. A blown quota fails every subsequent turn, so announcing each
664
+ // one would replace a silence bug with a spam bug. Closure state, owned by this
665
+ // loop — no module singleton (.claude/rules/architecture.md §7).
666
+ // Cross-RUN note. This comment CLAIMED the key survives a re-join; it does not,
667
+ // and N.11 measures the opposite (review codex, prose-has-no-test: the code was
668
+ // right, the sentence was false). What actually happens: the watermark
669
+ // deliberately does NOT advance on a failed reply (at-least-once retry, see
670
+ // below), so a re-join replays the message and fails again — and since each
671
+ // sseLoop call owns a FRESH closure, that re-join announces once more. Same for
672
+ // a process restart. Deliberate: a bridge that comes back and hits the same wall
673
+ // is information. Within one join, the key is what stops the flood.
674
+ let lastAnnouncedCause: string | undefined;
675
+
676
+ /**
677
+ * #606 — a DELIVERED turn means the previous cause is over: rearm the notice.
678
+ *
679
+ * Lives HERE, on the turn outcome, and not at a caller. Review F2 measured why:
680
+ * written at the text caller only, the VOICE path never rearmed, so
681
+ * text-fails → voice-recovers → text-fails-again left the third failure GAGGED
682
+ * — the issue's own defect, reproduced by the issue's fix. Placing it on the
683
+ * outcome removes the class instead of patching the two paths that exist today.
684
+ * An ABORTED turn never gets here: teardown says nothing about runtime health.
685
+ */
686
+ function rearmFailureNotice(): void {
687
+ lastAnnouncedCause = undefined;
688
+ }
689
+
573
690
  // Run one turn for `content` via the injected runtime, then auto-post its
574
691
  // reply UNLESS an outbound tool already posted this turn (anti-double-post).
575
692
  // Returns true when the reply was dropped because the stream was aborted
@@ -598,12 +715,15 @@ export async function sseLoop(opts: {
598
715
  return true;
599
716
  case "suppress-outbound":
600
717
  process.stderr.write(`${logPrefix} outbound tool posted this turn on ${runtime.id}; suppressing final auto-post\n`);
718
+ rearmFailureNotice();
601
719
  return false;
602
720
  case "skip-empty":
603
721
  process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
722
+ rearmFailureNotice();
604
723
  return false;
605
724
  case "auto-post":
606
- await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
725
+ await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl });
726
+ rearmFailureNotice();
607
727
  return false;
608
728
  }
609
729
  }
@@ -652,6 +772,49 @@ export async function sseLoop(opts: {
652
772
  .finally(() => ackInFlightIds.delete(idStr));
653
773
  }
654
774
 
775
+
776
+ /**
777
+ * #606 — say a failed turn on the CHANNEL, best-effort.
778
+ *
779
+ * Deliberately unable to make things worse:
780
+ * - the transport may be the very thing that broke (the measured case was a
781
+ * 503 storm), so a failing notice is swallowed to stderr, NEVER retried and
782
+ * never rethrown — one failure must not become two;
783
+ * - it posts through postBridgeReply, so the notice id lands in ownPostedIds
784
+ * like any other reply and cannot feed itself back through handleInboundMessage;
785
+ * - the cause key is recorded only AFTER a post that actually succeeded, so a
786
+ * lost notice does not silence the next one.
787
+ */
788
+ async function announceTurnFailure(err: unknown): Promise<void> {
789
+ const degradation = readTurnDegradation(err);
790
+ const key = failureCauseKey(err, degradation);
791
+ if (key === lastAnnouncedCause) {
792
+ process.stderr.write(`${logPrefix} failure notice suppressed (same cause as the previous one)\n`);
793
+ return;
794
+ }
795
+ try {
796
+ await postBridgeReply({
797
+ baseUrl: baseUrl(),
798
+ runtime,
799
+ text: failureNoticeText(err, degradation),
800
+ ownPostedIds,
801
+ signal,
802
+ logPrefix,
803
+ fetchImpl,
804
+ });
805
+ lastAnnouncedCause = key;
806
+ } catch (notifyErr) {
807
+ // Includes BridgeShutdownError from an expired token: swallowed here on
808
+ // purpose. CONSEQUENCE, named because it is not obvious (review F3): a
809
+ // token expiry first met BY THE NOTICE is discovered later, on the next
810
+ // turn, instead of shutting the bridge down now. Accepted — this path
811
+ // runs while ALREADY handling a failure; escalating a
812
+ // shutdown from the notice would turn "the agent could not answer" into
813
+ // "the agent died", which is a worse report than the one we came to give.
814
+ process.stderr.write(`${logPrefix} failure notice could not be posted: ${safeErrorMessage(notifyErr)}\n`);
815
+ }
816
+ }
817
+
655
818
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
656
819
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
657
820
  // TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
@@ -688,6 +851,7 @@ export async function sseLoop(opts: {
688
851
  } catch (err) {
689
852
  if (err instanceof BridgeShutdownError) throw err;
690
853
  process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
854
+ await announceTurnFailure(err);
691
855
  }
692
856
  return options.once === true;
693
857
  }
@@ -810,6 +974,12 @@ export async function sseLoop(opts: {
810
974
  } catch (err) {
811
975
  if (err instanceof BridgeShutdownError) throw err;
812
976
  process.stderr.write(`${logPrefix} failed to handle transcription: ${safeErrorMessage(err)}\n`);
977
+ // #606: a VOICE turn that fails was the second silent swallow in this
978
+ // same chain — found while building the tests, not by the grep on
979
+ // "failed to handle message", which only ever proved the uniqueness of
980
+ // the TEXT path. A user speaking to a dead runtime deserves the same
981
+ // notice as one typing to it.
982
+ await announceTurnFailure(err);
813
983
  }
814
984
  if (options.once) return;
815
985
  }
@@ -92,8 +92,19 @@ export const TOOL_DEFS = [
92
92
  "List the OTHER agents (instances) you can DM. Returns each agent's id, " +
93
93
  "label, runtime kind, and whether it is online. Use it to find an id before message_agent. " +
94
94
  "Normally project-scoped; an ORCHESTRATOR instance (#307) sees all the owner's projects, " +
95
- "each entry tagged with a `project` field.",
96
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
95
+ "each entry tagged with a `project` field. " +
96
+ "FILTER instead of listing everything (#501): `online: true` for live agents only, " +
97
+ "`label_prefix` for one team (e.g. \"501-\"). Both are applied server-side. " +
98
+ "If the presence source is unreachable the `online` filter is DROPPED and the answer " +
99
+ "says so — an empty list would wrongly read as \"no agent is alive\".",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ online: { type: "boolean", description: "Only agents currently seen online." },
104
+ label_prefix: { type: "string", description: 'Only labels starting with this, e.g. "501-".' },
105
+ },
106
+ additionalProperties: false,
107
+ },
97
108
  },
98
109
  {
99
110
  name: "message_agent",
@@ -146,7 +157,7 @@ export async function dispatchBridgeTool(
146
157
  ): Promise<AgentToolResult> {
147
158
  let result: AgentToolResult;
148
159
  if (name === "list_agents") {
149
- result = await listAgents(opts.ctx);
160
+ result = await listAgents(opts.ctx, args);
150
161
  } else if (name === "message_agent") {
151
162
  result = await messageAgent(opts.ctx, args);
152
163
  } else if (name === "send_message") {
@@ -71,10 +71,24 @@ export const CLAUDE_TOOL_DEFS = [
71
71
  "Use this to find the id of an agent (e.g. 'Codex Review') before calling message_agent. " +
72
72
  "Normally scoped to this project; if the owner designated this instance an ORCHESTRATOR " +
73
73
  "(#307), the list covers ALL the owner's projects and each entry carries a `project` field. " +
74
- "You are never in the list.",
74
+ "You are never in the list. " +
75
+ "FILTER instead of listing everything (#501): `online: true` returns only agents the server " +
76
+ 'currently sees online, `label_prefix` only those whose label starts with it (e.g. "501-" ' +
77
+ "for one team). Both are applied server-side, so an unfiltered call in a loop is the " +
78
+ "expensive path. If the presence source is unreachable the `online` filter is DROPPED and " +
79
+ 'the answer says so — an empty list would wrongly read as "no agent is alive".',
75
80
  inputSchema: {
76
81
  type: "object" as const,
77
- properties: {},
82
+ properties: {
83
+ online: {
84
+ type: "boolean",
85
+ description: "Only agents the server currently sees online.",
86
+ },
87
+ label_prefix: {
88
+ type: "string",
89
+ description: 'Only agents whose label starts with this prefix, e.g. "501-".',
90
+ },
91
+ },
78
92
  required: [],
79
93
  additionalProperties: false,
80
94
  },
package/lib/connect.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
21
  import { dirname, isAbsolute, join, relative } from "node:path";
22
- import { authFilePath } from "./auth.js";
22
+ import { connectAuthPath, isQaBackend } from "./auth.js";
23
23
  import { clientFingerprint } from "./fingerprint.js";
24
24
  import { cfAccessHeaders } from "./cfAccess.js";
25
25
 
@@ -50,6 +50,21 @@ interface TerminalResponse {
50
50
 
51
51
  type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
52
52
 
53
+ const DEFAULT_NEXT_STEPS =
54
+ `Next steps:\n` +
55
+ ` 1. Register the channel MCP server (one-time per machine):\n` +
56
+ ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
57
+ ` 2. Restart Claude Code in this directory.\n\n`;
58
+
59
+ // #613: qa never runs the published channel — the default advice would be wrong
60
+ // at the exact moment of the qa gesture.
61
+ export const QA_NEXT_STEPS =
62
+ `Next steps (qa):\n` +
63
+ ` 1. Run the channel FROM SOURCE in the Gateway worktree: register viber-qa-channel\n` +
64
+ ` in its local .mcp.json (bun ./viber-channel/viber-channel.ts,\n` +
65
+ ` VIBER_BASE_URL=https://viber-qa.dgypx.dev). NEVER bunx viber-channel@latest.\n` +
66
+ ` 2. See plans/613-env-qa (mount procedure).\n\n`;
67
+
53
68
  const POLL_INTERVAL_MS = 2000;
54
69
  const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
55
70
 
@@ -77,6 +92,7 @@ function writeAuthJson(
77
92
  cwd: string,
78
93
  data: ConsumedResponse,
79
94
  fingerprint: string,
95
+ authPath: string,
80
96
  ): void {
81
97
  const viberDir = join(cwd, ".viber");
82
98
  mkdirSync(viberDir, { recursive: true });
@@ -85,7 +101,7 @@ function writeAuthJson(
85
101
  // credentials to the same file the channel will read (e.g. dev.auth.json),
86
102
  // instead of silently writing auth.json while the channel reads elsewhere.
87
103
  // readme.md + .gitignore stay in <cwd>/.viber regardless.
88
- const authPath = authFilePath(cwd);
104
+ // #613: `authPath` comes from connectAuthPath (the claim decides for qa).
89
105
  mkdirSync(dirname(authPath), { recursive: true });
90
106
 
91
107
  const auth: Record<string, unknown> = {
@@ -159,6 +175,8 @@ export async function runConnect(
159
175
  cwd: string = process.cwd(),
160
176
  ): Promise<void> {
161
177
  const { baseUrl, claimId } = parseClaimUrl(claimUrl);
178
+ // #613: resolved BEFORE any network call, so a contradiction fails early.
179
+ const authPath = connectAuthPath(baseUrl, cwd);
162
180
 
163
181
  const fingerprint = clientFingerprint(cwd);
164
182
  process.stderr.write(
@@ -191,14 +209,11 @@ export async function runConnect(
191
209
  }
192
210
  const data = (await pollResp.json()) as PollResponse;
193
211
  if (data.status === "consumed") {
194
- writeAuthJson(cwd, data, fingerprint);
212
+ writeAuthJson(cwd, data, fingerprint, authPath);
195
213
  process.stdout.write(
196
214
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
197
- ` Wrote ${authFilePath(cwd)}\n\n` +
198
- `Next steps:\n` +
199
- ` 1. Register the channel MCP server (one-time per machine):\n` +
200
- ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
201
- ` 2. Restart Claude Code in this directory.\n\n`,
215
+ ` Wrote ${authPath}\n\n` +
216
+ (isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
202
217
  );
203
218
  return;
204
219
  }