mercury-agent 0.8.10 → 0.8.11

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.11",
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,
@@ -723,6 +723,18 @@ function buildPrompt(payload: Payload): string {
723
723
  return parts.join("\n");
724
724
  }
725
725
 
726
+ /** Read-only bwrap binds for each resource entry the host mounted into pi's agent dir. */
727
+ function piAgentRoBindArgs(): string[] {
728
+ const agentDir = "/home/mercury/.pi/agent";
729
+ if (!existsSync(agentDir)) return [];
730
+ const args: string[] = [];
731
+ for (const entry of readdirSync(agentDir)) {
732
+ const p = `${agentDir}/${entry}`;
733
+ args.push("--ro-bind", p, p);
734
+ }
735
+ return args;
736
+ }
737
+
726
738
  /**
727
739
  * Build bwrap args for sandboxing the agent process.
728
740
  * Uses bubblewrap for defense-in-depth: Docker isolates from host, bwrap restricts within container.
@@ -762,9 +774,11 @@ function buildBwrapArgs(
762
774
  "--bind",
763
775
  "/home/mercury",
764
776
  "/home/mercury",
765
- "--ro-bind",
766
- "/home/mercury/.pi/agent",
767
- "/home/mercury/.pi/agent",
777
+ // The host mounts the global resource dir entry by entry (auth.json stays
778
+ // on the host), so `.pi/agent` is a plain dir holding N read-only submounts
779
+ // rather than a single one. Re-bind each entry explicitly instead of relying
780
+ // on a recursive bind of the parent to carry the read-only flag down.
781
+ ...piAgentRoBindArgs(),
768
782
  "--proc",
769
783
  "/proc",
770
784
  "--dev",
@@ -836,11 +850,14 @@ function invokePiOnce(
836
850
  "--mode",
837
851
  "json",
838
852
  // 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.
853
+ // /home/mercury/.pi/agent, whose resource entries are read-only bind
854
+ // mounts, and even a read locks it via proper-lockfile (mkdir
855
+ // trust.json.lock). Workspaces with trust-requiring resources already
856
+ // resolved to "untrusted" in non-interactive mode, and without such
857
+ // resources nothing trust-gated loads — so --no-approve preserves
858
+ // behavior either way (the agent dir itself is now a Docker-created
859
+ // mount parent owned by root, so writing the trust file there would fail
860
+ // with EACCES rather than EROFS — still a failure, still skipped).
844
861
  "--no-approve",
845
862
  ...sessionArgs,
846
863
  "--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
@@ -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(
@@ -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. */