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 +1 -1
- package/src/agent/container-error.ts +14 -1
- package/src/agent/container-runner.ts +37 -17
- package/src/agent/user-error-messages.ts +1 -1
- package/src/core/global-admin.ts +48 -29
- package/src/core/permissions.ts +17 -1
- package/src/core/runtime.ts +13 -0
- package/src/storage/pi-auth.ts +30 -9
package/package.json
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export type ContainerFailureReason =
|
|
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 {
|
|
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 =
|
|
798
|
-
(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
package/src/core/global-admin.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -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",
|
package/src/storage/pi-auth.ts
CHANGED
|
@@ -33,19 +33,26 @@ function writeAuthFile(authPath: string, auth: AuthFile): void {
|
|
|
33
33
|
fs.chmodSync(authPath, 0o600);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export
|
|
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<
|
|
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
|
|
51
|
+
return { status: "none" };
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
if (options.provider !== "anthropic") {
|
|
48
|
-
return
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
+
}
|