mercury-agent 0.8.4 → 0.8.6
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.
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import { delimiter, dirname, join } from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getPiAuthCredential,
|
|
16
|
+
parseOAuthTokenEnv,
|
|
17
|
+
} from "mercury-agent/storage/pi-auth";
|
|
15
18
|
|
|
16
19
|
const KNOWLEDGE_DIR = "knowledge";
|
|
17
20
|
const VAULT_DIRS = ["people", "projects", "references", "daily", "episodes", "weekly", "monthly", "templates"];
|
|
@@ -407,32 +410,70 @@ export default function (mercury: {
|
|
|
407
410
|
// LLM credential resolution for host-side pi spawns
|
|
408
411
|
// ---------------------------------------------------------------------------
|
|
409
412
|
|
|
413
|
+
type PiAuthEnvResult =
|
|
414
|
+
| { ok: true; env: Record<string, string> }
|
|
415
|
+
| { ok: false; reason: string };
|
|
416
|
+
|
|
410
417
|
async function resolvePiAuthEnv(config: {
|
|
411
418
|
authPath?: string;
|
|
412
419
|
globalDir: string;
|
|
413
420
|
modelProvider: string;
|
|
414
|
-
}): Promise<
|
|
421
|
+
}): Promise<PiAuthEnvResult> {
|
|
415
422
|
const env: Record<string, string> = {};
|
|
416
423
|
|
|
417
424
|
// 1. Explicit env vars (strip MERCURY_ prefix, matching container-runner)
|
|
418
425
|
if (process.env.MERCURY_ANTHROPIC_API_KEY) {
|
|
419
426
|
env.ANTHROPIC_API_KEY = process.env.MERCURY_ANTHROPIC_API_KEY;
|
|
420
427
|
}
|
|
428
|
+
// Console-provisioned agents carry a JSON credential blob here, not a bare
|
|
429
|
+
// token — same handling as container-runner, via the shared parser.
|
|
430
|
+
let oauthBlobCorrupt = false;
|
|
421
431
|
if (process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN) {
|
|
422
|
-
|
|
432
|
+
const parsed = parseOAuthTokenEnv(
|
|
433
|
+
process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN,
|
|
434
|
+
);
|
|
435
|
+
if (parsed.status === "token") {
|
|
436
|
+
env.ANTHROPIC_API_KEY = parsed.token;
|
|
437
|
+
} else if (parsed.status === "blob") {
|
|
438
|
+
env.ANTHROPIC_OAUTH_TOKEN = parsed.access;
|
|
439
|
+
} else if (parsed.status === "corrupt-blob") {
|
|
440
|
+
oauthBlobCorrupt = true;
|
|
441
|
+
}
|
|
442
|
+
// "empty" — whitespace-only value, treat as unset and fall through
|
|
443
|
+
}
|
|
444
|
+
if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_OAUTH_TOKEN) {
|
|
445
|
+
return { ok: true, env };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Spawned pi inherits process.env, so an unprefixed host key also works.
|
|
449
|
+
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN) {
|
|
450
|
+
return { ok: true, env };
|
|
423
451
|
}
|
|
424
452
|
|
|
425
453
|
// 2. Fall back to Mercury's auth.json (OAuth token refresh)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
454
|
+
const authPath = config.authPath ?? join(config.globalDir, "auth.json");
|
|
455
|
+
const cred = await getPiAuthCredential({
|
|
456
|
+
provider: config.modelProvider,
|
|
457
|
+
authPath,
|
|
458
|
+
});
|
|
459
|
+
if (cred.status === "ok") {
|
|
460
|
+
env.ANTHROPIC_API_KEY = cred.apiKey;
|
|
461
|
+
return { ok: true, env };
|
|
433
462
|
}
|
|
434
463
|
|
|
435
|
-
|
|
464
|
+
// Non-anthropic providers aren't resolvable here — let pi use its own auth.
|
|
465
|
+
if (config.modelProvider !== "anthropic") {
|
|
466
|
+
return { ok: true, env };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Fail fast, mirroring the container-runner guard: without a credential
|
|
470
|
+
// every pi spawn exits 1, one per space per pending date, every run.
|
|
471
|
+
const reason = oauthBlobCorrupt
|
|
472
|
+
? "MERCURY_ANTHROPIC_OAUTH_TOKEN holds a corrupt credential blob — re-provision it or set MERCURY_ANTHROPIC_API_KEY"
|
|
473
|
+
: cred.status === "refresh-failed"
|
|
474
|
+
? `Anthropic OAuth refresh failed for ${authPath} — re-authenticate on the host (run mercury auth login from the project directory that owns that file) or set MERCURY_ANTHROPIC_API_KEY`
|
|
475
|
+
: `no Anthropic credential configured (checked ${authPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
|
|
476
|
+
return { ok: false, reason };
|
|
436
477
|
}
|
|
437
478
|
|
|
438
479
|
// ---------------------------------------------------------------------------
|
|
@@ -453,7 +494,18 @@ export default function (mercury: {
|
|
|
453
494
|
return;
|
|
454
495
|
}
|
|
455
496
|
|
|
456
|
-
const
|
|
497
|
+
const piAuth = await resolvePiAuthEnv(ctx.config);
|
|
498
|
+
if (!piAuth.ok) {
|
|
499
|
+
// One clear host-level error instead of a failed pi spawn per
|
|
500
|
+
// space per pending date; dates stay pending for the next run.
|
|
501
|
+
ctx.log.error("Skipping distillation: no Anthropic credential", {
|
|
502
|
+
reason: piAuth.reason,
|
|
503
|
+
});
|
|
504
|
+
mercury.store.set("last-distill", new Date().toISOString());
|
|
505
|
+
mercury.store.set("last-distill-status", "skipped: no credential");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const piAuthEnv = piAuth.env;
|
|
457
509
|
|
|
458
510
|
const db = new Database(dbPath, { readonly: true });
|
|
459
511
|
|
|
@@ -681,7 +733,14 @@ export default function (mercury: {
|
|
|
681
733
|
const dbPath = join(ctx.config.dataDir, "state.db");
|
|
682
734
|
if (!existsSync(dbPath)) return;
|
|
683
735
|
|
|
684
|
-
const
|
|
736
|
+
const piAuth = await resolvePiAuthEnv(ctx.config);
|
|
737
|
+
if (!piAuth.ok) {
|
|
738
|
+
ctx.log.error("Skipping consolidation: no Anthropic credential", {
|
|
739
|
+
reason: piAuth.reason,
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const piAuthEnv = piAuth.env;
|
|
685
744
|
|
|
686
745
|
const db = new Database(dbPath, { readonly: true });
|
|
687
746
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime console hygiene for the libsignal dependency (transitive via Baileys).
|
|
3
|
+
*
|
|
4
|
+
* libsignal writes directly to the global console — bypassing the silenced
|
|
5
|
+
* Baileys logger — and several of those calls dump entire SessionEntry objects,
|
|
6
|
+
* including ratchet private keys and root keys, into the service log
|
|
7
|
+
* (session_record.js: "Opening session:", "Closing session:", ...).
|
|
8
|
+
*
|
|
9
|
+
* Mercury is a published package, so patching node_modules (bun patch /
|
|
10
|
+
* patch-package) would not reach installed copies. Instead we wrap the global
|
|
11
|
+
* console methods libsignal uses with two deterministic layers:
|
|
12
|
+
*
|
|
13
|
+
* 1. Prefix suppression — calls whose first argument is a known libsignal
|
|
14
|
+
* session message are dropped entirely.
|
|
15
|
+
* 2. Shape redaction — any argument that structurally looks like a Signal
|
|
16
|
+
* SessionEntry/SessionRecord is replaced with a placeholder, as a backstop
|
|
17
|
+
* for call sites we don't know about.
|
|
18
|
+
*
|
|
19
|
+
* All other console traffic passes through untouched.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Known libsignal messages (session_record.js / session_builder.js). */
|
|
23
|
+
const SUPPRESSED_PREFIXES = [
|
|
24
|
+
"Opening session:",
|
|
25
|
+
"Closing session:",
|
|
26
|
+
"Session already open",
|
|
27
|
+
"Session already closed",
|
|
28
|
+
"Removing old closed session:",
|
|
29
|
+
"Migrating session to:",
|
|
30
|
+
"Closing open session in favor of incoming prekey bundle",
|
|
31
|
+
"Closing stale open session for new outgoing prekey bundle",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const REDACTED = "[libsignal session redacted]";
|
|
35
|
+
|
|
36
|
+
function isSessionLike(value: unknown): boolean {
|
|
37
|
+
if (typeof value !== "object" || value === null) return false;
|
|
38
|
+
const obj = value as Record<string, unknown>;
|
|
39
|
+
// SessionEntry: currentRatchet + indexInfo (+ _chains); SessionRecord: sessions map + version.
|
|
40
|
+
if ("currentRatchet" in obj && "indexInfo" in obj) return true;
|
|
41
|
+
if ("_chains" in obj && "registrationId" in obj) return true;
|
|
42
|
+
if ("sessions" in obj && "version" in obj && "haveOpenSession" in obj)
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function filterConsoleArgs(args: unknown[]): unknown[] | null {
|
|
48
|
+
const first = args[0];
|
|
49
|
+
if (
|
|
50
|
+
typeof first === "string" &&
|
|
51
|
+
SUPPRESSED_PREFIXES.some((p) => first.startsWith(p))
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
let changed = false;
|
|
56
|
+
const out = args.map((arg) => {
|
|
57
|
+
if (isSessionLike(arg)) {
|
|
58
|
+
changed = true;
|
|
59
|
+
return REDACTED;
|
|
60
|
+
}
|
|
61
|
+
return arg;
|
|
62
|
+
});
|
|
63
|
+
return changed ? out : args;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ConsoleMethod = (...args: unknown[]) => void;
|
|
67
|
+
|
|
68
|
+
// Tracks wrapper functions so install is idempotent per method: a method whose
|
|
69
|
+
// current function is already one of our wrappers is left alone, while a
|
|
70
|
+
// method that was replaced (e.g. restored in a test) gets re-wrapped.
|
|
71
|
+
const wrappers = new WeakSet<ConsoleMethod>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrap the global console methods libsignal writes to. Idempotent; call
|
|
75
|
+
* before creating the WhatsApp socket.
|
|
76
|
+
*/
|
|
77
|
+
export function installLibsignalConsoleFilter(): void {
|
|
78
|
+
for (const method of ["debug", "log", "info", "warn", "error"] as const) {
|
|
79
|
+
const current: ConsoleMethod = console[method];
|
|
80
|
+
if (wrappers.has(current)) continue;
|
|
81
|
+
const original = current.bind(console);
|
|
82
|
+
const wrapper: ConsoleMethod = (...args: unknown[]) => {
|
|
83
|
+
const filtered = filterConsoleArgs(args);
|
|
84
|
+
if (filtered === null) return;
|
|
85
|
+
original(...filtered);
|
|
86
|
+
};
|
|
87
|
+
wrappers.add(wrapper);
|
|
88
|
+
console[method] = wrapper;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { logger } from "../logger.js";
|
|
31
31
|
import { normalizeChatMarkdown } from "../text/markdown.js";
|
|
32
32
|
import { applyRtlDirection } from "../text/rtl.js";
|
|
33
|
+
import { installLibsignalConsoleFilter } from "./whatsapp-console-filter.js";
|
|
33
34
|
import {
|
|
34
35
|
canonicalizeJidSync,
|
|
35
36
|
resolveKeyIdentities,
|
|
@@ -220,6 +221,10 @@ export class WhatsAppBaileysAdapter
|
|
|
220
221
|
}
|
|
221
222
|
|
|
222
223
|
private async connect(): Promise<void> {
|
|
224
|
+
// libsignal (transitive via Baileys) writes session dumps — including
|
|
225
|
+
// private key material — straight to the global console, bypassing the
|
|
226
|
+
// silenced waLogger below. Filter before the socket exists.
|
|
227
|
+
installLibsignalConsoleFilter();
|
|
223
228
|
fs.mkdirSync(this.authDir, { recursive: true });
|
|
224
229
|
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
|
225
230
|
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
|
@@ -537,8 +542,8 @@ export class WhatsAppBaileysAdapter
|
|
|
537
542
|
const sender = identities.sender.canonical;
|
|
538
543
|
if (identities.sender.changed || identities.chat.changed) {
|
|
539
544
|
logger.debug("WhatsApp identity canonicalized", {
|
|
540
|
-
sender: identities.sender
|
|
541
|
-
chat: identities.chat
|
|
545
|
+
sender: `${identities.sender.original} -> ${identities.sender.canonical}`,
|
|
546
|
+
chat: `${identities.chat.original} -> ${identities.chat.canonical}`,
|
|
542
547
|
});
|
|
543
548
|
}
|
|
544
549
|
const senderName = msg.pushName || sender.split("@")[0] || "unknown";
|
package/src/storage/pi-auth.ts
CHANGED
|
@@ -33,6 +33,34 @@ function writeAuthFile(authPath: string, auth: AuthFile): void {
|
|
|
33
33
|
fs.chmodSync(authPath, 0o600);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export type OAuthTokenEnvValue =
|
|
37
|
+
| { status: "token"; token: string }
|
|
38
|
+
| { status: "blob"; access: string }
|
|
39
|
+
| { status: "corrupt-blob" }
|
|
40
|
+
/** Whitespace-only value — treat the variable as unset. */
|
|
41
|
+
| { status: "empty" };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Interpret the raw value of MERCURY_ANTHROPIC_OAUTH_TOKEN. Console-provisioned
|
|
45
|
+
* agents receive a full credential blob ({"access":"...","refresh":"...",
|
|
46
|
+
* "expires":...}) rather than a bare token, so any code spawning pi on the host
|
|
47
|
+
* must extract the access token instead of passing the value through verbatim.
|
|
48
|
+
*/
|
|
49
|
+
export function parseOAuthTokenEnv(raw: string): OAuthTokenEnvValue {
|
|
50
|
+
const trimmed = raw.trim();
|
|
51
|
+
if (!trimmed) return { status: "empty" };
|
|
52
|
+
if (!trimmed.startsWith("{")) return { status: "token", token: trimmed };
|
|
53
|
+
try {
|
|
54
|
+
const blob = JSON.parse(trimmed) as { access?: unknown };
|
|
55
|
+
if (typeof blob.access === "string" && blob.access) {
|
|
56
|
+
return { status: "blob", access: blob.access };
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// fall through to corrupt-blob
|
|
60
|
+
}
|
|
61
|
+
return { status: "corrupt-blob" };
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
export type PiAuthCredential =
|
|
37
65
|
| { status: "ok"; apiKey: string }
|
|
38
66
|
/** No usable oauth entry (or an env override takes precedence). */
|