mercury-agent 0.8.1 → 0.8.3-beta.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.8.1",
3
+ "version": "0.8.3-beta.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -1,4 +1,9 @@
1
- export type ContainerFailureReason = "timeout" | "oom" | "aborted" | "error";
1
+ export type ContainerFailureReason =
2
+ | "timeout"
3
+ | "oom"
4
+ | "aborted"
5
+ | "error"
6
+ | "no-credentials";
2
7
 
3
8
  export class ContainerError extends Error {
4
9
  readonly reason: ContainerFailureReason;
@@ -39,6 +44,14 @@ export class ContainerError extends Error {
39
44
  );
40
45
  }
41
46
 
47
+ static noCredentials(spaceId: string, detail: string): ContainerError {
48
+ return new ContainerError(
49
+ "no-credentials",
50
+ null,
51
+ `Refusing to start container for space ${spaceId}: ${detail}`,
52
+ );
53
+ }
54
+
42
55
  static error(exitCode: number, output: string): ContainerError {
43
56
  return new ContainerError(
44
57
  "error",
@@ -8,7 +8,7 @@ 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 { getApiKeyFromPiAuthFile } from "../storage/pi-auth.js";
11
+ import { getPiAuthCredential } from "../storage/pi-auth.js";
12
12
  import type {
13
13
  ContainerResult,
14
14
  MessageAttachment,
@@ -677,11 +677,6 @@ export class AgentContainerRunner {
677
677
  );
678
678
  }
679
679
 
680
- const authFromPi = await getApiKeyFromPiAuthFile({
681
- provider: this.config.modelProvider,
682
- authPath: this.config.authPath ?? path.join(globalDir, "auth.json"),
683
- });
684
-
685
680
  // Env vars that should never be passed to containers
686
681
  const BLOCKED_ENV_VARS = new Set([
687
682
  "MERCURY_API_SECRET",
@@ -762,6 +757,7 @@ export class AgentContainerRunner {
762
757
  p.key === "ANTHROPIC_OAUTH_TOKEN" &&
763
758
  p.value.trimStart().startsWith("{"),
764
759
  );
760
+ let oauthBlobCorrupt = false;
765
761
  if (anthOauthIdx !== -1) {
766
762
  const raw = passthroughEnvPairs[anthOauthIdx]?.value ?? "";
767
763
  passthroughEnvPairs.splice(anthOauthIdx, 1);
@@ -769,6 +765,7 @@ export class AgentContainerRunner {
769
765
  try {
770
766
  parsedCreds = JSON.parse(raw) as AnthropicOAuthCreds;
771
767
  } catch {
768
+ oauthBlobCorrupt = true;
772
769
  logger.warn("Anthropic OAuth blob corrupt; skipping token injection");
773
770
  }
774
771
  if (parsedCreds) {
@@ -794,18 +791,41 @@ export class AgentContainerRunner {
794
791
  }
795
792
 
796
793
  // Check for pi auth file fallback for Anthropic
797
- const hasAnthropicKey = passthroughEnvPairs.some(
798
- (p) => p.key === "ANTHROPIC_API_KEY" || p.key === "ANTHROPIC_OAUTH_TOKEN",
799
- );
800
- if (
801
- !hasAnthropicKey &&
802
- this.config.modelProvider === "anthropic" &&
803
- authFromPi
804
- ) {
805
- passthroughEnvPairs.push({
806
- key: "ANTHROPIC_OAUTH_TOKEN",
807
- value: authFromPi,
794
+ const hasAnthropicKey =
795
+ passthroughEnvPairs.some(
796
+ (p) =>
797
+ (p.key === "ANTHROPIC_API_KEY" ||
798
+ p.key === "ANTHROPIC_OAUTH_TOKEN") &&
799
+ p.value,
800
+ ) ||
801
+ Boolean(
802
+ input.extraEnv?.ANTHROPIC_API_KEY ||
803
+ input.extraEnv?.ANTHROPIC_OAUTH_TOKEN,
804
+ );
805
+ if (!hasAnthropicKey && this.config.modelProvider === "anthropic") {
806
+ const piAuth = await getPiAuthCredential({
807
+ provider: this.config.modelProvider,
808
+ authPath: this.config.authPath ?? path.join(globalDir, "auth.json"),
808
809
  });
810
+ if (piAuth.status === "ok") {
811
+ passthroughEnvPairs.push({
812
+ key: "ANTHROPIC_OAUTH_TOKEN",
813
+ value: piAuth.apiKey,
814
+ });
815
+ } else {
816
+ // Fail fast: without a credential, pi inside the container exits 1
817
+ // ("No API key found for anthropic") after a full container start,
818
+ // and the user gets a misleading "try again" message.
819
+ const detail = oauthBlobCorrupt
820
+ ? "MERCURY_ANTHROPIC_OAUTH_TOKEN holds a corrupt credential blob — re-provision it or set MERCURY_ANTHROPIC_API_KEY"
821
+ : piAuth.status === "refresh-failed"
822
+ ? "Anthropic OAuth refresh failed — re-authenticate on the host (mercury auth login) or set MERCURY_ANTHROPIC_API_KEY"
823
+ : "no Anthropic credential configured — run mercury auth login or set MERCURY_ANTHROPIC_API_KEY / MERCURY_ANTHROPIC_OAUTH_TOKEN";
824
+ logger.error(`Refusing to start container: ${detail}`, {
825
+ spaceId: input.spaceId,
826
+ });
827
+ throw ContainerError.noCredentials(input.spaceId, detail);
828
+ }
809
829
  }
810
830
 
811
831
  const envPairs = [
@@ -7,7 +7,7 @@ export type UserErrorCategory =
7
7
  | "generic";
8
8
 
9
9
  const AUTH_RE =
10
- /\b401\b|\b403\b|invalid\s+api\s+key|incorrect\s+api\s+key|authentication\s+failed|invalid\s+authentication|unauthorized|access\s+denied/i;
10
+ /\b401\b|\b403\b|invalid\s+api\s+key|incorrect\s+api\s+key|no\s+api\s+key\s+found|authentication\s+failed|invalid\s+authentication|unauthorized|access\s+denied/i;
11
11
 
12
12
  const KEY_LIMIT_RE = /quota|billing|usage\s+limit|spending\s+limit/i;
13
13
 
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Check if a caller is a global admin (configured in mercury.yaml / env).
3
- * Global admins are identified by `config.admins` and `config.dmAutoSpaceAdminIds`.
4
- * Platform-specific ID prefixes, + signs, and @domain suffixes are normalized.
2
+ * Matching of caller identities against configured user ids
3
+ * (`config.admins`, `config.dmAutoSpaceAdminIds`).
5
4
  *
6
- * WhatsApp callers arrive canonicalized to their phone JID, but older configs
7
- * may still list the LID digits (or vice versa when no mapping was known at
8
- * config time). When an alias lookup is provided, the caller's learned
9
- * LID↔phone counterpart is matched against the configured ids too.
5
+ * Configured ids come in loose formats (bare digits, `+` prefix, with or
6
+ * without platform prefix / domain suffix), while callers arrive as full
7
+ * canonical ids (e.g. `whatsapp:972542341444@s.whatsapp.net`). WhatsApp adds
8
+ * a second wrinkle: older configs may list the LID digits while callers are
9
+ * canonicalized to their phone JID (or vice versa when no mapping was known
10
+ * at config time). When an alias lookup is provided, the caller's learned
11
+ * LID↔phone counterpart from `wa_identity_aliases` is matched too.
10
12
  */
11
13
 
12
14
  /** Subset of Db used to resolve WhatsApp LID↔phone pairs. */
@@ -15,6 +17,44 @@ export interface WaAliasLookup {
15
17
  getWaLidForPn(pn: string): string | null;
16
18
  }
17
19
 
20
+ const normalize = (s: string) =>
21
+ s
22
+ .replace(/^[^:]+:/, "")
23
+ .replace(/^[+]+/, "")
24
+ .replace(/@.*$/, "");
25
+
26
+ /**
27
+ * Check whether a caller matches any id in a configured list, tolerating
28
+ * format differences (prefix, `+`, domain) and — with an alias lookup —
29
+ * the WhatsApp LID↔phone split.
30
+ */
31
+ export function matchesConfiguredId(
32
+ callerId: string,
33
+ configuredIds: string[],
34
+ aliases?: WaAliasLookup,
35
+ ): boolean {
36
+ if (configuredIds.length === 0) return false;
37
+
38
+ const callerCandidates = new Set([normalize(callerId)]);
39
+
40
+ if (aliases) {
41
+ const jid = callerId.replace(/^[^:]+:/, "");
42
+ if (jid.endsWith("@s.whatsapp.net")) {
43
+ const lid = aliases.getWaLidForPn(jid);
44
+ if (lid) callerCandidates.add(normalize(lid));
45
+ } else if (jid.endsWith("@lid")) {
46
+ const pn = aliases.getWaPnForLid(jid);
47
+ if (pn) callerCandidates.add(normalize(pn));
48
+ }
49
+ }
50
+
51
+ return configuredIds.some((id) => callerCandidates.has(normalize(id)));
52
+ }
53
+
54
+ /**
55
+ * Check if a caller is a global admin (configured in mercury.yaml / env).
56
+ * Global admins are identified by `config.admins` and `config.dmAutoSpaceAdminIds`.
57
+ */
18
58
  export function isGlobalAdmin(
19
59
  callerId: string,
20
60
  config: { admins?: string; dmAutoSpaceAdminIds?: string },
@@ -35,26 +75,5 @@ export function isGlobalAdmin(
35
75
  : []),
36
76
  ];
37
77
 
38
- const normalize = (s: string) =>
39
- s
40
- .replace(/^[^:]+:/, "")
41
- .replace(/^[+]+/, "")
42
- .replace(/@.*$/, "");
43
-
44
- const callerCandidates = new Set([normalize(callerId)]);
45
-
46
- if (aliases) {
47
- const jid = callerId.replace(/^[^:]+:/, "");
48
- if (jid.endsWith("@s.whatsapp.net")) {
49
- const lid = aliases.getWaLidForPn(jid);
50
- if (lid) callerCandidates.add(normalize(lid));
51
- } else if (jid.endsWith("@lid")) {
52
- const pn = aliases.getWaPnForLid(jid);
53
- if (pn) callerCandidates.add(normalize(pn));
54
- }
55
- }
56
-
57
- return globalAdmins.some(
58
- (id) => id === callerId || callerCandidates.has(normalize(id)),
59
- );
78
+ return matchesConfiguredId(callerId, globalAdmins, aliases);
60
79
  }
@@ -1,4 +1,5 @@
1
1
  import type { Db } from "../storage/db.js";
2
+ import { matchesConfiguredId } from "./global-admin.js";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Built-in permissions (static, cannot be overridden)
@@ -231,5 +232,20 @@ export function resolveRole(
231
232
 
232
233
  db.upsertMember(spaceId, platformUserId, displayName);
233
234
 
234
- return db.getRole(spaceId, platformUserId) ?? "member";
235
+ const role = db.getRole(spaceId, platformUserId) ?? "member";
236
+
237
+ // Seeded rows are keyed by the raw config string, which may differ from the
238
+ // canonical caller id (format looseness, WhatsApp LID vs phone JID). When the
239
+ // exact lookup misses but the caller matches a configured admin id, promote
240
+ // the canonical id so the row self-heals. Config admins are always admins —
241
+ // seedAdmins already re-promotes demoted ones on every re-seed.
242
+ if (
243
+ role === "member" &&
244
+ matchesConfiguredId(platformUserId, seededAdmins, db)
245
+ ) {
246
+ db.setRole(spaceId, platformUserId, "admin", "seed");
247
+ return "admin";
248
+ }
249
+
250
+ return role;
235
251
  }
@@ -641,6 +641,19 @@ export class MercuryCoreRuntime {
641
641
  type: "denied",
642
642
  reason: "Container was killed (possibly out of memory).",
643
643
  };
644
+ case "no-credentials": {
645
+ // Host refused to start the container (no model credential).
646
+ // Auth-category message: "try again" would be misleading here.
647
+ logger.error("Container start refused: no model credentials", {
648
+ detail: error.message,
649
+ });
650
+ const reason = friendlyErrorMessage(
651
+ "auth",
652
+ this.config.apiKeyMode,
653
+ this.config.consoleUrl,
654
+ );
655
+ return { type: "denied", reason };
656
+ }
644
657
  case "error": {
645
658
  logger.error(
646
659
  "Container error",
@@ -33,19 +33,26 @@ function writeAuthFile(authPath: string, auth: AuthFile): void {
33
33
  fs.chmodSync(authPath, 0o600);
34
34
  }
35
35
 
36
- export async function getApiKeyFromPiAuthFile(options: {
36
+ export type PiAuthCredential =
37
+ | { status: "ok"; apiKey: string }
38
+ /** No usable oauth entry (or an env override takes precedence). */
39
+ | { status: "none" }
40
+ /** An oauth entry exists but could not be turned into a usable key. */
41
+ | { status: "refresh-failed"; error?: Error };
42
+
43
+ export async function getPiAuthCredential(options: {
37
44
  provider: string;
38
45
  authPath: string;
39
- }): Promise<string | undefined> {
46
+ }): Promise<PiAuthCredential> {
40
47
  if (
41
48
  process.env.MERCURY_ANTHROPIC_API_KEY ||
42
49
  process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
43
50
  ) {
44
- return undefined;
51
+ return { status: "none" };
45
52
  }
46
53
 
47
54
  if (options.provider !== "anthropic") {
48
- return undefined;
55
+ return { status: "none" };
49
56
  }
50
57
 
51
58
  const authPath = options.authPath;
@@ -53,13 +60,15 @@ export async function getApiKeyFromPiAuthFile(options: {
53
60
 
54
61
  const entry = auth.anthropic;
55
62
  if (!entry || typeof entry !== "object" || entry.type !== "oauth") {
56
- return undefined;
63
+ return { status: "none" };
57
64
  }
58
65
 
59
66
  const access = typeof entry.access === "string" ? entry.access : undefined;
60
67
  const refresh = typeof entry.refresh === "string" ? entry.refresh : undefined;
61
68
  const expires = typeof entry.expires === "number" ? entry.expires : undefined;
62
- if (!access || !refresh || typeof expires !== "number") return undefined;
69
+ if (!access || !refresh || typeof expires !== "number") {
70
+ return { status: "none" };
71
+ }
63
72
 
64
73
  try {
65
74
  const result = await getOAuthApiKey("anthropic" satisfies OAuthProviderId, {
@@ -70,7 +79,7 @@ export async function getApiKeyFromPiAuthFile(options: {
70
79
  },
71
80
  });
72
81
 
73
- if (!result) return undefined;
82
+ if (!result) return { status: "refresh-failed" };
74
83
 
75
84
  const nextAuth = {
76
85
  ...auth,
@@ -84,12 +93,24 @@ export async function getApiKeyFromPiAuthFile(options: {
84
93
  logger.debug("Loaded anthropic oauth token from pi auth.json", {
85
94
  authPath,
86
95
  });
87
- return result.apiKey;
96
+ return { status: "ok", apiKey: result.apiKey };
88
97
  } catch (error) {
89
98
  logger.warn(
90
99
  "Failed to load anthropic oauth token from pi auth.json",
91
100
  error instanceof Error ? error : undefined,
92
101
  );
93
- return undefined;
102
+ return {
103
+ status: "refresh-failed",
104
+ error: error instanceof Error ? error : undefined,
105
+ };
94
106
  }
95
107
  }
108
+
109
+ /** Back-compat wrapper: returns the key on success, undefined otherwise. */
110
+ export async function getApiKeyFromPiAuthFile(options: {
111
+ provider: string;
112
+ authPath: string;
113
+ }): Promise<string | undefined> {
114
+ const result = await getPiAuthCredential(options);
115
+ return result.status === "ok" ? result.apiKey : undefined;
116
+ }