mercury-agent 0.8.3 → 0.8.5
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,7 @@ 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 { getPiAuthCredential } from "mercury-agent/storage/pi-auth";
|
|
15
15
|
|
|
16
16
|
const KNOWLEDGE_DIR = "knowledge";
|
|
17
17
|
const VAULT_DIRS = ["people", "projects", "references", "daily", "episodes", "weekly", "monthly", "templates"];
|
|
@@ -407,11 +407,15 @@ export default function (mercury: {
|
|
|
407
407
|
// LLM credential resolution for host-side pi spawns
|
|
408
408
|
// ---------------------------------------------------------------------------
|
|
409
409
|
|
|
410
|
+
type PiAuthEnvResult =
|
|
411
|
+
| { ok: true; env: Record<string, string> }
|
|
412
|
+
| { ok: false; reason: string };
|
|
413
|
+
|
|
410
414
|
async function resolvePiAuthEnv(config: {
|
|
411
415
|
authPath?: string;
|
|
412
416
|
globalDir: string;
|
|
413
417
|
modelProvider: string;
|
|
414
|
-
}): Promise<
|
|
418
|
+
}): Promise<PiAuthEnvResult> {
|
|
415
419
|
const env: Record<string, string> = {};
|
|
416
420
|
|
|
417
421
|
// 1. Explicit env vars (strip MERCURY_ prefix, matching container-runner)
|
|
@@ -421,18 +425,36 @@ export default function (mercury: {
|
|
|
421
425
|
if (process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN) {
|
|
422
426
|
env.ANTHROPIC_API_KEY = process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN;
|
|
423
427
|
}
|
|
428
|
+
if (env.ANTHROPIC_API_KEY) return { ok: true, env };
|
|
429
|
+
|
|
430
|
+
// Spawned pi inherits process.env, so an unprefixed host key also works.
|
|
431
|
+
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN) {
|
|
432
|
+
return { ok: true, env };
|
|
433
|
+
}
|
|
424
434
|
|
|
425
435
|
// 2. Fall back to Mercury's auth.json (OAuth token refresh)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
const authPath = config.authPath ?? join(config.globalDir, "auth.json");
|
|
437
|
+
const cred = await getPiAuthCredential({
|
|
438
|
+
provider: config.modelProvider,
|
|
439
|
+
authPath,
|
|
440
|
+
});
|
|
441
|
+
if (cred.status === "ok") {
|
|
442
|
+
env.ANTHROPIC_API_KEY = cred.apiKey;
|
|
443
|
+
return { ok: true, env };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Non-anthropic providers aren't resolvable here — let pi use its own auth.
|
|
447
|
+
if (config.modelProvider !== "anthropic") {
|
|
448
|
+
return { ok: true, env };
|
|
433
449
|
}
|
|
434
450
|
|
|
435
|
-
|
|
451
|
+
// Fail fast, mirroring the container-runner guard: without a credential
|
|
452
|
+
// every pi spawn exits 1, one per space per pending date, every run.
|
|
453
|
+
const reason =
|
|
454
|
+
cred.status === "refresh-failed"
|
|
455
|
+
? `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`
|
|
456
|
+
: `no Anthropic credential configured (checked ${authPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
|
|
457
|
+
return { ok: false, reason };
|
|
436
458
|
}
|
|
437
459
|
|
|
438
460
|
// ---------------------------------------------------------------------------
|
|
@@ -453,7 +475,18 @@ export default function (mercury: {
|
|
|
453
475
|
return;
|
|
454
476
|
}
|
|
455
477
|
|
|
456
|
-
const
|
|
478
|
+
const piAuth = await resolvePiAuthEnv(ctx.config);
|
|
479
|
+
if (!piAuth.ok) {
|
|
480
|
+
// One clear host-level error instead of a failed pi spawn per
|
|
481
|
+
// space per pending date; dates stay pending for the next run.
|
|
482
|
+
ctx.log.error("Skipping distillation: no Anthropic credential", {
|
|
483
|
+
reason: piAuth.reason,
|
|
484
|
+
});
|
|
485
|
+
mercury.store.set("last-distill", new Date().toISOString());
|
|
486
|
+
mercury.store.set("last-distill-status", "skipped: no credential");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const piAuthEnv = piAuth.env;
|
|
457
490
|
|
|
458
491
|
const db = new Database(dbPath, { readonly: true });
|
|
459
492
|
|
|
@@ -681,7 +714,14 @@ export default function (mercury: {
|
|
|
681
714
|
const dbPath = join(ctx.config.dataDir, "state.db");
|
|
682
715
|
if (!existsSync(dbPath)) return;
|
|
683
716
|
|
|
684
|
-
const
|
|
717
|
+
const piAuth = await resolvePiAuthEnv(ctx.config);
|
|
718
|
+
if (!piAuth.ok) {
|
|
719
|
+
ctx.log.error("Skipping consolidation: no Anthropic credential", {
|
|
720
|
+
reason: piAuth.reason,
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const piAuthEnv = piAuth.env;
|
|
685
725
|
|
|
686
726
|
const db = new Database(dbPath, { readonly: true });
|
|
687
727
|
|
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";
|
|
@@ -803,9 +803,11 @@ export class AgentContainerRunner {
|
|
|
803
803
|
input.extraEnv?.ANTHROPIC_OAUTH_TOKEN,
|
|
804
804
|
);
|
|
805
805
|
if (!hasAnthropicKey && this.config.modelProvider === "anthropic") {
|
|
806
|
+
const resolvedAuthPath =
|
|
807
|
+
this.config.authPath ?? path.join(globalDir, "auth.json");
|
|
806
808
|
const piAuth = await getPiAuthCredential({
|
|
807
809
|
provider: this.config.modelProvider,
|
|
808
|
-
authPath:
|
|
810
|
+
authPath: resolvedAuthPath,
|
|
809
811
|
});
|
|
810
812
|
if (piAuth.status === "ok") {
|
|
811
813
|
passthroughEnvPairs.push({
|
|
@@ -819,10 +821,11 @@ export class AgentContainerRunner {
|
|
|
819
821
|
const detail = oauthBlobCorrupt
|
|
820
822
|
? "MERCURY_ANTHROPIC_OAUTH_TOKEN holds a corrupt credential blob — re-provision it or set MERCURY_ANTHROPIC_API_KEY"
|
|
821
823
|
: piAuth.status === "refresh-failed"
|
|
822
|
-
?
|
|
823
|
-
:
|
|
824
|
+
? `Anthropic OAuth refresh failed for ${resolvedAuthPath} — re-authenticate on the host (run mercury auth login from the project directory that owns that file) or set MERCURY_ANTHROPIC_API_KEY`
|
|
825
|
+
: `no Anthropic credential configured (checked ${resolvedAuthPath}) — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN`;
|
|
824
826
|
logger.error(`Refusing to start container: ${detail}`, {
|
|
825
827
|
spaceId: input.spaceId,
|
|
828
|
+
authPath: resolvedAuthPath,
|
|
826
829
|
});
|
|
827
830
|
throw ContainerError.noCredentials(input.spaceId, detail);
|
|
828
831
|
}
|
package/src/cli/mercury.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { Command } from "commander";
|
|
20
20
|
import { loadConfig, resolveProjectPath } from "../config.js";
|
|
21
|
+
import { mergeRawMercuryConfig } from "../config-file.js";
|
|
21
22
|
import { getCatalogEntryByName } from "../extensions/catalog.js";
|
|
22
23
|
import {
|
|
23
24
|
checkExtensionIndexLoads,
|
|
@@ -100,6 +101,39 @@ function loadEnvFile(envPath: string): Record<string, string> {
|
|
|
100
101
|
return vars;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the auth.json path the way the running service does: `.env` values
|
|
106
|
+
* win over process env (mirroring runAction's Object.assign), mercury.yaml
|
|
107
|
+
* sits under both, and MERCURY_AUTH_PATH / runtime.auth_path override the
|
|
108
|
+
* default `<dataDir>/global/auth.json`. Falls back to the defaults if the
|
|
109
|
+
* project config is malformed — auth commands must stay usable to fix it.
|
|
110
|
+
*/
|
|
111
|
+
function resolveAuthJsonPath(): { authPath: string; dataDir: string } {
|
|
112
|
+
const envPath = join(CWD, ".env");
|
|
113
|
+
const envVars = existsSync(envPath) ? loadEnvFile(envPath) : {};
|
|
114
|
+
let authOverride: string | undefined;
|
|
115
|
+
let dataDir = ".mercury";
|
|
116
|
+
try {
|
|
117
|
+
const raw = mergeRawMercuryConfig({ ...process.env, ...envVars }, CWD);
|
|
118
|
+
if (typeof raw.authPath === "string" && raw.authPath) {
|
|
119
|
+
authOverride = raw.authPath;
|
|
120
|
+
}
|
|
121
|
+
if (typeof raw.dataDir === "string" && raw.dataDir) {
|
|
122
|
+
dataDir = raw.dataDir;
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[WARN] ${e instanceof Error ? e.message : String(e)} — using default auth path`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
authPath: resolveProjectPath(
|
|
131
|
+
authOverride ?? join(dataDir, "global", "auth.json"),
|
|
132
|
+
),
|
|
133
|
+
dataDir,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
function withProjectDb<T>(fn: (db: Db) => T): T {
|
|
104
138
|
const dbPath = join(CWD, getProjectDataDir(CWD), "state.db");
|
|
105
139
|
const db = new Db(dbPath);
|
|
@@ -474,8 +508,7 @@ function doctorAction(): void {
|
|
|
474
508
|
|
|
475
509
|
// 4. AI credentials
|
|
476
510
|
console.log("\nAI Credentials:");
|
|
477
|
-
const
|
|
478
|
-
const authPath = join(CWD, dataDir, "global", "auth.json");
|
|
511
|
+
const { authPath } = resolveAuthJsonPath();
|
|
479
512
|
const hasOAuth = existsSync(authPath);
|
|
480
513
|
const hasApiKey = !!(
|
|
481
514
|
process.env.MERCURY_ANTHROPIC_API_KEY ||
|
|
@@ -601,7 +634,7 @@ function doctorAction(): void {
|
|
|
601
634
|
|
|
602
635
|
// 8. Spaces exist
|
|
603
636
|
console.log("\nSpaces:");
|
|
604
|
-
const dbPath = join(CWD,
|
|
637
|
+
const dbPath = join(CWD, getProjectDataDir(CWD), "state.db");
|
|
605
638
|
if (existsSync(dbPath)) {
|
|
606
639
|
try {
|
|
607
640
|
const db = new Db(dbPath);
|
|
@@ -963,9 +996,31 @@ authCommand
|
|
|
963
996
|
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
|
964
997
|
console.log(`\nLogging in to ${provider.name}...`);
|
|
965
998
|
|
|
966
|
-
// Resolve auth.json path
|
|
967
|
-
|
|
968
|
-
const authPath
|
|
999
|
+
// Resolve auth.json path exactly like the running service does
|
|
1000
|
+
// (.env + mercury.yaml, MERCURY_AUTH_PATH / runtime.auth_path override).
|
|
1001
|
+
const { authPath, dataDir } = resolveAuthJsonPath();
|
|
1002
|
+
|
|
1003
|
+
// Credentials are CWD-scoped: a service running from another directory
|
|
1004
|
+
// reads its own <project>/<dataDir>/global/auth.json and will never see
|
|
1005
|
+
// tokens saved here. Warn before silently creating a stray .mercury tree.
|
|
1006
|
+
// A bare data dir doesn't count as a project marker — a prior misplaced
|
|
1007
|
+
// login creates exactly that; state.db only exists where Mercury has run.
|
|
1008
|
+
const looksLikeMercuryProject =
|
|
1009
|
+
existsSync(join(CWD, "mercury.yaml")) ||
|
|
1010
|
+
existsSync(join(CWD, ".env")) ||
|
|
1011
|
+
existsSync(join(CWD, dataDir, "state.db"));
|
|
1012
|
+
if (!looksLikeMercuryProject) {
|
|
1013
|
+
console.warn(
|
|
1014
|
+
`\n⚠ ${CWD} does not look like a Mercury project (no mercury.yaml, .env, or ${dataDir}/state.db).`,
|
|
1015
|
+
);
|
|
1016
|
+
console.warn(
|
|
1017
|
+
` Credentials will be saved to ${authPath} — a Mercury service running from a different directory will NOT read them.`,
|
|
1018
|
+
);
|
|
1019
|
+
console.warn(
|
|
1020
|
+
" If you meant to re-authenticate a running service, re-run this command from that project's directory.",
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
969
1024
|
const authDir = dirname(authPath);
|
|
970
1025
|
if (!existsSync(authDir)) {
|
|
971
1026
|
mkdirSync(authDir, { recursive: true });
|
|
@@ -1069,8 +1124,7 @@ authCommand
|
|
|
1069
1124
|
.command("logout [provider]")
|
|
1070
1125
|
.description("Remove saved OAuth credentials for a provider")
|
|
1071
1126
|
.action(async (providerArg?: string) => {
|
|
1072
|
-
const
|
|
1073
|
-
const authPath = join(CWD, dataDir, "global", "auth.json");
|
|
1127
|
+
const { authPath } = resolveAuthJsonPath();
|
|
1074
1128
|
|
|
1075
1129
|
if (!existsSync(authPath)) {
|
|
1076
1130
|
console.log("No credentials found.");
|
|
@@ -1113,8 +1167,7 @@ authCommand
|
|
|
1113
1167
|
.action(async () => {
|
|
1114
1168
|
const { getOAuthProviders } = await import("@earendil-works/pi-ai/oauth");
|
|
1115
1169
|
|
|
1116
|
-
const
|
|
1117
|
-
const authPath = join(CWD, dataDir, "global", "auth.json");
|
|
1170
|
+
const { authPath } = resolveAuthJsonPath();
|
|
1118
1171
|
|
|
1119
1172
|
let authData: Record<string, { type?: string; expires?: number }> = {};
|
|
1120
1173
|
if (existsSync(authPath)) {
|
package/src/storage/pi-auth.ts
CHANGED
|
@@ -96,7 +96,7 @@ export async function getPiAuthCredential(options: {
|
|
|
96
96
|
return { status: "ok", apiKey: result.apiKey };
|
|
97
97
|
} catch (error) {
|
|
98
98
|
logger.warn(
|
|
99
|
-
|
|
99
|
+
`Failed to load anthropic oauth token from pi auth.json at ${authPath}`,
|
|
100
100
|
error instanceof Error ? error : undefined,
|
|
101
101
|
);
|
|
102
102
|
return {
|