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.
- package/docs/media/whatsapp.md +8 -2
- package/examples/extensions/napkin/index.ts +8 -2
- package/package.json +1 -1
- package/src/adapters/whatsapp-media.ts +24 -5
- package/src/adapters/whatsapp.ts +18 -3
- package/src/agent/container-entry.ts +25 -8
- package/src/agent/container-runner.ts +135 -5
- package/src/bridges/whatsapp.ts +12 -7
- package/src/storage/pi-auth.ts +194 -30
package/docs/media/whatsapp.md
CHANGED
|
@@ -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
|
-
|
|
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. **
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
243
|
-
*
|
|
254
|
+
* Download media from a quoted (replied-to) WhatsApp message.
|
|
255
|
+
* Downloads voice/audio/document/image — video 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 (
|
|
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
|
|
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) {
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
840
|
-
// proper-lockfile (mkdir
|
|
841
|
-
// trust-requiring resources already
|
|
842
|
-
// non-interactive mode, and without such
|
|
843
|
-
// loads — so --no-approve preserves
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
package/src/bridges/whatsapp.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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/storage/pi-auth.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
fs.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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. */
|