mercury-agent 0.8.10 → 0.8.12

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.
@@ -47,7 +47,13 @@ Saved to: {workspace}/inbox/{timestamp}-{type}.{ext}
47
47
  function detectWhatsAppMedia(message: proto.IMessage): WhatsAppMediaInfo | null
48
48
  ```
49
49
 
50
- Checks message for media fields in order:
50
+ The message is first unwrapped with Baileys' `normalizeMessageContent()` —
51
+ WhatsApp wraps some payloads in FutureProofMessage envelopes
52
+ (`documentWithCaptionMessage` for documents sent with a caption,
53
+ `ephemeralMessage` in disappearing chats, `viewOnceMessage`/`viewOnceMessageV2`,
54
+ `editedMessage`). Without unwrapping, a captioned PDF is invisible.
55
+
56
+ Then checks the unwrapped content for media fields in order:
51
57
  1. `audioMessage?.ptt` → voice note
52
58
  2. `audioMessage` → audio
53
59
  3. `imageMessage` → image
@@ -157,7 +163,7 @@ Unknown MIME types default to `.bin`.
157
163
 
158
164
  2. **No built-in transcription** — Voice notes are saved as audio files; pi cannot play them. Install the **voice-transcribe** extension (local Whisper or cloud OpenAI/Groq/Gemini) to prepend a text transcript — see [overview.md](overview.md#voice-transcription).
159
165
 
160
- 3. **Reply context doesn't include file** — When replying to a media message, we include metadata but not the actual file path. The original attachment would need to be looked up.
166
+ 3. **Quoted media re-download is partial** — Replying to a voice/audio/document/image message re-downloads the quoted file into the inbox (so "read this" replies work even if the original send was missed). Quoted videos are not re-fetched — too large to download speculatively on every reply.
161
167
 
162
168
  4. **Ephemeral media** — WhatsApp media URLs expire. Download must happen immediately when the message arrives.
163
169
 
@@ -10,7 +10,7 @@ import {
10
10
  } from "node:fs";
11
11
  import { createRequire } from "node:module";
12
12
  import { homedir, tmpdir } from "node:os";
13
- import { delimiter, dirname, join } from "node:path";
13
+ import { delimiter, dirname, join, resolve } from "node:path";
14
14
  import {
15
15
  getPiAuthCredential,
16
16
  parseOAuthTokenEnv,
@@ -451,7 +451,13 @@ export default function (mercury: {
451
451
  }
452
452
 
453
453
  // 2. Fall back to Mercury's auth.json (OAuth token refresh)
454
- const authPath = config.authPath ?? join(config.globalDir, "auth.json");
454
+ // Resolved, not raw: extensions receive `ctx.config` with `globalDir` still
455
+ // relative, while the container runner resolves it. Both spellings name the
456
+ // same file, and naming it the same way keeps the error strings below (and
457
+ // the refresh dedupe in getPiAuthCredential) consistent between the two.
458
+ const authPath = resolve(
459
+ config.authPath ?? join(config.globalDir, "auth.json"),
460
+ );
455
461
  const cred = await getPiAuthCredential({
456
462
  provider: config.modelProvider,
457
463
  authPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -10,6 +10,7 @@ import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import {
12
12
  downloadMediaMessage,
13
+ normalizeMessageContent,
13
14
  type proto,
14
15
  type WAMessage,
15
16
  type WASocket,
@@ -64,8 +65,11 @@ export interface MediaDownloadOptions {
64
65
  * Returns null if the message has no media.
65
66
  */
66
67
  export function detectWhatsAppMedia(
67
- message: proto.IMessage | null | undefined,
68
+ rawMessage: proto.IMessage | null | undefined,
68
69
  ): WhatsAppMediaInfo | null {
70
+ // Captioned documents (and ephemeral/view-once media) arrive wrapped in a
71
+ // FutureProofMessage envelope, e.g. documentWithCaptionMessage.message.
72
+ const message = normalizeMessageContent(rawMessage ?? undefined);
69
73
  if (!message) return null;
70
74
 
71
75
  // Voice note (push-to-talk)
@@ -238,9 +242,18 @@ export async function downloadWhatsAppMedia(
238
242
  }
239
243
  }
240
244
 
245
+ /** Media types worth re-fetching when someone replies to the message. */
246
+ const QUOTED_DOWNLOAD_TYPES: ReadonlySet<MediaType> = new Set([
247
+ "voice",
248
+ "audio",
249
+ "document",
250
+ "image",
251
+ ]);
252
+
241
253
  /**
242
- * Download audio from a quoted (replied-to) WhatsApp message.
243
- * Only downloads voice/audio types other media types are skipped.
254
+ * Download media from a quoted (replied-to) WhatsApp message.
255
+ * Downloads voice/audio/document/imagevideo is skipped (too large to
256
+ * re-fetch speculatively on every reply).
244
257
  */
245
258
  export async function downloadQuotedMedia(
246
259
  contextInfo: proto.IContextInfo,
@@ -253,7 +266,7 @@ export async function downloadQuotedMedia(
253
266
  const mediaInfo = detectWhatsAppMedia(quotedMessage);
254
267
  if (!mediaInfo) return null;
255
268
 
256
- if (mediaInfo.type !== "voice" && mediaInfo.type !== "audio") return null;
269
+ if (!QUOTED_DOWNLOAD_TYPES.has(mediaInfo.type)) return null;
257
270
 
258
271
  const syntheticMsg: WAMessage = {
259
272
  key: {
@@ -310,7 +323,12 @@ export async function downloadQuotedMedia(
310
323
  fs.mkdirSync(mediaDir, { recursive: true });
311
324
 
312
325
  const ext = mimeToExt(mediaInfo.mimeType);
313
- const filename = `${Date.now()}-${mediaInfo.type}.${ext}`;
326
+ const safeName = mediaInfo.filename
327
+ ? path.basename(mediaInfo.filename).replace(/[^a-zA-Z0-9._-]/g, "_")
328
+ : undefined;
329
+ const filename = safeName
330
+ ? `${Date.now()}-${safeName}`
331
+ : `${Date.now()}-${mediaInfo.type}.${ext}`;
314
332
  const filePath = path.join(mediaDir, filename);
315
333
 
316
334
  fs.writeFileSync(filePath, buffer);
@@ -327,6 +345,7 @@ export async function downloadQuotedMedia(
327
345
  path: filePath,
328
346
  type: mediaInfo.type,
329
347
  mimeType: mediaInfo.mimeType,
348
+ filename: mediaInfo.filename,
330
349
  sizeBytes: buffer.length,
331
350
  };
332
351
  } catch (error) {
@@ -6,6 +6,7 @@ import makeWASocket, {
6
6
  fetchLatestWaWebVersion,
7
7
  jidDecode,
8
8
  makeCacheableSignalKeyStore,
9
+ normalizeMessageContent,
9
10
  type proto,
10
11
  useMultiFileAuthState,
11
12
  type WAMessage,
@@ -45,7 +46,11 @@ type WhatsAppThreadId = {
45
46
  threadJid: string;
46
47
  };
47
48
 
48
- function extractText(message?: proto.IMessage | null): string {
49
+ function extractText(rawMessage?: proto.IMessage | null): string {
50
+ // Unwrap FutureProofMessage envelopes (documentWithCaptionMessage,
51
+ // ephemeralMessage, viewOnceMessage, …) — a document sent with a caption
52
+ // arrives as documentWithCaptionMessage.message.documentMessage.
53
+ const message = normalizeMessageContent(rawMessage ?? undefined);
49
54
  if (!message) return "";
50
55
  return (
51
56
  message.conversation ||
@@ -58,8 +63,9 @@ function extractText(message?: proto.IMessage | null): string {
58
63
  }
59
64
 
60
65
  function getContextInfo(
61
- message?: proto.IMessage | null,
66
+ rawMessage?: proto.IMessage | null,
62
67
  ): proto.IContextInfo | undefined {
68
+ const message = normalizeMessageContent(rawMessage ?? undefined);
63
69
  if (!message) return undefined;
64
70
  const contextInfo =
65
71
  message.extendedTextMessage?.contextInfo ||
@@ -601,7 +607,16 @@ export class WhatsAppBaileysAdapter
601
607
  }
602
608
 
603
609
  const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
604
- if (!text && !hasMedia) return;
610
+ if (!text && !hasMedia) {
611
+ // Neither text nor media extracted — an unrecognized message shape would
612
+ // otherwise vanish silently (this is how captioned documents got lost
613
+ // before documentWithCaptionMessage unwrapping was added).
614
+ logger.debug("WhatsApp message dropped: no text or media extracted", {
615
+ remoteJid: chatJid,
616
+ messageKeys: Object.keys(msg.message ?? {}),
617
+ });
618
+ return;
619
+ }
605
620
 
606
621
  const threadId = this.encodeThreadId({
607
622
  chatJid,
@@ -12,8 +12,10 @@ import {
12
12
  import path from "node:path";
13
13
  import type { MessageAttachment, StoredMessage } from "../types.js";
14
14
  import {
15
+ type CapabilitySource,
15
16
  DEFAULT_CAPABILITIES,
16
17
  type ModelCapabilities,
18
+ type WireModelCapabilities,
17
19
  } from "./model-capabilities-core.js";
18
20
  import { classifyPiFailure } from "./pi-failure-class.js";
19
21
  import {
@@ -59,48 +61,60 @@ function backoffMs(attemptIndex: number): number {
59
61
  return Math.min(cap, base + jitter);
60
62
  }
61
63
 
62
- function parsePartialCapabilities(obj: unknown): ModelCapabilities {
63
- if (!obj || typeof obj !== "object") return { ...DEFAULT_CAPABILITIES };
64
+ const CAPABILITY_SOURCES: readonly CapabilitySource[] = [
65
+ "env",
66
+ "yaml",
67
+ "builtin",
68
+ "default",
69
+ ];
70
+
71
+ /** Guessed capabilities, explicitly marked as such. */
72
+ function unverifiedCapabilities(): WireModelCapabilities {
73
+ return { ...DEFAULT_CAPABILITIES, source: "default" };
74
+ }
75
+
76
+ function parsePartialCapabilities(obj: unknown): WireModelCapabilities {
77
+ if (!obj || typeof obj !== "object") return unverifiedCapabilities();
64
78
  const o = obj as Record<string, unknown>;
65
- const out = { ...DEFAULT_CAPABILITIES };
79
+ const out: WireModelCapabilities = unverifiedCapabilities();
66
80
  if (typeof o.tools === "boolean") out.tools = o.tools;
67
81
  if (typeof o.vision === "boolean") out.vision = o.vision;
68
82
  if (typeof o.audio_input === "boolean") out.audio_input = o.audio_input;
69
83
  if (typeof o.audio_output === "boolean") out.audio_output = o.audio_output;
70
84
  if (typeof o.extended_thinking === "boolean")
71
85
  out.extended_thinking = o.extended_thinking;
86
+ if (
87
+ typeof o.source === "string" &&
88
+ (CAPABILITY_SOURCES as readonly string[]).includes(o.source)
89
+ ) {
90
+ out.source = o.source as CapabilitySource;
91
+ }
72
92
  return out;
73
93
  }
74
94
 
75
95
  /**
76
96
  * Per-leg capabilities from host (MODEL_CHAIN_CAPABILITIES JSON array).
77
- * When missing or invalid, defaults to DEFAULT_CAPABILITIES for each leg.
97
+ * When missing or invalid, falls back to guessed capabilities marked
98
+ * `source: "default"` so no unverified limitation is asserted to the model.
78
99
  */
79
100
  function parseModelChainCapabilitiesFromEnv(
80
101
  legCount: number,
81
- ): ModelCapabilities[] {
102
+ ): WireModelCapabilities[] {
103
+ const fallback = (): WireModelCapabilities[] =>
104
+ Array.from({ length: legCount }, unverifiedCapabilities);
105
+
82
106
  const raw = process.env.MODEL_CHAIN_CAPABILITIES?.trim();
83
- if (!raw) {
84
- return Array.from({ length: legCount }, () => ({
85
- ...DEFAULT_CAPABILITIES,
86
- }));
87
- }
107
+ if (!raw) return fallback();
88
108
  try {
89
109
  const arr = JSON.parse(raw) as unknown;
90
- if (!Array.isArray(arr)) {
91
- return Array.from({ length: legCount }, () => ({
92
- ...DEFAULT_CAPABILITIES,
93
- }));
94
- }
95
- const out: ModelCapabilities[] = [];
110
+ if (!Array.isArray(arr)) return fallback();
111
+ const out: WireModelCapabilities[] = [];
96
112
  for (let i = 0; i < legCount; i++) {
97
113
  out.push(parsePartialCapabilities(arr[i]));
98
114
  }
99
115
  return out;
100
116
  } catch {
101
- return Array.from({ length: legCount }, () => ({
102
- ...DEFAULT_CAPABILITIES,
103
- }));
117
+ return fallback();
104
118
  }
105
119
  }
106
120
 
@@ -191,33 +205,20 @@ function formatContextTimestamp(ms: number): string {
191
205
  });
192
206
  }
193
207
 
194
- function hasImageAttachments(
195
- attachments: MessageAttachment[] | undefined,
196
- ): boolean {
197
- if (!attachments?.length) return false;
198
- return attachments.some(
199
- (a) =>
200
- a.type === "image" ||
201
- (a.mimeType?.toLowerCase().startsWith("image/") ?? false),
202
- );
203
- }
204
-
205
- function hasAudioAttachments(
206
- attachments: MessageAttachment[] | undefined,
207
- ): boolean {
208
- if (!attachments?.length) return false;
209
- return attachments.some(
210
- (a) =>
211
- a.type === "audio" ||
212
- a.type === "voice" ||
213
- (a.mimeType?.toLowerCase().startsWith("audio/") ?? false),
214
- );
215
- }
216
-
217
- function buildCapabilitySection(
218
- caps: ModelCapabilities,
219
- payload: Payload,
220
- ): string {
208
+ /**
209
+ * Narrates only `tools` — the one capability pi does not track and Mercury can
210
+ * state as fact, since a `false` can only come from operator config.
211
+ *
212
+ * Vision and audio are deliberately absent. pi's `read` tool already emits
213
+ * "[Current model does not support images...]" at the moment an image is read,
214
+ * keyed to the model it is actually calling, and stays silent for model ids it
215
+ * does not recognise. Mercury's up-front version had to guess for unknown ids,
216
+ * and `DEFAULT_CAPABILITIES` guesses `vision: false` — which told every model
217
+ * newer than the pinned pi registry that it was blind. The audio flags could
218
+ * never be anything but `false` from a lookup (pi has no audio models, and
219
+ * voice notes are transcribed host-side), so they only ever added noise.
220
+ */
221
+ export function buildCapabilitySection(caps: WireModelCapabilities): string {
221
222
  const parts: string[] = ["## Current model capabilities"];
222
223
  parts.push(
223
224
  `This turn uses a model with the following constraints (do not assume you can exceed them):`,
@@ -225,15 +226,6 @@ function buildCapabilitySection(
225
226
  parts.push(
226
227
  `- **tools (bash / read / write / edit):** ${caps.tools ? "available" : "NOT available — you cannot run shell commands, read/write workspace files via tools, or use mrctl"}`,
227
228
  );
228
- parts.push(
229
- `- **vision (images):** ${caps.vision ? "available" : "NOT available"}`,
230
- );
231
- parts.push(
232
- `- **audio input:** ${caps.audio_input ? "available" : "NOT available"}`,
233
- );
234
- parts.push(
235
- `- **audio output:** ${caps.audio_output ? "available" : "NOT available"}`,
236
- );
237
229
 
238
230
  if (!caps.tools) {
239
231
  parts.push("");
@@ -242,20 +234,6 @@ function buildCapabilitySection(
242
234
  );
243
235
  }
244
236
 
245
- if (!caps.vision && hasImageAttachments(payload.attachments)) {
246
- parts.push("");
247
- parts.push(
248
- `**Note:** This model cannot process image pixels. Image files are still listed in <attachments /> with paths — you may reference paths and filenames but cannot interpret visual content.`,
249
- );
250
- }
251
-
252
- if (!caps.audio_input && hasAudioAttachments(payload.attachments)) {
253
- parts.push("");
254
- parts.push(
255
- `**Note:** This model cannot process audio. Voice attachments are listed with paths only.`,
256
- );
257
- }
258
-
259
237
  return parts.join("\n");
260
238
  }
261
239
 
@@ -356,7 +334,7 @@ Your prompt may include \`<active_episodes>\` XML with time-bounded topics relev
356
334
  parts.push(claudeCodePreamble);
357
335
  }
358
336
  parts.push(mercuryPlatform);
359
- parts.push(buildCapabilitySection(caps, payload));
337
+ parts.push(buildCapabilitySection(caps));
360
338
  parts.push(memory);
361
339
  parts.push(destructiveOps);
362
340
  parts.push(toolResultPresentation);
@@ -723,6 +701,18 @@ function buildPrompt(payload: Payload): string {
723
701
  return parts.join("\n");
724
702
  }
725
703
 
704
+ /** Read-only bwrap binds for each resource entry the host mounted into pi's agent dir. */
705
+ function piAgentRoBindArgs(): string[] {
706
+ const agentDir = "/home/mercury/.pi/agent";
707
+ if (!existsSync(agentDir)) return [];
708
+ const args: string[] = [];
709
+ for (const entry of readdirSync(agentDir)) {
710
+ const p = `${agentDir}/${entry}`;
711
+ args.push("--ro-bind", p, p);
712
+ }
713
+ return args;
714
+ }
715
+
726
716
  /**
727
717
  * Build bwrap args for sandboxing the agent process.
728
718
  * Uses bubblewrap for defense-in-depth: Docker isolates from host, bwrap restricts within container.
@@ -762,9 +752,11 @@ function buildBwrapArgs(
762
752
  "--bind",
763
753
  "/home/mercury",
764
754
  "/home/mercury",
765
- "--ro-bind",
766
- "/home/mercury/.pi/agent",
767
- "/home/mercury/.pi/agent",
755
+ // The host mounts the global resource dir entry by entry (auth.json stays
756
+ // on the host), so `.pi/agent` is a plain dir holding N read-only submounts
757
+ // rather than a single one. Re-bind each entry explicitly instead of relying
758
+ // on a recursive bind of the parent to carry the read-only flag down.
759
+ ...piAgentRoBindArgs(),
768
760
  "--proc",
769
761
  "/proc",
770
762
  "--dev",
@@ -836,11 +828,14 @@ function invokePiOnce(
836
828
  "--mode",
837
829
  "json",
838
830
  // Skip pi's project-trust store entirely. The store lives in
839
- // /home/mercury/.pi/agent (mounted :ro) and even a read locks it via
840
- // proper-lockfile (mkdir trust.json.lock → EROFS). Workspaces with
841
- // trust-requiring resources already resolved to "untrusted" in
842
- // non-interactive mode, and without such resources nothing trust-gated
843
- // loads — so --no-approve preserves behavior either way.
831
+ // /home/mercury/.pi/agent, whose resource entries are read-only bind
832
+ // mounts, and even a read locks it via proper-lockfile (mkdir
833
+ // trust.json.lock). Workspaces with trust-requiring resources already
834
+ // resolved to "untrusted" in non-interactive mode, and without such
835
+ // resources nothing trust-gated loads — so --no-approve preserves
836
+ // behavior either way (the agent dir itself is now a Docker-created
837
+ // mount parent owned by root, so writing the trust file there would fail
838
+ // with EACCES rather than EROFS — still a failure, still skipped).
844
839
  "--no-approve",
845
840
  ...sessionArgs,
846
841
  "--provider",
@@ -8,7 +8,12 @@ import { mintCallerToken } from "../core/caller-token.js";
8
8
  import { scanOutbox } from "../core/outbox.js";
9
9
  import type { ExtImageBuildState } from "../extensions/image-builder.js";
10
10
  import { type Logger, logger } from "../logger.js";
11
- import { getPiAuthCredential } from "../storage/pi-auth.js";
11
+ import { ensurePiResourceDir } from "../storage/memory.js";
12
+ import {
13
+ getPiAuthCredential,
14
+ hasOAuthEntry,
15
+ providerCredentialEnvVar,
16
+ } from "../storage/pi-auth.js";
12
17
  import type {
13
18
  ContainerResult,
14
19
  MessageAttachment,
@@ -34,6 +39,60 @@ import { ContainerError } from "./container-error.js";
34
39
  */
35
40
  const INNER_IO_DIR = "/run/mercury-io";
36
41
 
42
+ /** Where pi looks for global agent resources inside the inner container (PI_CODING_AGENT_DIR). */
43
+ const INNER_PI_AGENT_DIR = "/home/mercury/.pi/agent";
44
+
45
+ /**
46
+ * Entries of the host global dir that are mounted into `INNER_PI_AGENT_DIR`.
47
+ * Everything pi loads as a resource — and nothing else. `auth.json` (and the
48
+ * `.tmp` siblings an interrupted rotation can leave next to it) is deliberately
49
+ * absent: it carries the OAuth refresh token, and the container only ever needs
50
+ * the short-lived access token handed to it via ANTHROPIC_OAUTH_TOKEN.
51
+ *
52
+ * A superset of what Mercury itself writes (`.pi/`, `AGENTS.md`, `skills/`) —
53
+ * the remaining entries are pi resource kinds a user may drop in by hand, none
54
+ * of which can hold a credential.
55
+ */
56
+ const PI_AGENT_RESOURCE_ENTRIES = [
57
+ ".pi",
58
+ "AGENTS.md",
59
+ "skills",
60
+ "agents",
61
+ "commands",
62
+ "prompts",
63
+ "extensions",
64
+ ] as const;
65
+
66
+ /**
67
+ * Docker `-v` args exposing the global dir's resource entries at
68
+ * `INNER_PI_AGENT_DIR`, one mount per entry rather than one mount of the
69
+ * directory itself. Binding the directory would carry `auth.json` — and the
70
+ * `auth.json.<pid>.tmp` siblings an interrupted rotation can leave next to it
71
+ * (see `writeAuthFile` in storage/pi-auth.ts) — into the least-trusted part of
72
+ * the system, handing it the long-lived refresh token when all it needs is the
73
+ * access token injected as ANTHROPIC_OAUTH_TOKEN. The allowlist fails closed:
74
+ * anything new that lands in the global dir must be opted in here.
75
+ *
76
+ * `hostGlobalDir` is the path this process can stat; `innerGlobalDir` is the
77
+ * path the Docker daemon resolves the bind source against (they differ under
78
+ * MERCURY_HOST_DATA_DIR). Absent entries are skipped — Docker would otherwise
79
+ * create the missing source as a directory, turning `AGENTS.md` into a folder.
80
+ */
81
+ export function buildPiAgentMountArgs(
82
+ hostGlobalDir: string,
83
+ innerGlobalDir: string,
84
+ ): string[] {
85
+ const args: string[] = [];
86
+ for (const entry of PI_AGENT_RESOURCE_ENTRIES) {
87
+ if (!fs.existsSync(path.join(hostGlobalDir, entry))) continue;
88
+ args.push(
89
+ "-v",
90
+ `${path.join(innerGlobalDir, entry)}:${INNER_PI_AGENT_DIR}/${entry}:ro`,
91
+ );
92
+ }
93
+ return args;
94
+ }
95
+
37
96
  /** Poll interval (ms) while waiting for the inner container's result file. */
38
97
  const RESULT_POLL_MS = 150;
39
98
  /** Run a `docker inspect` liveness probe every Nth poll (~2s) to fail fast on crash. */
@@ -647,6 +706,62 @@ export class AgentContainerRunner {
647
706
  : `mercury-${timestamp}-${id}`;
648
707
  }
649
708
 
709
+ /**
710
+ * Resolve each OAuth provider in the model chain from the host's auth.json
711
+ * and append its credential to `envPairs` as the env var pi reads it from.
712
+ *
713
+ * Only providers pi can be handed a token for are servable: `openai-codex`
714
+ * has no env var at all (see `providerCredentialEnvVar`), so a codex leg is
715
+ * dead on arrival and says so in the log rather than failing obscurely inside
716
+ * the container. Providers already carrying a credential through passthrough
717
+ * env are left alone — an explicit `MERCURY_*` value outranks the file.
718
+ *
719
+ * A leg that cannot be served is logged, not thrown: `MODEL_CHAIN` exists so
720
+ * a later leg can carry the turn when an earlier one fails, and refusing the
721
+ * whole spawn over one unusable leg would defeat that. The Anthropic
722
+ * fail-fast above is unaffected — it guards the primary provider.
723
+ */
724
+ private async injectChainOAuthCredentials(
725
+ envPairs: Array<{ key: string; value: string }>,
726
+ extraEnv: Record<string, string> | undefined,
727
+ authPath: string,
728
+ spaceId: string,
729
+ // Injected like `OAuthSpawnDeps` above, so a test can exercise the
730
+ // refresh-failure branch without a token endpoint.
731
+ resolveCredential: typeof getPiAuthCredential = getPiAuthCredential,
732
+ ): Promise<void> {
733
+ const providers = new Set(
734
+ (this.config.resolvedModelChain ?? []).map((leg) => leg.provider),
735
+ );
736
+ for (const provider of providers) {
737
+ const envVar = providerCredentialEnvVar(provider);
738
+ if (!envVar) {
739
+ if (hasOAuthEntry(authPath, provider)) {
740
+ logger.warn(
741
+ `Model chain leg "${provider}" has OAuth credentials in ${authPath}, but pi reads no env var for that provider — the credential cannot be passed to the container (auth.json stays on the host). This leg will fail; use a provider with an API key env var, or drop the leg.`,
742
+ { spaceId, provider },
743
+ );
744
+ }
745
+ continue;
746
+ }
747
+
748
+ const alreadySet =
749
+ envPairs.some((p) => p.key === envVar && p.value) ||
750
+ Boolean(extraEnv?.[envVar]);
751
+ if (alreadySet) continue;
752
+
753
+ const credential = await resolveCredential({ provider, authPath });
754
+ if (credential.status === "ok") {
755
+ envPairs.push({ key: envVar, value: credential.apiKey });
756
+ } else if (credential.status === "refresh-failed") {
757
+ logger.warn(
758
+ `OAuth refresh failed for model chain leg "${provider}" (${authPath}) — the container starts without that credential. Re-authenticate on the host with mercury auth login.`,
759
+ { spaceId, provider },
760
+ );
761
+ }
762
+ }
763
+ }
764
+
650
765
  async reply(input: {
651
766
  spaceId: string;
652
767
  spaceWorkspace: string;
@@ -664,7 +779,11 @@ export class AgentContainerRunner {
664
779
  const globalDir = path.resolve(this.config.globalDir);
665
780
  const spacesRoot = path.resolve(this.config.spacesDir);
666
781
 
667
- fs.mkdirSync(globalDir, { recursive: true });
782
+ // Resource structure, not a bare mkdir: the global dir is mounted entry by
783
+ // entry (see PI_AGENT_RESOURCE_ENTRIES), so an empty dir would leave the
784
+ // container without PI_CODING_AGENT_DIR at all. Idempotent — the same helper
785
+ // runtime.ts calls at startup.
786
+ ensurePiResourceDir(globalDir);
668
787
  fs.mkdirSync(spacesRoot, { recursive: true });
669
788
  try {
670
789
  execFileSync("chown", ["-R", "1000:1000", globalDir], { stdio: "pipe" });
@@ -799,6 +918,17 @@ export class AgentContainerRunner {
799
918
  }
800
919
  }
801
920
 
921
+ // Every other OAuth provider in the resolved chain. The container used to
922
+ // read auth.json itself (the global dir was mounted wholesale); that file
923
+ // now stays on the host because it carries refresh tokens, so each leg's
924
+ // credential must be resolved here and handed over as env instead.
925
+ await this.injectChainOAuthCredentials(
926
+ passthroughEnvPairs,
927
+ input.extraEnv,
928
+ this.config.authPath ?? path.join(globalDir, "auth.json"),
929
+ input.spaceId,
930
+ );
931
+
802
932
  const envPairs = [
803
933
  // Internal vars (set by code, not from env)
804
934
  { key: "HOME", value: "/home/mercury" },
@@ -807,7 +937,7 @@ export class AgentContainerRunner {
807
937
  value:
808
938
  "/home/mercury/.local/bin:/home/mercury/.bun/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin",
809
939
  },
810
- { key: "PI_CODING_AGENT_DIR", value: "/home/mercury/.pi/agent" },
940
+ { key: "PI_CODING_AGENT_DIR", value: INNER_PI_AGENT_DIR },
811
941
  { key: "CALLER_ID", value: input.callerId },
812
942
  { key: "SPACE_ID", value: input.spaceId },
813
943
  {
@@ -957,8 +1087,6 @@ export class AgentContainerRunner {
957
1087
  "-v",
958
1088
  `${innerSpaceDir}:/spaces/${input.spaceId}`,
959
1089
  "-v",
960
- `${innerGlobalDir}:/home/mercury/.pi/agent:ro`,
961
- "-v",
962
1090
  `${readmePath}:/docs/mercury/README.md:ro`,
963
1091
  "-v",
964
1092
  `${docsDir}:/docs/mercury/docs:ro`,
@@ -968,6 +1096,8 @@ export class AgentContainerRunner {
968
1096
  `IO_DIR=${INNER_IO_DIR}`,
969
1097
  );
970
1098
 
1099
+ args.push(...buildPiAgentMountArgs(globalDir, innerGlobalDir));
1100
+
971
1101
  if (this.config.containerRuntime === "runsc") {
972
1102
  // Mount the per-agent run dir so the inner container can reach the outer's
973
1103
  // API unix socket (api-<hostname>.sock, created in main.ts). Mirrors the
@@ -13,6 +13,22 @@ export type ModelCapabilities = {
13
13
 
14
14
  export type ModelCapabilityKey = keyof ModelCapabilities;
15
15
 
16
+ /**
17
+ * Where a capability set came from. `"default"` means the model id matched
18
+ * nothing — the flags below are guesses, not facts, and callers must not
19
+ * present them to the model as constraints. See `buildCapabilitySection`.
20
+ */
21
+ export type CapabilitySource = "env" | "yaml" | "builtin" | "default";
22
+
23
+ /**
24
+ * Capabilities as serialized to the container over `MODEL_CHAIN_CAPABILITIES`.
25
+ * `source` rides along so the container can tell a looked-up `false` from an
26
+ * assumed one.
27
+ */
28
+ export type WireModelCapabilities = ModelCapabilities & {
29
+ source?: CapabilitySource;
30
+ };
31
+
16
32
  /** Fallback when no builtin / YAML / env match. */
17
33
  export const DEFAULT_CAPABILITIES: ModelCapabilities = {
18
34
  tools: true,
@@ -9,18 +9,23 @@ import { parse as parseYaml } from "yaml";
9
9
  import { z } from "zod";
10
10
  import type { ModelLeg } from "../config.js";
11
11
  import {
12
+ type CapabilitySource,
12
13
  DEFAULT_CAPABILITIES,
13
14
  type ModelCapabilities,
14
15
  type ModelCapabilityKey,
16
+ type WireModelCapabilities,
15
17
  } from "./model-capabilities-core.js";
16
18
 
17
19
  export type {
20
+ CapabilitySource,
18
21
  ModelCapabilities,
19
22
  ModelCapabilityKey,
23
+ WireModelCapabilities,
20
24
  } from "./model-capabilities-core.js";
21
25
  export { DEFAULT_CAPABILITIES } from "./model-capabilities-core.js";
22
26
 
23
- export type CapabilityResolveSource = "env" | "yaml" | "builtin" | "default";
27
+ /** @deprecated Use `CapabilitySource` from model-capabilities-core. */
28
+ export type CapabilityResolveSource = CapabilitySource;
24
29
 
25
30
  export type ResolvedModelCapabilities = {
26
31
  capabilities: ModelCapabilities;
@@ -164,22 +169,40 @@ export function resolveModelChainCapabilities(
164
169
  dataDirAbsolute: string,
165
170
  envCaps: ModelCapabilities | null,
166
171
  ): {
167
- chainCaps: ModelCapabilities[];
172
+ chainCaps: WireModelCapabilities[];
168
173
  userMap: UserModelCapabilitiesMap | null;
169
174
  } {
170
175
  const userMap = loadUserModelCapabilitiesMap(dataDirAbsolute);
171
- const chainCaps = chain.map((leg) =>
172
- resolveModelCapabilities(leg.model, leg.provider, userMap, envCaps),
173
- );
176
+ const chainCaps = chain.map((leg) => {
177
+ const { capabilities, source } = resolveModelCapabilitiesWithSource(
178
+ leg.model,
179
+ leg.provider,
180
+ userMap,
181
+ envCaps,
182
+ );
183
+ return { ...capabilities, source };
184
+ });
174
185
  return { chainCaps, userMap };
175
186
  }
176
187
 
177
188
  export function chainSupportsRequirements(
178
189
  requires: ModelCapabilityKey[],
179
- chainCaps: ModelCapabilities[],
190
+ chainCaps: WireModelCapabilities[],
180
191
  ): boolean {
181
192
  if (requires.length === 0) return true;
182
- return chainCaps.some((caps) => requires.every((key) => caps[key] === true));
193
+ // A `false` from an unresolved model id (`source: "default"`) is a guess, not
194
+ // a fact — `DEFAULT_CAPABILITIES` assumes no vision, so every model newer
195
+ // than the pinned pi registry looks incapable here. Dropping a
196
+ // capability-gated extension or skill on that guess removes function from a
197
+ // model that may well support it, and does so invisibly at startup. Treat
198
+ // unknown as permissive: let the extension install and fail visibly at use
199
+ // time instead. This is the last consumer of `source` — the system prompt no
200
+ // longer narrates vision or audio at all (see `buildCapabilitySection`).
201
+ const satisfies = (caps: WireModelCapabilities, key: ModelCapabilityKey) =>
202
+ caps[key] === true || caps.source === "default";
203
+ return chainCaps.some((caps) =>
204
+ requires.every((key) => satisfies(caps, key)),
205
+ );
183
206
  }
184
207
 
185
208
  /** Log warnings for models that fell back to defaults (once per distinct model id). */
@@ -1,6 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import type { proto, WAMessage } from "@whiskeysockets/baileys";
3
+ import {
4
+ normalizeMessageContent,
5
+ type proto,
6
+ type WAMessage,
7
+ } from "@whiskeysockets/baileys";
4
8
  import type { Message } from "chat";
5
9
  import type { WhatsAppBaileysAdapter } from "../adapters/whatsapp.js";
6
10
  import {
@@ -79,13 +83,14 @@ export class WhatsAppBridge implements PlatformBridge {
79
83
  }
80
84
 
81
85
  if (attachments.length === 0) {
86
+ const content = normalizeMessageContent(rawMsg.message ?? undefined);
82
87
  const contextInfo =
83
- rawMsg.message?.extendedTextMessage?.contextInfo ||
84
- rawMsg.message?.audioMessage?.contextInfo ||
85
- rawMsg.message?.imageMessage?.contextInfo ||
86
- rawMsg.message?.videoMessage?.contextInfo ||
87
- rawMsg.message?.documentMessage?.contextInfo ||
88
- rawMsg.message?.stickerMessage?.contextInfo;
88
+ content?.extendedTextMessage?.contextInfo ||
89
+ content?.audioMessage?.contextInfo ||
90
+ content?.imageMessage?.contextInfo ||
91
+ content?.videoMessage?.contextInfo ||
92
+ content?.documentMessage?.contextInfo ||
93
+ content?.stickerMessage?.contextInfo;
89
94
  if (contextInfo?.quotedMessage) {
90
95
  if (await ctx.isOverQuota()) {
91
96
  logger.warn(
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type ModelCapabilities,
5
5
  parseModelCapabilitiesEnv,
6
6
  resolveModelChainCapabilities,
7
+ type WireModelCapabilities,
7
8
  } from "./agent/model-capabilities.js";
8
9
  import { mergeRawMercuryConfig } from "./config-file.js";
9
10
  import { parseModelLegsArray } from "./config-model-chain.js";
@@ -305,7 +306,7 @@ export type AppConfig = z.infer<typeof schema> & {
305
306
  /** Parsed MERCURY_MODEL_CAPABILITIES override, if valid. */
306
307
  parsedModelCapabilitiesEnv: ModelCapabilities | null;
307
308
  /** Capabilities per chain leg (same order as resolvedModelChain). */
308
- resolvedModelChainCapabilities: ModelCapabilities[];
309
+ resolvedModelChainCapabilities: WireModelCapabilities[];
309
310
  /** Effective budget after clamping to container timeout. */
310
311
  effectiveModelChainBudgetMs: number;
311
312
  /** Parsed `extensionDefaults` JSON: flat "ext.key" → value map. */
@@ -1,12 +1,48 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import {
4
5
  getOAuthApiKey,
6
+ getOAuthProvider,
5
7
  type OAuthCredentials,
6
8
  type OAuthProviderId,
7
9
  } from "@earendil-works/pi-ai/oauth";
8
10
  import { logger } from "../logger.js";
9
11
 
12
+ /**
13
+ * Env var pi reads a provider's credential from, for the OAuth providers
14
+ * `mercury auth login` can write to auth.json. Mirrors `getApiKeyEnvVars` in
15
+ * `@earendil-works/pi-ai/dist/env-api-keys.js` (see its `anthropic` and
16
+ * `github-copilot` cases) — that mapping is not exported, so it is restated
17
+ * here rather than reached into.
18
+ *
19
+ * `openai-codex` is deliberately absent: pi has no env var for it, so a codex
20
+ * credential can only travel in auth.json — which Mercury no longer mounts into
21
+ * the container (it carries the refresh token). A codex leg is unservable.
22
+ */
23
+ const PROVIDER_CREDENTIAL_ENV_VAR: Record<string, string> = {
24
+ anthropic: "ANTHROPIC_OAUTH_TOKEN",
25
+ "github-copilot": "COPILOT_GITHUB_TOKEN",
26
+ };
27
+
28
+ /**
29
+ * The env var that carries this provider's credential into the agent container,
30
+ * or undefined when pi has no env var for it (the credential cannot be passed).
31
+ */
32
+ export function providerCredentialEnvVar(provider: string): string | undefined {
33
+ return PROVIDER_CREDENTIAL_ENV_VAR[provider];
34
+ }
35
+
36
+ /**
37
+ * Whether auth.json holds an OAuth entry for this provider — asked before
38
+ * resolving one, so a credential that could never be passed on (no env var for
39
+ * it) is reported without spending a single-use refresh token to find out.
40
+ */
41
+ export function hasOAuthEntry(authPath: string, provider: string): boolean {
42
+ const entry = readAuthFile(path.resolve(authPath))[provider];
43
+ return Boolean(entry && typeof entry === "object" && entry.type === "oauth");
44
+ }
45
+
10
46
  type AuthEntry =
11
47
  | ({ type: "oauth" } & OAuthCredentials)
12
48
  | { type: "api_key"; key: string }
@@ -27,10 +63,55 @@ function readAuthFile(authPath: string): AuthFile {
27
63
  }
28
64
  }
29
65
 
66
+ /**
67
+ * Persist the auth file atomically: write a sibling temp file, then rename over
68
+ * the target. A torn or partially-written auth.json is unrecoverable — Anthropic
69
+ * rotates refresh tokens on every refresh, so the copy on disk is the only
70
+ * record of the current link in the chain.
71
+ *
72
+ * The rename is retried because on Windows a concurrent reader holding a handle
73
+ * makes `renameSync` fail with EPERM/EBUSY; the operation succeeds moments later.
74
+ */
30
75
  function writeAuthFile(authPath: string, auth: AuthFile): void {
31
76
  fs.mkdirSync(path.dirname(authPath), { recursive: true });
32
- fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf8");
33
- fs.chmodSync(authPath, 0o600);
77
+ const tmpPath = `${authPath}.${process.pid}.tmp`;
78
+ fs.writeFileSync(tmpPath, JSON.stringify(auth, null, 2), "utf8");
79
+ try {
80
+ fs.chmodSync(tmpPath, 0o600);
81
+ } catch {
82
+ // chmod is best-effort (no-op on some Windows setups); never fail the
83
+ // persist over file permissions when a credential is waiting to be saved.
84
+ }
85
+
86
+ let lastError: unknown;
87
+ for (let attempt = 0; attempt < 5; attempt++) {
88
+ try {
89
+ fs.renameSync(tmpPath, authPath);
90
+ return;
91
+ } catch (err) {
92
+ lastError = err;
93
+ // Busy-wait briefly: this path is rare (~3x/day) and must stay sync so
94
+ // callers cannot observe a half-persisted credential.
95
+ const until = Date.now() + 20;
96
+ while (Date.now() < until) {
97
+ /* spin */
98
+ }
99
+ }
100
+ }
101
+ try {
102
+ fs.rmSync(tmpPath, { force: true });
103
+ } catch {
104
+ // Leaving a stray temp file is preferable to masking the rename error.
105
+ }
106
+ throw lastError;
107
+ }
108
+
109
+ /**
110
+ * Short, non-reversible fingerprint of a secret, safe to log. Used to make token
111
+ * rotations identifiable after the fact without ever recording the token.
112
+ */
113
+ function fingerprint(secret: string): string {
114
+ return crypto.createHash("sha256").update(secret).digest("hex").slice(0, 12);
34
115
  }
35
116
 
36
117
  export type OAuthTokenEnvValue =
@@ -61,6 +142,15 @@ export function parseOAuthTokenEnv(raw: string): OAuthTokenEnvValue {
61
142
  return { status: "corrupt-blob" };
62
143
  }
63
144
 
145
+ /** Whether the environment names an Anthropic credential that can actually be used. */
146
+ function hasAnthropicEnvOverride(): boolean {
147
+ if (process.env.MERCURY_ANTHROPIC_API_KEY) return true;
148
+ const raw = process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN;
149
+ if (!raw) return false;
150
+ const parsed = parseOAuthTokenEnv(raw);
151
+ return parsed.status === "token" || parsed.status === "blob";
152
+ }
153
+
64
154
  export type PiAuthCredential =
65
155
  | { status: "ok"; apiKey: string }
66
156
  /** No usable oauth entry (or an env override takes precedence). */
@@ -76,24 +166,47 @@ export async function getPiAuthCredential(options: {
76
166
  provider: string;
77
167
  authPath: string;
78
168
  }): Promise<PiAuthCredential> {
79
- if (
80
- process.env.MERCURY_ANTHROPIC_API_KEY ||
81
- process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
82
- ) {
169
+ // Anthropic-only override: a *usable* MERCURY_ANTHROPIC_* value is the
170
+ // operator naming the credential to use, so the file is not consulted. No
171
+ // other provider has such a variable — theirs arrive as ordinary passthrough
172
+ // env, which the caller checks before asking us.
173
+ //
174
+ // A corrupt blob is not an override: it names nothing, and treating its mere
175
+ // presence as one would suppress the file fallback that is the only remaining
176
+ // way to authenticate.
177
+ if (options.provider === "anthropic" && hasAnthropicEnvOverride()) {
83
178
  return { status: "none" };
84
179
  }
85
180
 
86
- if (options.provider !== "anthropic") {
181
+ // Only providers pi can refresh an OAuth token for. Anything else in the file
182
+ // (a hand-written api_key entry, a provider from a newer pi) is not ours to
183
+ // interpret, and getOAuthApiKey would throw on an unknown id.
184
+ if (!getOAuthProvider(options.provider)) {
87
185
  return { status: "none" };
88
186
  }
89
187
 
90
188
  // Coalesce concurrent refreshes for the same auth file so only one
91
- // token-endpoint call is made; the rest share its result.
92
- const key = options.authPath;
189
+ // token-endpoint call is made; the rest share its result. Keyed per provider
190
+ // as well as per file: one auth.json holds an entry per provider, and a
191
+ // shared promise would hand a copilot caller the anthropic token.
192
+ //
193
+ // The key must be canonical, not the caller's spelling: callers reach this
194
+ // function with the same file written different ways (a relative
195
+ // `.mercury/global/auth.json` from an extension, an absolute path from the
196
+ // container runner). Relative paths already resolve against `process.cwd()`
197
+ // inside `fs`, so `path.resolve` names the exact same file the raw spelling
198
+ // would have opened — it only removes the caller's freedom to defeat the
199
+ // dedupe by spelling. The resolved path is also what gets read, written and
200
+ // logged, so one file never appears under two names in the logs. (The map is
201
+ // process-local; coalescing across processes would need a lock file.)
202
+ const authPath = path.resolve(options.authPath);
203
+ // "|" is not legal in a Windows path and never appears in a provider id,
204
+ // so the two halves can never run together into a colliding key.
205
+ const key = `${authPath}|${options.provider}`;
93
206
  const existing = inflightRefresh.get(key);
94
207
  if (existing) return existing;
95
208
 
96
- const promise = doGetPiAuthCredential(options);
209
+ const promise = doGetPiAuthCredential({ ...options, authPath });
97
210
  inflightRefresh.set(key, promise);
98
211
  try {
99
212
  return await promise;
@@ -107,9 +220,10 @@ async function doGetPiAuthCredential(options: {
107
220
  authPath: string;
108
221
  }): Promise<PiAuthCredential> {
109
222
  const authPath = options.authPath;
223
+ const provider = options.provider;
110
224
  const auth = readAuthFile(authPath);
111
225
 
112
- const entry = auth.anthropic;
226
+ const entry = auth[provider];
113
227
  if (!entry || typeof entry !== "object" || entry.type !== "oauth") {
114
228
  return { status: "none" };
115
229
  }
@@ -121,33 +235,23 @@ async function doGetPiAuthCredential(options: {
121
235
  return { status: "none" };
122
236
  }
123
237
 
238
+ // Step 1 — obtain a usable key. getOAuthApiKey only contacts the token
239
+ // endpoint when the access token has expired; otherwise it returns the
240
+ // existing credentials untouched. A throw here means the refresh itself was
241
+ // rejected, which is a genuine credential failure.
242
+ let result: Awaited<ReturnType<typeof getOAuthApiKey>>;
124
243
  try {
125
- const result = await getOAuthApiKey("anthropic" satisfies OAuthProviderId, {
126
- anthropic: {
244
+ result = await getOAuthApiKey(provider as OAuthProviderId, {
245
+ [provider]: {
246
+ ...entry,
127
247
  access,
128
248
  refresh,
129
249
  expires,
130
250
  },
131
251
  });
132
-
133
- if (!result) return { status: "refresh-failed" };
134
-
135
- const nextAuth = {
136
- ...auth,
137
- anthropic: {
138
- type: "oauth" as const,
139
- ...result.newCredentials,
140
- },
141
- };
142
-
143
- writeAuthFile(authPath, nextAuth);
144
- logger.debug("Loaded anthropic oauth token from pi auth.json", {
145
- authPath,
146
- });
147
- return { status: "ok", apiKey: result.apiKey };
148
252
  } catch (error) {
149
253
  logger.warn(
150
- `Failed to load anthropic oauth token from pi auth.json at ${authPath}`,
254
+ `Failed to load ${provider} oauth token from pi auth.json at ${authPath}`,
151
255
  error instanceof Error ? error : undefined,
152
256
  );
153
257
  return {
@@ -155,6 +259,66 @@ async function doGetPiAuthCredential(options: {
155
259
  error: error instanceof Error ? error : undefined,
156
260
  };
157
261
  }
262
+
263
+ if (!result) return { status: "refresh-failed" };
264
+
265
+ // Step 2 — persist, in its own scope. Providers rotate the refresh token on
266
+ // every refresh (the old one is consumed server-side), so when a rotation has
267
+ // happened the value in memory is the ONLY copy of the current chain link.
268
+ // A failure to save it is therefore unrecoverable — but it is emphatically not
269
+ // a refresh failure, and must not be reported as one: the key we hold is
270
+ // valid, and failing the call here would break the chain instead of preserving
271
+ // the one chance to use and re-save it.
272
+ const nextCredentials = result.newCredentials;
273
+ const rotated =
274
+ typeof nextCredentials.refresh === "string" &&
275
+ nextCredentials.refresh !== refresh;
276
+
277
+ try {
278
+ // Re-read immediately before merging: another provider's refresh may have
279
+ // rewritten the file since this call read it, and a stale snapshot would
280
+ // drop that provider's rotation on the floor.
281
+ writeAuthFile(authPath, {
282
+ ...readAuthFile(authPath),
283
+ [provider]: {
284
+ type: "oauth" as const,
285
+ ...nextCredentials,
286
+ },
287
+ });
288
+ if (rotated) {
289
+ // The single log line that makes a broken chain diagnosable after the
290
+ // fact: which link was replaced by which, without recording either token.
291
+ logger.info(`Rotated ${provider} oauth refresh token`, {
292
+ authPath,
293
+ previousRefresh: fingerprint(refresh),
294
+ newRefresh: fingerprint(nextCredentials.refresh as string),
295
+ });
296
+ }
297
+ } catch (error) {
298
+ if (rotated) {
299
+ logger.error(
300
+ `CRITICAL: ${provider} OAuth credential was rotated but could NOT be saved to ${authPath} — the previous refresh token is already consumed server-side, so this space will fail to authenticate once the current access token expires. Re-run mercury auth login from the project directory that owns this file.`,
301
+ {
302
+ authPath,
303
+ previousRefresh: fingerprint(refresh),
304
+ unsavedRefresh: fingerprint(nextCredentials.refresh as string),
305
+ error: error instanceof Error ? error.message : String(error),
306
+ },
307
+ );
308
+ } else {
309
+ // No rotation occurred, so the on-disk copy is still current and nothing
310
+ // was lost — the rewrite was a no-op carrying identical content.
311
+ logger.warn(
312
+ `Could not rewrite pi auth.json at ${authPath} (no rotation occurred, stored credential is unchanged)`,
313
+ error instanceof Error ? error : undefined,
314
+ );
315
+ }
316
+ }
317
+
318
+ logger.debug(`Loaded ${provider} oauth token from pi auth.json`, {
319
+ authPath,
320
+ });
321
+ return { status: "ok", apiKey: result.apiKey };
158
322
  }
159
323
 
160
324
  /** Back-compat wrapper: returns the key on success, undefined otherwise. */