@mono-agent/agent-app 0.6.2 → 0.9.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/README.md +259 -19
- package/dist/account-home.d.ts +6 -0
- package/dist/account-home.d.ts.map +1 -0
- package/dist/account-home.js +9 -0
- package/dist/account-home.js.map +1 -0
- package/dist/adapter-send-proxy.d.ts +18 -0
- package/dist/adapter-send-proxy.d.ts.map +1 -0
- package/dist/adapter-send-proxy.js +229 -0
- package/dist/adapter-send-proxy.js.map +1 -0
- package/dist/adapter-send-tools-main.js +62 -4
- package/dist/adapter-send-tools-main.js.map +1 -1
- package/dist/adapter-send-tools.d.ts +33 -6
- package/dist/adapter-send-tools.d.ts.map +1 -1
- package/dist/adapter-send-tools.js +485 -87
- package/dist/adapter-send-tools.js.map +1 -1
- package/dist/app-config.d.ts +3 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +16 -5
- package/dist/app-config.js.map +1 -1
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +761 -147
- package/dist/app.js.map +1 -1
- package/dist/background-environment.d.ts +16 -0
- package/dist/background-environment.d.ts.map +1 -0
- package/dist/background-environment.js +68 -0
- package/dist/background-environment.js.map +1 -0
- package/dist/background-runtime.d.ts +62 -0
- package/dist/background-runtime.d.ts.map +1 -0
- package/dist/background-runtime.js +1115 -0
- package/dist/background-runtime.js.map +1 -0
- package/dist/background-snapshot-key.d.ts +13 -0
- package/dist/background-snapshot-key.d.ts.map +1 -0
- package/dist/background-snapshot-key.js +194 -0
- package/dist/background-snapshot-key.js.map +1 -0
- package/dist/background-snapshot.d.ts +96 -0
- package/dist/background-snapshot.d.ts.map +1 -0
- package/dist/background-snapshot.js +450 -0
- package/dist/background-snapshot.js.map +1 -0
- package/dist/background-worker-lease.d.ts +39 -0
- package/dist/background-worker-lease.d.ts.map +1 -0
- package/dist/background-worker-lease.js +356 -0
- package/dist/background-worker-lease.js.map +1 -0
- package/dist/background.d.ts +74 -2
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +815 -73
- package/dist/background.js.map +1 -1
- package/dist/broadcast-recorder.d.ts.map +1 -1
- package/dist/broadcast-recorder.js +24 -2
- package/dist/broadcast-recorder.js.map +1 -1
- package/dist/channel-plugins.d.ts +2 -0
- package/dist/channel-plugins.d.ts.map +1 -1
- package/dist/channel-plugins.js +8 -0
- package/dist/channel-plugins.js.map +1 -1
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +87 -9
- package/dist/channels.js.map +1 -1
- package/dist/cli.d.ts +86 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2421 -143
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +448 -6
- package/dist/config-reference.js.map +1 -1
- package/dist/configuration-proposal-main.d.ts +3 -0
- package/dist/configuration-proposal-main.d.ts.map +1 -0
- package/dist/configuration-proposal-main.js +13 -0
- package/dist/configuration-proposal-main.js.map +1 -0
- package/dist/configuration-proposal-tool.d.ts +33 -0
- package/dist/configuration-proposal-tool.d.ts.map +1 -0
- package/dist/configuration-proposal-tool.js +188 -0
- package/dist/configuration-proposal-tool.js.map +1 -0
- package/dist/configured-agent.d.ts +16 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +165 -18
- package/dist/configured-agent.js.map +1 -1
- package/dist/consumer-contract.d.ts +3 -0
- package/dist/consumer-contract.d.ts.map +1 -1
- package/dist/consumer-contract.js +44 -1
- package/dist/consumer-contract.js.map +1 -1
- package/dist/continuation-command.d.ts +15 -0
- package/dist/continuation-command.d.ts.map +1 -0
- package/dist/continuation-command.js +170 -0
- package/dist/continuation-command.js.map +1 -0
- package/dist/continuation-config.d.ts +25 -0
- package/dist/continuation-config.d.ts.map +1 -0
- package/dist/continuation-config.js +236 -0
- package/dist/continuation-config.js.map +1 -0
- package/dist/continuation-service.d.ts +99 -0
- package/dist/continuation-service.d.ts.map +1 -0
- package/dist/continuation-service.js +1313 -0
- package/dist/continuation-service.js.map +1 -0
- package/dist/continuation-store.d.ts +104 -0
- package/dist/continuation-store.d.ts.map +1 -0
- package/dist/continuation-store.js +688 -0
- package/dist/continuation-store.js.map +1 -0
- package/dist/continuations.d.ts +167 -0
- package/dist/continuations.d.ts.map +1 -0
- package/dist/continuations.js +71 -0
- package/dist/continuations.js.map +1 -0
- package/dist/doctor.d.ts +29 -5
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +1611 -116
- package/dist/doctor.js.map +1 -1
- package/dist/first-run-managed-memory.d.ts +39 -0
- package/dist/first-run-managed-memory.d.ts.map +1 -0
- package/dist/first-run-managed-memory.js +584 -0
- package/dist/first-run-managed-memory.js.map +1 -0
- package/dist/first-run-readiness.d.ts +111 -0
- package/dist/first-run-readiness.d.ts.map +1 -0
- package/dist/first-run-readiness.js +728 -0
- package/dist/first-run-readiness.js.map +1 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +95 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1218 -24
- package/dist/init.js.map +1 -1
- package/dist/interaction-bridge.d.ts +25 -1
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +281 -19
- package/dist/interaction-bridge.js.map +1 -1
- package/dist/launchd.d.ts +16 -6
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +30 -15
- package/dist/launchd.js.map +1 -1
- package/dist/local-configuration.d.ts +99 -0
- package/dist/local-configuration.d.ts.map +1 -0
- package/dist/local-configuration.js +1299 -0
- package/dist/local-configuration.js.map +1 -0
- package/dist/managed-runtime-packages.d.ts +9 -0
- package/dist/managed-runtime-packages.d.ts.map +1 -0
- package/dist/managed-runtime-packages.js +56 -0
- package/dist/managed-runtime-packages.js.map +1 -0
- package/dist/memory-command.d.ts +4 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +751 -27
- package/dist/memory-command.js.map +1 -1
- package/dist/memory-embedding-service.d.ts +33 -0
- package/dist/memory-embedding-service.d.ts.map +1 -0
- package/dist/memory-embedding-service.js +207 -0
- package/dist/memory-embedding-service.js.map +1 -0
- package/dist/memory-recall.d.ts +43 -14
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +103 -33
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-retrieval.d.ts +66 -0
- package/dist/memory-retrieval.d.ts.map +1 -0
- package/dist/memory-retrieval.js +332 -0
- package/dist/memory-retrieval.js.map +1 -0
- package/dist/modules/base.d.ts +6 -5
- package/dist/modules/base.d.ts.map +1 -1
- package/dist/modules/base.js +11 -7
- package/dist/modules/base.js.map +1 -1
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +141 -25
- package/dist/modules/catalog.js.map +1 -1
- package/dist/modules/index.d.ts +2 -2
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/known-tools.d.ts +3 -0
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +11 -1
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/modules/types.d.ts +20 -6
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/modules/types.js.map +1 -1
- package/dist/notify-runtime.d.ts +26 -0
- package/dist/notify-runtime.d.ts.map +1 -0
- package/dist/notify-runtime.js +27 -0
- package/dist/notify-runtime.js.map +1 -0
- package/dist/notify-tool.d.ts +51 -0
- package/dist/notify-tool.d.ts.map +1 -0
- package/dist/notify-tool.js +182 -0
- package/dist/notify-tool.js.map +1 -0
- package/dist/package-version.d.ts +3 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +14 -0
- package/dist/package-version.js.map +1 -0
- package/dist/pi-auth-store-inspection.d.ts +21 -0
- package/dist/pi-auth-store-inspection.d.ts.map +1 -0
- package/dist/pi-auth-store-inspection.js +120 -0
- package/dist/pi-auth-store-inspection.js.map +1 -0
- package/dist/pi-oauth-login-main.d.ts +3 -0
- package/dist/pi-oauth-login-main.d.ts.map +1 -0
- package/dist/pi-oauth-login-main.js +14 -0
- package/dist/pi-oauth-login-main.js.map +1 -0
- package/dist/pi-oauth-login.d.ts +22 -0
- package/dist/pi-oauth-login.d.ts.map +1 -0
- package/dist/pi-oauth-login.js +79 -0
- package/dist/pi-oauth-login.js.map +1 -0
- package/dist/proactive-notify.d.ts +2 -0
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +1 -0
- package/dist/proactive-notify.js.map +1 -1
- package/dist/process-incarnation.d.ts +23 -0
- package/dist/process-incarnation.d.ts.map +1 -0
- package/dist/process-incarnation.js +208 -0
- package/dist/process-incarnation.js.map +1 -0
- package/dist/project-skills.d.ts +40 -0
- package/dist/project-skills.d.ts.map +1 -0
- package/dist/project-skills.js +573 -0
- package/dist/project-skills.js.map +1 -0
- package/dist/provider-setup.d.ts +117 -2
- package/dist/provider-setup.d.ts.map +1 -1
- package/dist/provider-setup.js +1505 -49
- package/dist/provider-setup.js.map +1 -1
- package/dist/readiness-probe-worker.d.ts +12 -0
- package/dist/readiness-probe-worker.d.ts.map +1 -0
- package/dist/readiness-probe-worker.js +276 -0
- package/dist/readiness-probe-worker.js.map +1 -0
- package/dist/readiness-probe.d.ts +100 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +780 -0
- package/dist/readiness-probe.js.map +1 -0
- package/dist/recipes/base.d.ts +15 -0
- package/dist/recipes/base.d.ts.map +1 -0
- package/dist/recipes/base.js +51 -0
- package/dist/recipes/base.js.map +1 -0
- package/dist/recipes/catalog.d.ts +4 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +525 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/index.d.ts +11 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +14 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/types.d.ts +70 -0
- package/dist/recipes/types.d.ts.map +1 -0
- package/dist/recipes/types.js +15 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/request-model-override.d.ts +43 -1
- package/dist/request-model-override.d.ts.map +1 -1
- package/dist/request-model-override.js +178 -19
- package/dist/request-model-override.js.map +1 -1
- package/dist/run-history.d.ts +32 -0
- package/dist/run-history.d.ts.map +1 -0
- package/dist/run-history.js +859 -0
- package/dist/run-history.js.map +1 -0
- package/dist/runs-health.d.ts.map +1 -1
- package/dist/runs-health.js +12 -4
- package/dist/runs-health.js.map +1 -1
- package/dist/runtime-option-extensions.d.ts +14 -0
- package/dist/runtime-option-extensions.d.ts.map +1 -0
- package/dist/runtime-option-extensions.js +98 -0
- package/dist/runtime-option-extensions.js.map +1 -0
- package/dist/runtime-routes.d.ts +9 -0
- package/dist/runtime-routes.d.ts.map +1 -0
- package/dist/runtime-routes.js +13 -0
- package/dist/runtime-routes.js.map +1 -0
- package/dist/sandbox-manager.d.ts +86 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +1402 -0
- package/dist/sandbox-manager.js.map +1 -0
- package/dist/setup.d.ts +29 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +97 -0
- package/dist/setup.js.map +1 -0
- package/dist/supermemory-plugin.d.ts +56 -0
- package/dist/supermemory-plugin.d.ts.map +1 -0
- package/dist/supermemory-plugin.js +142 -0
- package/dist/supermemory-plugin.js.map +1 -0
- package/dist/tui-command.d.ts +56 -0
- package/dist/tui-command.d.ts.map +1 -1
- package/dist/tui-command.js +305 -31
- package/dist/tui-command.js.map +1 -1
- package/dist/web-command.d.ts +8 -0
- package/dist/web-command.d.ts.map +1 -1
- package/dist/web-command.js +174 -17
- package/dist/web-command.js.map +1 -1
- package/dist/wizard/answers.d.ts +31 -4
- package/dist/wizard/answers.d.ts.map +1 -1
- package/dist/wizard/answers.js +141 -29
- package/dist/wizard/answers.js.map +1 -1
- package/dist/wizard/from-flags.d.ts +7 -0
- package/dist/wizard/from-flags.d.ts.map +1 -1
- package/dist/wizard/from-flags.js +36 -2
- package/dist/wizard/from-flags.js.map +1 -1
- package/dist/wizard/index.d.ts +4 -4
- package/dist/wizard/index.d.ts.map +1 -1
- package/dist/wizard/index.js +2 -2
- package/dist/wizard/index.js.map +1 -1
- package/dist/wizard/model-discovery.d.ts +33 -2
- package/dist/wizard/model-discovery.d.ts.map +1 -1
- package/dist/wizard/model-discovery.js +826 -108
- package/dist/wizard/model-discovery.js.map +1 -1
- package/dist/wizard/presets.d.ts +1 -1
- package/dist/wizard/presets.d.ts.map +1 -1
- package/dist/wizard/presets.js +7 -12
- package/dist/wizard/presets.js.map +1 -1
- package/dist/wizard/prompts.d.ts +56 -9
- package/dist/wizard/prompts.d.ts.map +1 -1
- package/dist/wizard/prompts.js +172 -20
- package/dist/wizard/prompts.js.map +1 -1
- package/dist/wizard/run.d.ts +59 -3
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +1290 -191
- package/dist/wizard/run.js.map +1 -1
- package/package.json +21 -16
- package/resources/srt/package-lock.json +66 -0
- package/resources/srt/package.json +9 -0
- package/schema/mono-agent.config.schema.json +587 -20
- package/skills/mono-agent-composer/SKILL.md +6 -6
- package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
- package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
- package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
- package/skills/mono-agent-composer/references/package-map.md +2 -2
- package/skills/mono-agent-composer/references/playbooks.md +8 -7
- package/skills/mono-agent-composer/references/validation.md +3 -3
package/dist/provider-setup.js
CHANGED
|
@@ -1,28 +1,345 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { chmod, link, lstat, mkdir, mkdtemp, open, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
5
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
|
10
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
11
|
+
import { modelReferenceKey, parseMonoRuntimeModelReference } from "@mono-agent/runtime-adapter";
|
|
12
|
+
import { inspectPiAuthStore } from "./pi-auth-store-inspection.js";
|
|
6
13
|
const DEFAULT_PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
|
7
|
-
const PI_OAUTH_LOGIN_PROVIDERS = new Set(["anthropic", "github-copilot", "openai-codex"]);
|
|
8
14
|
const PI_API_KEY_PROVIDERS = {
|
|
9
15
|
"opencode-go": "OPENCODE_API_KEY",
|
|
10
16
|
};
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
const DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS = {
|
|
18
|
+
codex: ["OPENAI_API_KEY"],
|
|
19
|
+
claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"],
|
|
20
|
+
};
|
|
21
|
+
const PROVIDER_STATUS_SECRET_ENV_KEYS = [
|
|
22
|
+
...DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.codex,
|
|
23
|
+
...DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.claude,
|
|
24
|
+
...Object.values(PI_API_KEY_PROVIDERS),
|
|
25
|
+
];
|
|
26
|
+
const PROVIDER_STATUS_ENV_ALLOWLIST = new Set([
|
|
27
|
+
"APPDATA",
|
|
28
|
+
"COLORTERM",
|
|
29
|
+
"COMSPEC",
|
|
30
|
+
"HOMEDRIVE",
|
|
31
|
+
"HOME",
|
|
32
|
+
"HOMEPATH",
|
|
33
|
+
"LANG",
|
|
34
|
+
"LANGUAGE",
|
|
35
|
+
"LOCALAPPDATA",
|
|
36
|
+
"LOGNAME",
|
|
37
|
+
"NO_COLOR",
|
|
38
|
+
"PATH",
|
|
39
|
+
"PATHEXT",
|
|
40
|
+
"SHELL",
|
|
41
|
+
"SYSTEMROOT",
|
|
42
|
+
"TEMP",
|
|
43
|
+
"TERM",
|
|
44
|
+
"TMP",
|
|
45
|
+
"TMPDIR",
|
|
46
|
+
"USER",
|
|
47
|
+
"USERPROFILE",
|
|
48
|
+
"USERNAME",
|
|
49
|
+
"WINDIR",
|
|
50
|
+
"XDG_CACHE_HOME",
|
|
51
|
+
"XDG_CONFIG_DIRS",
|
|
52
|
+
"XDG_CONFIG_HOME",
|
|
53
|
+
"XDG_DATA_DIRS",
|
|
54
|
+
"XDG_DATA_HOME",
|
|
55
|
+
"XDG_STATE_HOME",
|
|
56
|
+
]);
|
|
57
|
+
const DEFAULT_PROVIDER_PREFLIGHT_TIMEOUT_MS = 5_000;
|
|
58
|
+
const PROVIDER_AUTH_TERM_GRACE_MS = 1_000;
|
|
59
|
+
const PROVIDER_AUTH_KILL_SETTLE_MS = 1_000;
|
|
60
|
+
const PROVIDER_PREFLIGHT_TERM_GRACE_MS = 250;
|
|
61
|
+
const PROVIDER_PREFLIGHT_KILL_SETTLE_MS = 250;
|
|
62
|
+
const PROVIDER_DISCOVERY_TERM_GRACE_MS = 250;
|
|
63
|
+
const PROVIDER_DISCOVERY_KILL_SETTLE_MS = 250;
|
|
64
|
+
const MAX_PROVIDER_DISCOVERY_STDOUT_BYTES = 4 * 1024 * 1024;
|
|
65
|
+
const MAX_API_KEY_CREDENTIAL_STRING_LENGTH = 65_536;
|
|
66
|
+
export function resolvePiCliPath() {
|
|
67
|
+
// App-owned wrapper, provider-owned OAuth: unlike Pi's generic CLI this
|
|
68
|
+
// supplies onManualCodeInput, so a full redirect URL pasted into the terminal
|
|
69
|
+
// reaches Anthropic's state-validating parser.
|
|
70
|
+
return fileURLToPath(new URL("./pi-oauth-login-main.js", import.meta.url));
|
|
71
|
+
}
|
|
72
|
+
export function piLoginCommand(provider, piCliPath = resolvePiCliPath()) {
|
|
73
|
+
return [process.execPath, piCliPath, provider];
|
|
13
74
|
}
|
|
14
75
|
export function piLoginCommandLine(provider) {
|
|
15
|
-
return
|
|
76
|
+
return piAuthRecoveryCommand(provider);
|
|
77
|
+
}
|
|
78
|
+
export function piAuthRecoveryCommand(provider, piAuthPath) {
|
|
79
|
+
return piAuthPath === undefined
|
|
80
|
+
? `mono-agent auth login ${shellQuote(provider)}`
|
|
81
|
+
: `mono-agent auth login ${shellQuote(provider)} --pi-auth-path ${shellQuote(piAuthPath)}`;
|
|
16
82
|
}
|
|
17
83
|
export function piAuthWorkingDirectory(piAuthPath, cwd = process.cwd()) {
|
|
18
|
-
return dirname(piAuthPath
|
|
84
|
+
return dirname(piAuthPathForSetup(piAuthPath, cwd));
|
|
19
85
|
}
|
|
20
86
|
export function piAuthPathForSetup(piAuthPath, cwd = process.cwd()) {
|
|
21
|
-
|
|
87
|
+
if (piAuthPath === undefined || piAuthPath.trim().length === 0) {
|
|
88
|
+
return DEFAULT_PI_AUTH_PATH;
|
|
89
|
+
}
|
|
90
|
+
const normalized = piAuthPath.trim();
|
|
91
|
+
if (normalized === "~") {
|
|
92
|
+
return homedir();
|
|
93
|
+
}
|
|
94
|
+
if (normalized.startsWith("~/") || normalized.startsWith("~\\")) {
|
|
95
|
+
return resolve(homedir(), normalized.slice(2));
|
|
96
|
+
}
|
|
97
|
+
return resolve(cwd, normalized);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Detect credential/sign-in postconditions without authenticating or claiming
|
|
101
|
+
* provider readiness. Only an exact live route probe may promote these states
|
|
102
|
+
* to `verified`.
|
|
103
|
+
*/
|
|
104
|
+
export async function detectProviderCredentialStates(options) {
|
|
105
|
+
const states = {};
|
|
106
|
+
const refs = options.modelRefs.flatMap((raw) => {
|
|
107
|
+
try {
|
|
108
|
+
return [parseMonoRuntimeModelReference(raw)];
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
const timeout = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
115
|
+
? Math.trunc(options.timeoutMs)
|
|
116
|
+
: 2_000;
|
|
117
|
+
const run = options.execFile ?? runProviderStatusCommand;
|
|
118
|
+
const persistedEnv = options.persistedEnv ?? {};
|
|
119
|
+
const statusEnv = credentialNeutralProviderStatusEnvironment(process.env, persistedEnv);
|
|
120
|
+
const checks = [];
|
|
121
|
+
if (refs.some((ref) => ref.sdk === "codex")) {
|
|
122
|
+
if (hasAnyNonEmptyPersistedValue(persistedEnv, DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.codex)) {
|
|
123
|
+
states.codex = "credential_detected";
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
checks.push(run("codex", ["login", "status"], {
|
|
127
|
+
cwd: options.cwd,
|
|
128
|
+
timeout,
|
|
129
|
+
env: statusEnv,
|
|
130
|
+
...(options.abortSignal === undefined ? {} : { abortSignal: options.abortSignal }),
|
|
131
|
+
}).then(() => { states.codex = "credential_detected"; }, () => { states.codex = "auth_required"; }));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (refs.some((ref) => ref.sdk === "claude")) {
|
|
135
|
+
if (hasAnyNonEmptyPersistedValue(persistedEnv, DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.claude)) {
|
|
136
|
+
states.claude = "credential_detected";
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
checks.push(run("claude", ["auth", "status", "--json"], {
|
|
140
|
+
cwd: options.cwd,
|
|
141
|
+
timeout,
|
|
142
|
+
env: statusEnv,
|
|
143
|
+
...(options.abortSignal === undefined ? {} : { abortSignal: options.abortSignal }),
|
|
144
|
+
}).then(() => { states.claude = "credential_detected"; }, () => { states.claude = "auth_required"; }));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const piProviders = new Set(refs.filter((ref) => ref.sdk === "pi" && typeof ref.provider === "string")
|
|
148
|
+
.map((ref) => ref.provider));
|
|
149
|
+
if (piProviders.size > 0) {
|
|
150
|
+
const detected = await safelyDetectedPiCredentialProviders(piAuthPathForSetup(options.piAuthPath, options.cwd));
|
|
151
|
+
for (const provider of piProviders) {
|
|
152
|
+
const apiKeyEnv = PI_API_KEY_PROVIDERS[provider];
|
|
153
|
+
states[`pi:${provider}`] = detected.has(provider)
|
|
154
|
+
|| (apiKeyEnv !== undefined && hasNonEmptyPersistedValue(persistedEnv[apiKeyEnv]))
|
|
155
|
+
? "credential_detected"
|
|
156
|
+
: "auth_required";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await Promise.all(checks);
|
|
160
|
+
return states;
|
|
161
|
+
}
|
|
162
|
+
/** Whether a selected route has a credential in the destination agent's durable environment. */
|
|
163
|
+
export function hasDurableProviderEnvironmentCredential(rawModelRef, persistedEnv) {
|
|
164
|
+
let ref;
|
|
165
|
+
try {
|
|
166
|
+
ref = parseMonoRuntimeModelReference(rawModelRef);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (ref.sdk === "codex") {
|
|
172
|
+
return hasAnyNonEmptyPersistedValue(persistedEnv, DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.codex);
|
|
173
|
+
}
|
|
174
|
+
if (ref.sdk === "claude") {
|
|
175
|
+
return hasAnyNonEmptyPersistedValue(persistedEnv, DIRECT_PROVIDER_CREDENTIAL_ENV_KEYS.claude);
|
|
176
|
+
}
|
|
177
|
+
if (ref.sdk !== "pi" || typeof ref.provider !== "string")
|
|
178
|
+
return false;
|
|
179
|
+
const apiKeyEnv = PI_API_KEY_PROVIDERS[ref.provider];
|
|
180
|
+
return apiKeyEnv !== undefined && hasNonEmptyPersistedValue(persistedEnv[apiKeyEnv]);
|
|
181
|
+
}
|
|
182
|
+
function hasAnyNonEmptyPersistedValue(persistedEnv, names) {
|
|
183
|
+
return names.some((name) => hasNonEmptyPersistedValue(persistedEnv[name]));
|
|
184
|
+
}
|
|
185
|
+
function hasNonEmptyPersistedValue(value) {
|
|
186
|
+
return value !== undefined && value.trim().length > 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Build the minimal operational environment needed to inspect durable CLI login
|
|
190
|
+
* state. A positive allowlist prevents unrelated shell credentials from being
|
|
191
|
+
* inherited by Codex or Claude while retaining their standard config roots.
|
|
192
|
+
*/
|
|
193
|
+
export function credentialNeutralProviderStatusEnvironment(source = process.env, durableEnvironment = {}) {
|
|
194
|
+
const sanitized = {};
|
|
195
|
+
for (const [name, value] of Object.entries(source)) {
|
|
196
|
+
const normalizedName = name.toUpperCase();
|
|
197
|
+
if (PROVIDER_STATUS_ENV_ALLOWLIST.has(normalizedName)
|
|
198
|
+
|| normalizedName.startsWith("LC_")) {
|
|
199
|
+
sanitized[name] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const name of ["CLAUDE_CONFIG_DIR", "CODEX_HOME"]) {
|
|
203
|
+
const value = durableEnvironment[name];
|
|
204
|
+
if (hasNonEmptyPersistedValue(value))
|
|
205
|
+
sanitized[name] = value;
|
|
206
|
+
}
|
|
207
|
+
for (const name of PROVIDER_STATUS_SECRET_ENV_KEYS)
|
|
208
|
+
delete sanitized[name];
|
|
209
|
+
return sanitized;
|
|
210
|
+
}
|
|
211
|
+
function runProviderStatusCommand(file, args, options) {
|
|
212
|
+
return runBoundedProviderCommand(file, args, options).then(() => undefined);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Run a non-interactive provider probe with a hard process-lifecycle bound.
|
|
216
|
+
* Node's execFile timeout sends only SIGTERM and can wait forever when a CLI
|
|
217
|
+
* traps it, so discovery uses explicit TERM-to-KILL escalation and detaches a
|
|
218
|
+
* process whose exit still cannot be confirmed.
|
|
219
|
+
*/
|
|
220
|
+
export function runBoundedProviderCommand(file, args, options) {
|
|
221
|
+
const timeout = Number.isFinite(options.timeout) && options.timeout > 0
|
|
222
|
+
? Math.max(1, Math.trunc(options.timeout))
|
|
223
|
+
: 1;
|
|
224
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
225
|
+
const spawnImpl = options.spawn ?? spawn;
|
|
226
|
+
const child = spawnImpl(file, [...args], {
|
|
227
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
228
|
+
...(options.env === undefined ? {} : { env: { ...options.env } }),
|
|
229
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
230
|
+
});
|
|
231
|
+
const chunks = [];
|
|
232
|
+
let stdoutBytes = 0;
|
|
233
|
+
let settled = false;
|
|
234
|
+
let terminationReason;
|
|
235
|
+
let childError;
|
|
236
|
+
let timeoutTimer;
|
|
237
|
+
let forceKillTimer;
|
|
238
|
+
let killSettlementTimer;
|
|
239
|
+
const finish = (error) => {
|
|
240
|
+
if (settled)
|
|
241
|
+
return;
|
|
242
|
+
settled = true;
|
|
243
|
+
if (timeoutTimer !== undefined)
|
|
244
|
+
clearTimeout(timeoutTimer);
|
|
245
|
+
if (forceKillTimer !== undefined)
|
|
246
|
+
clearTimeout(forceKillTimer);
|
|
247
|
+
if (killSettlementTimer !== undefined)
|
|
248
|
+
clearTimeout(killSettlementTimer);
|
|
249
|
+
options.abortSignal?.removeEventListener("abort", abort);
|
|
250
|
+
child.stdout?.off("data", onStdout);
|
|
251
|
+
if (error === undefined)
|
|
252
|
+
resolveCommand({ stdout: Buffer.concat(chunks).toString("utf8") });
|
|
253
|
+
else
|
|
254
|
+
rejectCommand(error);
|
|
255
|
+
};
|
|
256
|
+
const terminate = (reason) => {
|
|
257
|
+
if (settled || terminationReason !== undefined)
|
|
258
|
+
return;
|
|
259
|
+
terminationReason = reason;
|
|
260
|
+
try {
|
|
261
|
+
child.kill("SIGTERM");
|
|
262
|
+
}
|
|
263
|
+
catch { /* escalation remains authoritative */ }
|
|
264
|
+
forceKillTimer = setTimeout(() => {
|
|
265
|
+
if (settled)
|
|
266
|
+
return;
|
|
267
|
+
try {
|
|
268
|
+
child.kill("SIGKILL");
|
|
269
|
+
}
|
|
270
|
+
catch { /* settlement deadline remains authoritative */ }
|
|
271
|
+
killSettlementTimer = setTimeout(() => {
|
|
272
|
+
child.stdout?.destroy();
|
|
273
|
+
child.unref?.();
|
|
274
|
+
finish(reason);
|
|
275
|
+
}, PROVIDER_DISCOVERY_KILL_SETTLE_MS);
|
|
276
|
+
killSettlementTimer.unref?.();
|
|
277
|
+
}, PROVIDER_DISCOVERY_TERM_GRACE_MS);
|
|
278
|
+
forceKillTimer.unref?.();
|
|
279
|
+
};
|
|
280
|
+
const abort = () => {
|
|
281
|
+
const error = new Error(`${file} provider probe was interrupted.`);
|
|
282
|
+
error.name = "AbortError";
|
|
283
|
+
terminate(error);
|
|
284
|
+
};
|
|
285
|
+
const onStdout = (chunk) => {
|
|
286
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
287
|
+
stdoutBytes += bytes.byteLength;
|
|
288
|
+
if (stdoutBytes > MAX_PROVIDER_DISCOVERY_STDOUT_BYTES) {
|
|
289
|
+
terminate(new Error(`${file} provider probe exceeded the ${MAX_PROVIDER_DISCOVERY_STDOUT_BYTES}-byte output limit.`));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
chunks.push(bytes);
|
|
293
|
+
};
|
|
294
|
+
child.stdout?.on("data", onStdout);
|
|
295
|
+
child.once("error", (error) => {
|
|
296
|
+
childError = error;
|
|
297
|
+
if (child.pid === undefined && terminationReason === undefined)
|
|
298
|
+
finish(error);
|
|
299
|
+
});
|
|
300
|
+
child.once("close", (code, signal) => {
|
|
301
|
+
if (terminationReason !== undefined) {
|
|
302
|
+
finish(terminationReason);
|
|
303
|
+
}
|
|
304
|
+
else if (code === 0) {
|
|
305
|
+
finish();
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
finish(childError ?? new Error(signal === null
|
|
309
|
+
? `${file} provider probe exited ${code ?? "unknown"}.`
|
|
310
|
+
: `${file} provider probe terminated by ${signal}.`));
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
timeoutTimer = setTimeout(() => terminate(new Error(`${file} provider probe timed out after ${timeout}ms.`)), timeout);
|
|
314
|
+
timeoutTimer.unref?.();
|
|
315
|
+
options.abortSignal?.addEventListener("abort", abort, { once: true });
|
|
316
|
+
if (options.abortSignal?.aborted === true)
|
|
317
|
+
abort();
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async function safelyDetectedPiCredentialProviders(path) {
|
|
321
|
+
const inspection = await inspectPiAuthStore(path);
|
|
322
|
+
if (inspection.status !== "ok")
|
|
323
|
+
return new Set();
|
|
324
|
+
const nested = isRecord(inspection.auth.providers) ? inspection.auth.providers : {};
|
|
325
|
+
const entries = Object.entries({
|
|
326
|
+
...nested,
|
|
327
|
+
...Object.fromEntries(Object.entries(inspection.auth).filter(([key]) => key !== "providers")),
|
|
328
|
+
});
|
|
329
|
+
return new Set(entries
|
|
330
|
+
.filter(([provider, credential]) => isUsableStoredPiCredential(provider, credential))
|
|
331
|
+
.map(([provider]) => provider));
|
|
22
332
|
}
|
|
23
333
|
export function planProviderSetup(options) {
|
|
24
334
|
const piAuthPath = options.piAuthPath ?? DEFAULT_PI_AUTH_PATH;
|
|
25
335
|
const actionsById = new Map();
|
|
336
|
+
const detectedModelRefs = new Set();
|
|
337
|
+
const piOAuthProviders = new Set(getOAuthProviders().map((provider) => provider.id));
|
|
338
|
+
const piProviders = builtinModels();
|
|
339
|
+
const authAlreadyDetected = (keys) => options.forceAuthentication !== true && keys.some((key) => {
|
|
340
|
+
const state = options.credentialStates?.[key];
|
|
341
|
+
return state === "credential_detected" || state === "verified";
|
|
342
|
+
});
|
|
26
343
|
for (const raw of options.modelRefs) {
|
|
27
344
|
let ref;
|
|
28
345
|
try {
|
|
@@ -44,6 +361,10 @@ export function planProviderSetup(options) {
|
|
|
44
361
|
});
|
|
45
362
|
};
|
|
46
363
|
if (ref.sdk === "claude") {
|
|
364
|
+
if (authAlreadyDetected(["claude", refKey])) {
|
|
365
|
+
detectedModelRefs.add(refKey);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
47
368
|
add({
|
|
48
369
|
id: "claude-login",
|
|
49
370
|
kind: "auth",
|
|
@@ -56,20 +377,32 @@ export function planProviderSetup(options) {
|
|
|
56
377
|
continue;
|
|
57
378
|
}
|
|
58
379
|
if (ref.sdk === "codex") {
|
|
380
|
+
if (authAlreadyDetected(["codex", refKey])) {
|
|
381
|
+
detectedModelRefs.add(refKey);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const authMode = options.codexAuthMode ?? "browser";
|
|
59
385
|
add({
|
|
60
386
|
id: "codex-login",
|
|
61
387
|
kind: "auth",
|
|
62
388
|
label: "Codex login",
|
|
63
389
|
modelRefs: [refKey],
|
|
64
|
-
command: ["codex", "login"],
|
|
390
|
+
command: authMode === "device" ? ["codex", "login", "--device-auth"] : ["codex", "login"],
|
|
391
|
+
authMode,
|
|
65
392
|
cwd: options.cwd,
|
|
66
|
-
detail:
|
|
393
|
+
detail: authMode === "device"
|
|
394
|
+
? "Runs Codex device-code login for a remote or headless machine."
|
|
395
|
+
: "Runs Codex browser login with a localhost callback server.",
|
|
67
396
|
});
|
|
68
397
|
continue;
|
|
69
398
|
}
|
|
70
399
|
if (ref.sdk !== "pi" || typeof ref.provider !== "string") {
|
|
71
400
|
continue;
|
|
72
401
|
}
|
|
402
|
+
if (authAlreadyDetected([`pi:${ref.provider}`, ref.provider, refKey])) {
|
|
403
|
+
detectedModelRefs.add(refKey);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
73
406
|
if (ref.provider === "ollama") {
|
|
74
407
|
add({
|
|
75
408
|
id: "ollama-list",
|
|
@@ -94,21 +427,38 @@ export function planProviderSetup(options) {
|
|
|
94
427
|
});
|
|
95
428
|
continue;
|
|
96
429
|
}
|
|
97
|
-
|
|
430
|
+
const provider = piProviders.getProvider(ref.provider);
|
|
431
|
+
const supportsOAuth = piOAuthProviders.has(ref.provider);
|
|
432
|
+
const supportsApiKeyLogin = typeof provider?.auth.apiKey?.login === "function";
|
|
433
|
+
const selectedMethod = options.piAuthMethods?.[ref.provider]
|
|
434
|
+
?? (supportsOAuth ? "oauth" : supportsApiKeyLogin ? "api-key" : undefined);
|
|
435
|
+
if (selectedMethod === "api-key" && supportsApiKeyLogin) {
|
|
436
|
+
const envVar = PI_API_KEY_PROVIDERS[ref.provider];
|
|
437
|
+
// Complex or ambient providers may require multiple values. Never invent
|
|
438
|
+
// an environment name or persist an incomplete credential; their normal
|
|
439
|
+
// provider environment remains available as the explicit manual path.
|
|
440
|
+
if (envVar === undefined)
|
|
441
|
+
continue;
|
|
442
|
+
const persistence = options.piApiKeyPersistenceByProvider?.[ref.provider]
|
|
443
|
+
?? options.piApiKeyPersistence
|
|
444
|
+
?? "secure-store";
|
|
98
445
|
add({
|
|
99
|
-
id:
|
|
446
|
+
id: `pi-api-key:${ref.provider}`,
|
|
100
447
|
kind: "auth",
|
|
101
|
-
label: "
|
|
448
|
+
label: `${provider?.auth.apiKey?.name ?? ref.provider} (${persistence === "secure-store" ? "secure store" : "environment"})`,
|
|
102
449
|
modelRefs: [refKey],
|
|
103
450
|
provider: ref.provider,
|
|
104
|
-
envVar
|
|
451
|
+
envVar,
|
|
452
|
+
persistence,
|
|
105
453
|
piAuthPath: piAuthPathForSetup(piAuthPath, options.cwd),
|
|
106
454
|
cwd: piAuthWorkingDirectory(piAuthPath, options.cwd),
|
|
107
|
-
detail:
|
|
455
|
+
detail: persistence === "secure-store"
|
|
456
|
+
? `Stores ${envVar} in the owner-only Pi auth store used by providers.piAuthPath.`
|
|
457
|
+
: `Uses ${envVar} from the durable agent environment without copying it into Pi auth.json.`,
|
|
108
458
|
});
|
|
109
459
|
continue;
|
|
110
460
|
}
|
|
111
|
-
if (!
|
|
461
|
+
if (selectedMethod !== "oauth" || !supportsOAuth) {
|
|
112
462
|
continue;
|
|
113
463
|
}
|
|
114
464
|
add({
|
|
@@ -116,16 +466,20 @@ export function planProviderSetup(options) {
|
|
|
116
466
|
kind: "auth",
|
|
117
467
|
label: `Pi login for ${ref.provider}`,
|
|
118
468
|
modelRefs: [refKey],
|
|
119
|
-
command: piLoginCommand(ref.provider),
|
|
469
|
+
command: piLoginCommand(ref.provider, options.piCliPath),
|
|
470
|
+
piAuthPath: piAuthPathForSetup(piAuthPath, options.cwd),
|
|
120
471
|
cwd: piAuthWorkingDirectory(piAuthPath, options.cwd),
|
|
121
|
-
detail: `Runs Pi auth for provider \`${ref.provider}\`
|
|
472
|
+
detail: `Runs bundled Pi auth for provider \`${ref.provider}\` and securely replaces providers.piAuthPath.`,
|
|
122
473
|
});
|
|
123
474
|
}
|
|
124
|
-
return { actions: [...actionsById.values()] };
|
|
475
|
+
return { actions: [...actionsById.values()], detectedModelRefs: [...detectedModelRefs] };
|
|
125
476
|
}
|
|
126
477
|
export function providerSetupActionCommandLine(action) {
|
|
127
478
|
if ("command" in action) {
|
|
128
|
-
|
|
479
|
+
if (isProviderSetupPiLoginAction(action)) {
|
|
480
|
+
return piAuthRecoveryCommand(action.id.slice("pi-login:".length), action.piAuthPath);
|
|
481
|
+
}
|
|
482
|
+
return action.command.map(shellQuote).join(" ");
|
|
129
483
|
}
|
|
130
484
|
if (isProviderSetupPiApiKeyAction(action)) {
|
|
131
485
|
return `${action.envVar} -> ${action.piAuthPath}`;
|
|
@@ -135,23 +489,33 @@ export function providerSetupActionCommandLine(action) {
|
|
|
135
489
|
export function isProviderSetupPiApiKeyAction(action) {
|
|
136
490
|
return "provider" in action && "piAuthPath" in action && "envVar" in action;
|
|
137
491
|
}
|
|
492
|
+
export function isProviderSetupPiLoginAction(action) {
|
|
493
|
+
return action.id.startsWith("pi-login:") && "piAuthPath" in action && "command" in action;
|
|
494
|
+
}
|
|
138
495
|
export async function executeProviderSetupPlan(plan, options = {}) {
|
|
496
|
+
const preflightTimeoutMs = positivePreflightTimeout(options.preflightTimeoutMs);
|
|
139
497
|
const results = [];
|
|
140
498
|
for (const action of plan.actions) {
|
|
499
|
+
if (options.abortSignal?.aborted === true)
|
|
500
|
+
break;
|
|
141
501
|
const result = isProviderSetupPiApiKeyAction(action)
|
|
142
|
-
? await runPiApiKeyAction(action, options.apiKeys ?? {})
|
|
502
|
+
? await runPiApiKeyAction(action, options.apiKeys ?? {}, options.platform ?? process.platform, options)
|
|
143
503
|
: "command" in action
|
|
144
|
-
? await runCommandAction(action, options.spawn ?? spawn)
|
|
145
|
-
: await runHttpAction(action, options.fetch ?? fetch);
|
|
504
|
+
? await runCommandAction(action, options.spawn ?? spawn, options.platform ?? process.platform, options, preflightTimeoutMs, options.abortSignal)
|
|
505
|
+
: await runHttpAction(action, options.fetch ?? fetch, preflightTimeoutMs, options.abortSignal);
|
|
146
506
|
results.push(result);
|
|
147
|
-
if (result.
|
|
507
|
+
if (result.failureKind !== undefined)
|
|
148
508
|
break;
|
|
149
|
-
}
|
|
150
509
|
}
|
|
151
510
|
return results;
|
|
152
511
|
}
|
|
153
|
-
async function runPiApiKeyAction(action, apiKeys) {
|
|
154
|
-
|
|
512
|
+
async function runPiApiKeyAction(action, apiKeys, platform, hooks) {
|
|
513
|
+
// Secure-store is an explicit mutation and therefore consumes only a value
|
|
514
|
+
// intentionally handed to this action. Merely exporting an environment
|
|
515
|
+
// variable must never cause mono-agent to copy it into auth.json.
|
|
516
|
+
const raw = apiKeys[action.id]
|
|
517
|
+
?? apiKeys[action.provider]
|
|
518
|
+
?? (action.persistence === "environment" ? process.env[action.envVar] : undefined);
|
|
155
519
|
const key = raw?.trim();
|
|
156
520
|
if (key === undefined || key.length === 0) {
|
|
157
521
|
return {
|
|
@@ -160,12 +524,23 @@ async function runPiApiKeyAction(action, apiKeys) {
|
|
|
160
524
|
detail: `${action.envVar} was not provided; skipped saving credentials for ${action.provider}.`,
|
|
161
525
|
};
|
|
162
526
|
}
|
|
527
|
+
if (action.persistence === "environment") {
|
|
528
|
+
return {
|
|
529
|
+
action,
|
|
530
|
+
status: "ok",
|
|
531
|
+
detail: `${action.envVar} is available in the durable environment; the value was not copied into Pi auth.json.`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
163
534
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
535
|
+
assertOwnerOnlyPersistenceSupported(platform);
|
|
536
|
+
await withPiAuthFileLock(action.piAuthPath, async (authPath, ownerUid, assertLockHeld) => {
|
|
537
|
+
const original = await readPiAuthStore(authPath, piAuthSingleLinkPolicy(ownerUid));
|
|
538
|
+
const next = {
|
|
539
|
+
...original.auth,
|
|
540
|
+
[action.provider]: { type: "api_key", key },
|
|
541
|
+
};
|
|
542
|
+
await writePiAuthStoreAtomically(authPath, next, original, ownerUid, assertLockHeld, hooks);
|
|
543
|
+
}, hooks);
|
|
169
544
|
return {
|
|
170
545
|
action,
|
|
171
546
|
status: "ok",
|
|
@@ -176,51 +551,1122 @@ async function runPiApiKeyAction(action, apiKeys) {
|
|
|
176
551
|
return {
|
|
177
552
|
action,
|
|
178
553
|
status: "failed",
|
|
554
|
+
...(error instanceof PiAuthCleanupError ? { failureKind: "cleanup_failed" } : {}),
|
|
179
555
|
detail: error instanceof Error ? error.message : String(error),
|
|
180
556
|
};
|
|
181
557
|
}
|
|
182
558
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
559
|
+
function isBoundedCredentialString(value) {
|
|
560
|
+
return typeof value === "string"
|
|
561
|
+
&& value.trim().length > 0
|
|
562
|
+
&& value.length <= MAX_API_KEY_CREDENTIAL_STRING_LENGTH
|
|
563
|
+
&& !value.includes("\0");
|
|
564
|
+
}
|
|
565
|
+
function isUsableStoredPiCredential(_provider, value) {
|
|
566
|
+
if (!isRecord(value))
|
|
567
|
+
return false;
|
|
568
|
+
if (value.type === "oauth") {
|
|
569
|
+
return isBoundedCredentialString(value.access) || isBoundedCredentialString(value.refresh);
|
|
570
|
+
}
|
|
571
|
+
return value.type === "api_key" && isBoundedCredentialString(value.key);
|
|
572
|
+
}
|
|
573
|
+
async function runCommandAction(action, spawnImpl, platform, hooks, preflightTimeoutMs, abortSignal) {
|
|
574
|
+
if (isProviderSetupPiLoginAction(action)) {
|
|
575
|
+
return await runPiLoginAction(action, spawnImpl, platform, hooks, abortSignal);
|
|
576
|
+
}
|
|
577
|
+
return await runSpawnedCommand(action, spawnImpl, action.cwd, preflightTimeoutMs, abortSignal);
|
|
578
|
+
}
|
|
579
|
+
async function runPiLoginAction(action, spawnImpl, platform, hooks, abortSignal) {
|
|
580
|
+
let dominantFailure;
|
|
581
|
+
let cleanupError;
|
|
582
|
+
let stagingPath;
|
|
583
|
+
let stagingCleanupCompleted = false;
|
|
584
|
+
let piTaskCompleted = false;
|
|
585
|
+
try {
|
|
586
|
+
assertOwnerOnlyPersistenceSupported(platform);
|
|
587
|
+
return await withPiAuthFileLock(action.piAuthPath, async (authPath, ownerUid, assertLockHeld) => {
|
|
588
|
+
let stagingDir;
|
|
589
|
+
let operationError;
|
|
590
|
+
try {
|
|
591
|
+
const original = await readPiAuthStore(authPath, piAuthSingleLinkPolicy(ownerUid));
|
|
592
|
+
stagingDir = await mkdtemp(join(dirname(authPath), ".mono-agent-pi-auth-"));
|
|
593
|
+
stagingPath = stagingDir;
|
|
594
|
+
const stagedAuthPath = join(stagingDir, "auth.json");
|
|
595
|
+
if (original.exists) {
|
|
596
|
+
await writeFile(stagedAuthPath, original.contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
597
|
+
}
|
|
598
|
+
const result = await runSpawnedCommand(action, spawnImpl, stagingDir, DEFAULT_PROVIDER_PREFLIGHT_TIMEOUT_MS, abortSignal);
|
|
599
|
+
if (result.status !== "ok") {
|
|
600
|
+
dominantFailure = result;
|
|
601
|
+
piTaskCompleted = true;
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
const staged = await readPiAuthStore(stagedAuthPath, piAuthSingleLinkPolicy(ownerUid));
|
|
605
|
+
if (!staged.exists) {
|
|
606
|
+
throw new Error("Bundled Pi login exited successfully without producing auth.json; the configured store was not changed.");
|
|
607
|
+
}
|
|
608
|
+
const provider = action.id.slice("pi-login:".length);
|
|
609
|
+
assertOAuthCredential(staged.auth[provider], provider);
|
|
610
|
+
assertUnchangedSiblingCredentials(original.auth, staged.auth, provider);
|
|
611
|
+
await assertPiAuthStoreUnchanged(authPath, original, ownerUid);
|
|
612
|
+
await chmod(stagedAuthPath, 0o600);
|
|
613
|
+
await syncFile(stagedAuthPath);
|
|
614
|
+
const hardenedStaged = await readPiAuthStore(stagedAuthPath, piAuthSingleLinkPolicy(ownerUid));
|
|
615
|
+
if (!hardenedStaged.exists ||
|
|
616
|
+
hardenedStaged.dev !== staged.dev ||
|
|
617
|
+
hardenedStaged.ino !== staged.ino ||
|
|
618
|
+
hardenedStaged.contents !== staged.contents ||
|
|
619
|
+
((hardenedStaged.mode ?? 0) & 0o777) !== 0o600) {
|
|
620
|
+
throw new Error("Bundled Pi login output changed while owner-only permissions were applied; the configured store was not changed.");
|
|
621
|
+
}
|
|
622
|
+
await promotePiAuthStoreWithoutClobber(stagedAuthPath, authPath, original, ownerUid, assertLockHeld, hardenedStaged, hooks);
|
|
623
|
+
piTaskCompleted = true;
|
|
624
|
+
try {
|
|
625
|
+
await hooks.beforePiAuthPostMutationSync?.(authPath);
|
|
626
|
+
await syncDirectory(dirname(authPath));
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
throw new PiAuthCleanupError(authPath, `credentials were installed but parent-directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
action,
|
|
633
|
+
status: "ok",
|
|
634
|
+
detail: `${providerSetupActionCommandLine(action)} saved credentials to ${action.piAuthPath}.`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
operationError = error;
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
if (stagingDir !== undefined) {
|
|
643
|
+
try {
|
|
644
|
+
await hooks.beforePiAuthCleanup?.(stagingDir);
|
|
645
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
646
|
+
stagingCleanupCompleted = true;
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
const stagingCleanupError = new PiAuthCleanupError(stagingDir, `the credential-bearing OAuth staging directory could not be removed: ${error instanceof Error ? error.message : String(error)}`);
|
|
650
|
+
cleanupError = stagingCleanupError;
|
|
651
|
+
throw combinePiAuthCleanupError(operationError, stagingCleanupError);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}, hooks);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
if (dominantFailure?.failureKind === "child_exit_unconfirmed") {
|
|
659
|
+
return {
|
|
660
|
+
...dominantFailure,
|
|
661
|
+
detail: `${dominantFailure.detail} Cleanup also failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
662
|
+
};
|
|
188
663
|
}
|
|
189
|
-
|
|
664
|
+
if (cleanupError !== undefined || piTaskCompleted || error instanceof PiAuthCleanupError) {
|
|
665
|
+
const cleanupPath = error instanceof PiAuthCleanupError
|
|
666
|
+
? error.path
|
|
667
|
+
: !stagingCleanupCompleted && stagingPath !== undefined
|
|
668
|
+
? stagingPath
|
|
669
|
+
: `${action.piAuthPath}.mono-agent.lock`;
|
|
190
670
|
return {
|
|
191
671
|
action,
|
|
192
672
|
status: "failed",
|
|
193
|
-
|
|
673
|
+
failureKind: "cleanup_failed",
|
|
674
|
+
detail: `${dominantFailure?.detail === undefined ? "Provider authentication stopped." : dominantFailure.detail} ` +
|
|
675
|
+
`Temporary credential cleanup for ${cleanupPath} failed: ${error instanceof Error ? error.message : String(error)} ` +
|
|
676
|
+
"Inspect and remove the residue manually before retrying authentication.",
|
|
194
677
|
};
|
|
195
678
|
}
|
|
679
|
+
return {
|
|
680
|
+
action,
|
|
681
|
+
status: "failed",
|
|
682
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
class PiAuthCleanupError extends Error {
|
|
687
|
+
paths;
|
|
688
|
+
path;
|
|
689
|
+
detail;
|
|
690
|
+
constructor(path, detail) {
|
|
691
|
+
const paths = [...new Set(typeof path === "string" ? [path] : path)];
|
|
692
|
+
const primaryPath = paths[0] ?? "unknown credential artifact";
|
|
693
|
+
super(`Pi credential${paths.length === 1 && primaryPath.endsWith(".mono-agent.lock") ? " lock" : ""} cleanup for ${paths.join(", ")} is uncertain: ${detail}`);
|
|
694
|
+
this.name = "PiAuthCleanupError";
|
|
695
|
+
this.paths = paths;
|
|
696
|
+
this.path = primaryPath;
|
|
697
|
+
this.detail = detail;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function combinePiAuthCleanupError(earlier, cleanup) {
|
|
701
|
+
if (earlier instanceof PiAuthCleanupError) {
|
|
702
|
+
return new PiAuthCleanupError([...earlier.paths, ...cleanup.paths], `${earlier.detail}; additionally, ${cleanup.detail}`);
|
|
703
|
+
}
|
|
704
|
+
if (earlier === undefined)
|
|
705
|
+
return cleanup;
|
|
706
|
+
return new PiAuthCleanupError(cleanup.paths, `${cleanup.detail}; the preceding provider operation also failed: ${earlier instanceof Error ? earlier.message : String(earlier)}`);
|
|
707
|
+
}
|
|
708
|
+
function assertOwnerOnlyPersistenceSupported(platform) {
|
|
709
|
+
if (platform === "win32") {
|
|
710
|
+
throw new Error("Automatic Pi credential persistence is unavailable because owner-only file permissions cannot be verified on Windows. Complete authentication manually and rerun validation.");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function withPiAuthFileLock(authPath, task, hooks = {}, staleRepairAttempted = false) {
|
|
714
|
+
await mkdir(dirname(authPath), { recursive: true, mode: 0o700 });
|
|
715
|
+
const canonicalParent = await realpath(dirname(authPath));
|
|
716
|
+
const ownerUid = currentProcessUidForPi(canonicalParent);
|
|
717
|
+
await assertSafePiAuthParent(canonicalParent, ownerUid);
|
|
718
|
+
const canonicalAuthPath = join(canonicalParent, basename(authPath));
|
|
719
|
+
await assertPiAuthPathOutsideGitWorktree(canonicalAuthPath);
|
|
720
|
+
const lockPath = `${canonicalAuthPath}.mono-agent.lock`;
|
|
721
|
+
const token = randomUUID();
|
|
722
|
+
const contents = Buffer.from(`${JSON.stringify({
|
|
723
|
+
version: 1,
|
|
724
|
+
pid: process.pid,
|
|
725
|
+
createdAt: new Date().toISOString(),
|
|
726
|
+
ownerUid,
|
|
727
|
+
token,
|
|
728
|
+
})}\n`, "utf8");
|
|
729
|
+
let handle;
|
|
730
|
+
let identity;
|
|
731
|
+
try {
|
|
732
|
+
handle = await open(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
733
|
+
const initialStat = await handle.stat();
|
|
734
|
+
identity = { dev: initialStat.dev, ino: initialStat.ino, ownerUid };
|
|
735
|
+
assertPiAuthLockStat(lockPath, initialStat, ownerUid);
|
|
736
|
+
await handle.writeFile(contents);
|
|
737
|
+
await handle.sync();
|
|
738
|
+
const writtenStat = await handle.stat();
|
|
739
|
+
assertPiAuthLockStat(lockPath, writtenStat, ownerUid);
|
|
740
|
+
if (writtenStat.dev !== initialStat.dev ||
|
|
741
|
+
writtenStat.ino !== initialStat.ino ||
|
|
742
|
+
writtenStat.size !== contents.length) {
|
|
743
|
+
throw new Error(`Pi credential lock ${lockPath} changed while its owner record was written.`);
|
|
744
|
+
}
|
|
745
|
+
await syncDirectory(canonicalParent);
|
|
746
|
+
await hooks.afterPiAuthLockCreated?.(lockPath);
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
let rollbackError;
|
|
750
|
+
if (handle !== undefined) {
|
|
751
|
+
try {
|
|
752
|
+
await handle.close();
|
|
753
|
+
}
|
|
754
|
+
catch (closeError) {
|
|
755
|
+
rollbackError = new PiAuthCleanupError(lockPath, `the failed lock-creation handle could not be confirmed closed: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
756
|
+
}
|
|
757
|
+
if (identity !== undefined) {
|
|
758
|
+
try {
|
|
759
|
+
await removePiAuthLockIfIdentity(lockPath, identity, hooks);
|
|
760
|
+
}
|
|
761
|
+
catch (removeError) {
|
|
762
|
+
rollbackError ??= removeError;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
rollbackError ??= new PiAuthCleanupError(lockPath, "the failed lock-creation artifact could not be identified for safe removal");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (rollbackError !== undefined) {
|
|
770
|
+
throw combinePiAuthCleanupError(error, rollbackError instanceof PiAuthCleanupError
|
|
771
|
+
? rollbackError
|
|
772
|
+
: new PiAuthCleanupError(lockPath, String(rollbackError)));
|
|
773
|
+
}
|
|
774
|
+
if (isAlreadyExistsError(error)) {
|
|
775
|
+
if (!staleRepairAttempted) {
|
|
776
|
+
const repair = await repairStalePiAuthLock(lockPath, ownerUid, {
|
|
777
|
+
...(hooks.beforeStalePiAuthLockRemoval === undefined
|
|
778
|
+
? {}
|
|
779
|
+
: { beforeRemoval: hooks.beforeStalePiAuthLockRemoval }),
|
|
780
|
+
...(hooks.afterStalePiAuthLockRemoval === undefined
|
|
781
|
+
? {}
|
|
782
|
+
: { afterRemoval: hooks.afterStalePiAuthLockRemoval }),
|
|
783
|
+
});
|
|
784
|
+
if (repair === "removed") {
|
|
785
|
+
return await withPiAuthFileLock(authPath, task, hooks, true);
|
|
786
|
+
}
|
|
787
|
+
if (repair === "active") {
|
|
788
|
+
throw new Error(`Pi credential lock ${lockPath} already exists and belongs to an active authentication process. Wait for it to finish, then retry.`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
throw new Error(`Pi credential lock ${lockPath} already exists and could not be proven stale. It was left untouched; inspect its owner and retry after the active authentication exits.`);
|
|
792
|
+
}
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
if (handle === undefined || identity === undefined) {
|
|
796
|
+
throw new Error(`Pi credential lock ${lockPath} could not be established.`);
|
|
797
|
+
}
|
|
798
|
+
const lock = {
|
|
799
|
+
path: lockPath,
|
|
800
|
+
handle,
|
|
801
|
+
ownerUid,
|
|
802
|
+
dev: identity.dev,
|
|
803
|
+
ino: identity.ino,
|
|
804
|
+
token,
|
|
805
|
+
contents,
|
|
806
|
+
};
|
|
807
|
+
let taskError;
|
|
808
|
+
try {
|
|
809
|
+
return await task(canonicalAuthPath, ownerUid, () => assertPiAuthLockHeld(lock));
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
taskError = error;
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
finally {
|
|
816
|
+
let lifecycleError;
|
|
817
|
+
try {
|
|
818
|
+
await lock.handle.close();
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
lifecycleError = new PiAuthCleanupError(lock.path, `the lock handle could not be confirmed closed: ${error instanceof Error ? error.message : String(error)}`);
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
await releasePiAuthLock(lock, hooks);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
const releaseError = error instanceof PiAuthCleanupError
|
|
828
|
+
? error
|
|
829
|
+
: new PiAuthCleanupError(lock.path, error instanceof Error ? error.message : String(error));
|
|
830
|
+
lifecycleError = combinePiAuthCleanupError(lifecycleError, releaseError);
|
|
831
|
+
}
|
|
832
|
+
if (lifecycleError !== undefined) {
|
|
833
|
+
throw combinePiAuthCleanupError(taskError, lifecycleError);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function currentProcessUidForPi(path) {
|
|
838
|
+
if (typeof process.getuid !== "function") {
|
|
839
|
+
throw new Error(`Automatic Pi credential persistence cannot verify the current user for ${path}.`);
|
|
840
|
+
}
|
|
841
|
+
return process.getuid();
|
|
842
|
+
}
|
|
843
|
+
async function assertSafePiAuthParent(path, ownerUid) {
|
|
844
|
+
const pathStat = await lstat(path);
|
|
845
|
+
let handle;
|
|
846
|
+
try {
|
|
847
|
+
handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
848
|
+
const handleStat = await handle.stat();
|
|
849
|
+
if (pathStat.isSymbolicLink() ||
|
|
850
|
+
!pathStat.isDirectory() ||
|
|
851
|
+
!handleStat.isDirectory() ||
|
|
852
|
+
pathStat.dev !== handleStat.dev ||
|
|
853
|
+
pathStat.ino !== handleStat.ino ||
|
|
854
|
+
handleStat.uid !== ownerUid ||
|
|
855
|
+
(handleStat.mode & 0o022) !== 0) {
|
|
856
|
+
throw new Error(`Refusing automatic Pi credential persistence because parent directory ${path} must be owned by the current user and not group/world-writable.`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
await handle?.close();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function assertPiAuthLockStat(path, value, ownerUid) {
|
|
864
|
+
if (!value.isFile() ||
|
|
865
|
+
value.uid !== ownerUid ||
|
|
866
|
+
(value.mode & 0o777) !== 0o600 ||
|
|
867
|
+
value.nlink !== 1) {
|
|
868
|
+
throw new Error(`Refusing to use Pi credential lock ${path} because it is not a current-user, owner-only regular file with one link.`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
async function readPiAuthLockPath(path, ownerUid) {
|
|
872
|
+
let pathStat;
|
|
873
|
+
try {
|
|
874
|
+
pathStat = await lstat(path);
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
if (isMissingFileError(error))
|
|
878
|
+
return undefined;
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
assertPiAuthLockStat(path, pathStat, ownerUid);
|
|
882
|
+
let handle;
|
|
883
|
+
try {
|
|
884
|
+
handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
885
|
+
const handleStat = await handle.stat();
|
|
886
|
+
assertPiAuthLockStat(path, handleStat, ownerUid);
|
|
887
|
+
if (pathStat.dev !== handleStat.dev || pathStat.ino !== handleStat.ino) {
|
|
888
|
+
throw new Error(`Pi credential lock ${path} changed identity while it was opened.`);
|
|
889
|
+
}
|
|
890
|
+
const contents = await handle.readFile();
|
|
891
|
+
if (contents.length !== handleStat.size) {
|
|
892
|
+
throw new Error(`Pi credential lock ${path} changed while its contents were read.`);
|
|
893
|
+
}
|
|
894
|
+
if (contents.length > 4096) {
|
|
895
|
+
throw new Error(`Pi credential lock ${path} exceeds the 4096-byte safety limit.`);
|
|
896
|
+
}
|
|
897
|
+
return { dev: handleStat.dev, ino: handleStat.ino, contents };
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
await handle?.close();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Remove only a secure, identity-stable lock whose recorded process is proven
|
|
905
|
+
* gone with ESRCH. Active, EPERM, malformed and racing locks are untouched.
|
|
906
|
+
*
|
|
907
|
+
* @internal Exported as a narrow deterministic test seam.
|
|
908
|
+
*/
|
|
909
|
+
export async function repairStalePiAuthLock(path, ownerUid, options = {}) {
|
|
910
|
+
const kill = options.kill ?? ((pid, signal) => process.kill(pid, signal));
|
|
911
|
+
let initial;
|
|
912
|
+
try {
|
|
913
|
+
initial = await readPiAuthLockPath(path, ownerUid);
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return "unverifiable";
|
|
917
|
+
}
|
|
918
|
+
if (initial === undefined)
|
|
919
|
+
return "unverifiable";
|
|
920
|
+
const record = parseStalePiAuthLockRecord(initial.contents, ownerUid);
|
|
921
|
+
if (record === undefined)
|
|
922
|
+
return "unverifiable";
|
|
923
|
+
const liveness = piLockProcessLiveness(record.pid, kill);
|
|
924
|
+
if (liveness !== "stale")
|
|
925
|
+
return liveness;
|
|
926
|
+
await options.beforeRemoval?.(path);
|
|
927
|
+
let current;
|
|
928
|
+
try {
|
|
929
|
+
current = await readPiAuthLockPath(path, ownerUid);
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
return "unverifiable";
|
|
933
|
+
}
|
|
934
|
+
if (current === undefined
|
|
935
|
+
|| current.dev !== initial.dev
|
|
936
|
+
|| current.ino !== initial.ino
|
|
937
|
+
|| !current.contents.equals(initial.contents)) {
|
|
938
|
+
return "unverifiable";
|
|
939
|
+
}
|
|
940
|
+
const currentRecord = parseStalePiAuthLockRecord(current.contents, ownerUid);
|
|
941
|
+
if (currentRecord === undefined
|
|
942
|
+
|| currentRecord.pid !== record.pid
|
|
943
|
+
|| currentRecord.token !== record.token
|
|
944
|
+
|| piLockProcessLiveness(currentRecord.pid, kill) !== "stale") {
|
|
945
|
+
return "unverifiable";
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
await rm(path);
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
return "unverifiable";
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
await options.afterRemoval?.(path);
|
|
955
|
+
await syncDirectory(dirname(path));
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
throw new PiAuthCleanupError(path, `the proven-stale lock was removed but directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
959
|
+
}
|
|
960
|
+
return "removed";
|
|
961
|
+
}
|
|
962
|
+
function parseStalePiAuthLockRecord(contents, ownerUid) {
|
|
963
|
+
if (contents.length === 0 || contents.length > 4096)
|
|
964
|
+
return undefined;
|
|
965
|
+
let value;
|
|
966
|
+
try {
|
|
967
|
+
value = JSON.parse(contents.toString("utf8"));
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
if (!isRecord(value))
|
|
973
|
+
return undefined;
|
|
974
|
+
if (value.version !== 1
|
|
975
|
+
|| !Number.isSafeInteger(value.pid)
|
|
976
|
+
|| value.pid <= 0
|
|
977
|
+
|| value.ownerUid !== ownerUid
|
|
978
|
+
|| typeof value.token !== "string"
|
|
979
|
+
|| value.token.length < 8
|
|
980
|
+
|| value.token.length > 200) {
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
version: 1,
|
|
985
|
+
pid: value.pid,
|
|
986
|
+
ownerUid,
|
|
987
|
+
token: value.token,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
function piLockProcessLiveness(pid, kill) {
|
|
991
|
+
try {
|
|
992
|
+
kill(pid, 0);
|
|
993
|
+
return "active";
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
const code = isRecord(error) && typeof error.code === "string" ? error.code : "";
|
|
997
|
+
if (code === "ESRCH")
|
|
998
|
+
return "stale";
|
|
999
|
+
if (code === "EPERM")
|
|
1000
|
+
return "active";
|
|
1001
|
+
return "unverifiable";
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async function assertPiAuthLockHeld(lock) {
|
|
1005
|
+
const handleStat = await lock.handle.stat();
|
|
1006
|
+
assertPiAuthLockStat(lock.path, handleStat, lock.ownerUid);
|
|
1007
|
+
const current = await readPiAuthLockPath(lock.path, lock.ownerUid);
|
|
1008
|
+
if (current === undefined ||
|
|
1009
|
+
handleStat.dev !== lock.dev ||
|
|
1010
|
+
handleStat.ino !== lock.ino ||
|
|
1011
|
+
current.dev !== lock.dev ||
|
|
1012
|
+
current.ino !== lock.ino ||
|
|
1013
|
+
!current.contents.equals(lock.contents)) {
|
|
1014
|
+
throw new Error(`Pi credential lock ${lock.path} changed during credential setup; its replacement was left untouched.`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function releasePiAuthLock(lock, hooks) {
|
|
1018
|
+
let current;
|
|
1019
|
+
try {
|
|
1020
|
+
current = await readPiAuthLockPath(lock.path, lock.ownerUid);
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
throw new PiAuthCleanupError(lock.path, error instanceof Error ? error.message : String(error));
|
|
1024
|
+
}
|
|
1025
|
+
if (current === undefined) {
|
|
1026
|
+
await confirmMissingPiAuthLockDurable(lock.path, hooks);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (current.dev !== lock.dev ||
|
|
1030
|
+
current.ino !== lock.ino ||
|
|
1031
|
+
!current.contents.equals(lock.contents)) {
|
|
1032
|
+
throw new PiAuthCleanupError(lock.path, "the lock identity changed; its replacement was left untouched");
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
await rm(lock.path);
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
if (!isMissingFileError(error)) {
|
|
1039
|
+
throw new PiAuthCleanupError(lock.path, error instanceof Error ? error.message : String(error));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
try {
|
|
1043
|
+
await syncDirectory(dirname(lock.path));
|
|
1044
|
+
}
|
|
1045
|
+
catch (error) {
|
|
1046
|
+
throw new PiAuthCleanupError(lock.path, `the lock was removed but directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async function removePiAuthLockIfIdentity(path, expected, hooks) {
|
|
1050
|
+
let current;
|
|
1051
|
+
try {
|
|
1052
|
+
current = await lstat(path);
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
if (isMissingFileError(error)) {
|
|
1056
|
+
await confirmMissingPiAuthLockDurable(path, hooks);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
throw new PiAuthCleanupError(path, error instanceof Error ? error.message : String(error));
|
|
1060
|
+
}
|
|
1061
|
+
if (!current.isFile() ||
|
|
1062
|
+
current.uid !== expected.ownerUid ||
|
|
1063
|
+
current.dev !== expected.dev ||
|
|
1064
|
+
current.ino !== expected.ino ||
|
|
1065
|
+
current.nlink !== 1 ||
|
|
1066
|
+
(current.mode & 0o777) !== 0o600) {
|
|
1067
|
+
throw new PiAuthCleanupError(path, "the failed lock-creation artifact changed identity; its replacement was left untouched");
|
|
1068
|
+
}
|
|
1069
|
+
try {
|
|
1070
|
+
await rm(path);
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
if (!isMissingFileError(error)) {
|
|
1074
|
+
throw new PiAuthCleanupError(path, error instanceof Error ? error.message : String(error));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
await syncDirectory(dirname(path));
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
throw new PiAuthCleanupError(path, `the failed lock-creation artifact was removed but directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
async function confirmMissingPiAuthLockDurable(path, hooks) {
|
|
1085
|
+
try {
|
|
1086
|
+
await hooks.beforePiAuthMissingLockSync?.(path);
|
|
1087
|
+
await syncDirectory(dirname(path));
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
throw new PiAuthCleanupError(path, `the lock path is absent but parent-directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function readPiAuthStore(path, policy) {
|
|
1094
|
+
let handle;
|
|
1095
|
+
try {
|
|
1096
|
+
handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
if (isMissingFileError(error)) {
|
|
1100
|
+
return { exists: false, contents: "", auth: {} };
|
|
1101
|
+
}
|
|
1102
|
+
if (isSymlinkOpenError(error)) {
|
|
1103
|
+
throw new Error(`Refusing to use Pi auth path ${path} because the final path is a symbolic link.`);
|
|
1104
|
+
}
|
|
1105
|
+
throw error;
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const fileStat = await handle.stat();
|
|
1109
|
+
assertPiAuthStoreStat(path, fileStat, policy);
|
|
1110
|
+
const contents = await handle.readFile({ encoding: "utf8" });
|
|
1111
|
+
let parsed;
|
|
1112
|
+
try {
|
|
1113
|
+
parsed = JSON.parse(contents);
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
throw new Error(`Unable to parse Pi auth file ${path}; the original file was left unchanged.`);
|
|
1117
|
+
}
|
|
1118
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1119
|
+
throw new Error(`Pi auth file ${path} must contain a JSON object; the original file was left unchanged.`);
|
|
1120
|
+
}
|
|
1121
|
+
const pathStat = await lstat(path);
|
|
1122
|
+
assertPiAuthStoreStat(path, pathStat, policy);
|
|
1123
|
+
if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
|
|
1124
|
+
throw new Error(`Pi auth file ${path} changed while it was being read; the newer path was preserved.`);
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
exists: true,
|
|
1128
|
+
contents,
|
|
1129
|
+
auth: parsed,
|
|
1130
|
+
dev: fileStat.dev,
|
|
1131
|
+
ino: fileStat.ino,
|
|
1132
|
+
uid: fileStat.uid,
|
|
1133
|
+
nlink: fileStat.nlink,
|
|
1134
|
+
mode: fileStat.mode,
|
|
1135
|
+
size: fileStat.size,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
await handle.close();
|
|
196
1140
|
}
|
|
1141
|
+
}
|
|
1142
|
+
function assertPiAuthStoreStat(path, value, policy) {
|
|
1143
|
+
if (!value.isFile()) {
|
|
1144
|
+
throw new Error(`Refusing to use Pi auth path ${path} because it is not a regular file.`);
|
|
1145
|
+
}
|
|
1146
|
+
if (value.uid !== policy.ownerUid) {
|
|
1147
|
+
throw new Error(`Refusing to use Pi auth path ${path} because it is not owned by the current user.`);
|
|
1148
|
+
}
|
|
1149
|
+
if ((value.mode & 0o022) !== 0) {
|
|
1150
|
+
throw new Error(`Refusing to use Pi auth path ${path} because it is writable by another user.`);
|
|
1151
|
+
}
|
|
1152
|
+
if (!policy.allowedLinkCounts.includes(value.nlink)) {
|
|
1153
|
+
throw new Error(`Refusing to use Pi auth path ${path} because its hard-link identity is unsafe.`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async function writePiAuthStoreAtomically(path, auth, original, ownerUid, assertLockHeld, hooks) {
|
|
1157
|
+
await assertPiAuthStoreUnchanged(path, original, ownerUid);
|
|
1158
|
+
const dir = dirname(path);
|
|
1159
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
1160
|
+
const tempDir = await mkdtemp(join(dir, ".mono-agent-pi-auth-write-"));
|
|
1161
|
+
const tempPath = join(tempDir, "auth.json");
|
|
1162
|
+
let promotionInstalled = false;
|
|
1163
|
+
let operationError;
|
|
1164
|
+
try {
|
|
1165
|
+
await writeFile(tempPath, `${JSON.stringify(auth, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1166
|
+
await syncFile(tempPath);
|
|
1167
|
+
await assertPiAuthStoreUnchanged(path, original, ownerUid);
|
|
1168
|
+
await promotePiAuthStoreWithoutClobber(tempPath, path, original, ownerUid, assertLockHeld, undefined, hooks);
|
|
1169
|
+
promotionInstalled = true;
|
|
1170
|
+
try {
|
|
1171
|
+
await hooks.beforePiAuthPostMutationSync?.(path);
|
|
1172
|
+
await syncDirectory(dir);
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
throw new PiAuthCleanupError(path, `credentials were installed but parent-directory durability could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
operationError = error;
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
finally {
|
|
1183
|
+
try {
|
|
1184
|
+
await hooks.beforePiAuthTempCleanup?.(tempDir);
|
|
1185
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
throw combinePiAuthCleanupError(operationError, new PiAuthCleanupError(tempDir, `${promotionInstalled ? "credentials were installed, and " : ""}the credential-bearing transaction directory could not be removed: ${error instanceof Error ? error.message : String(error)}`));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function assertPiAuthStoreUnchanged(path, original, ownerUid) {
|
|
1193
|
+
const current = await readPiAuthStore(path, piAuthSingleLinkPolicy(ownerUid));
|
|
1194
|
+
if (!samePiAuthStoreSnapshot(current, original)) {
|
|
1195
|
+
throw new Error(`Pi auth file ${path} changed during credential setup; the newer file was preserved.`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function samePiAuthStoreSnapshot(left, right) {
|
|
1199
|
+
if (left.exists !== right.exists)
|
|
1200
|
+
return false;
|
|
1201
|
+
if (!left.exists || !right.exists)
|
|
1202
|
+
return true;
|
|
1203
|
+
return left.dev === right.dev &&
|
|
1204
|
+
left.ino === right.ino &&
|
|
1205
|
+
left.uid === right.uid &&
|
|
1206
|
+
left.nlink === right.nlink &&
|
|
1207
|
+
left.mode === right.mode &&
|
|
1208
|
+
left.size === right.size &&
|
|
1209
|
+
left.contents === right.contents;
|
|
1210
|
+
}
|
|
1211
|
+
async function promotePiAuthStoreWithoutClobber(stagedPath, targetPath, expected, ownerUid, assertLockHeld, intendedInput, hooks = {}) {
|
|
1212
|
+
const intended = intendedInput ?? await readPiAuthStore(stagedPath, piAuthSingleLinkPolicy(ownerUid));
|
|
1213
|
+
if (!intended.exists) {
|
|
1214
|
+
throw new Error(`Pi auth staging file ${stagedPath} disappeared before promotion.`);
|
|
1215
|
+
}
|
|
1216
|
+
if (!expected.exists) {
|
|
1217
|
+
let installed = false;
|
|
1218
|
+
try {
|
|
1219
|
+
await hooks.beforePiAuthPromotion?.(targetPath, stagedPath);
|
|
1220
|
+
await assertLockHeld();
|
|
1221
|
+
installed = await linkPiFileIfAbsent(stagedPath, targetPath);
|
|
1222
|
+
if (!installed) {
|
|
1223
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1224
|
+
}
|
|
1225
|
+
await hooks.afterPiAuthLink?.(targetPath, stagedPath);
|
|
1226
|
+
await assertPromotedPiAuthStore(intended, stagedPath, targetPath, ownerUid);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
if (installed && !(error instanceof PiAuthCleanupError)) {
|
|
1231
|
+
throw new PiAuthCleanupError(targetPath, `new credentials were linked but their final state could not be confirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1232
|
+
}
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const backupPath = join(dirname(targetPath), `.${basename(targetPath)}.mono-agent-${randomUUID()}.backup`);
|
|
1237
|
+
let preserveConcurrentBackup = true;
|
|
1238
|
+
let backupCreated = false;
|
|
1239
|
+
let targetMutationStarted = false;
|
|
1240
|
+
let promotionError;
|
|
1241
|
+
try {
|
|
1242
|
+
await hooks.beforePiAuthPromotion?.(targetPath, stagedPath);
|
|
1243
|
+
await assertLockHeld();
|
|
1244
|
+
try {
|
|
1245
|
+
await rename(targetPath, backupPath);
|
|
1246
|
+
backupCreated = true;
|
|
1247
|
+
targetMutationStarted = true;
|
|
1248
|
+
}
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
if (error.code === "ENOENT") {
|
|
1251
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1252
|
+
}
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
let moved;
|
|
1256
|
+
try {
|
|
1257
|
+
moved = await readPiAuthStore(backupPath, piAuthSingleLinkPolicy(ownerUid));
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
if (await linkPiFileIfAbsent(backupPath, targetPath)) {
|
|
1261
|
+
await rm(backupPath, { force: true });
|
|
1262
|
+
preserveConcurrentBackup = false;
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1266
|
+
}
|
|
1267
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1268
|
+
}
|
|
1269
|
+
if (!samePiAuthStoreSnapshotAfterMove(moved, expected)) {
|
|
1270
|
+
if (await linkPiFileIfAbsent(backupPath, targetPath)) {
|
|
1271
|
+
await rm(backupPath, { force: true });
|
|
1272
|
+
preserveConcurrentBackup = false;
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1276
|
+
}
|
|
1277
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1278
|
+
}
|
|
1279
|
+
if (!await claimedPiAuthBackupStillMatches(backupPath, expected, ownerUid)) {
|
|
1280
|
+
try {
|
|
1281
|
+
if (await linkPiFileIfAbsent(backupPath, targetPath)) {
|
|
1282
|
+
preserveConcurrentBackup = false;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1290
|
+
throw new Error(`Pi auth promotion failed; credentials were retained at ${backupPath}.`);
|
|
1291
|
+
}
|
|
1292
|
+
throw new Error(preserveConcurrentBackup
|
|
1293
|
+
? `Pi auth promotion failed; concurrent credentials were retained at ${backupPath}.`
|
|
1294
|
+
: `Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1295
|
+
}
|
|
1296
|
+
let installed;
|
|
1297
|
+
try {
|
|
1298
|
+
installed = await linkPiFileIfAbsent(stagedPath, targetPath);
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1302
|
+
throw new Error(`Pi auth promotion failed; the original credentials were retained at ${backupPath}.`);
|
|
1303
|
+
}
|
|
1304
|
+
if (!installed) {
|
|
1305
|
+
preserveConcurrentBackup = false;
|
|
1306
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
await hooks.afterPiAuthLink?.(targetPath, stagedPath);
|
|
1310
|
+
await assertPromotedPiAuthStore(intended, stagedPath, targetPath, ownerUid);
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
const backupStillExpected = await claimedPiAuthBackupStillMatches(backupPath, expected, ownerUid);
|
|
1314
|
+
try {
|
|
1315
|
+
const restored = await linkPiFileIfAbsent(backupPath, targetPath);
|
|
1316
|
+
if (restored || backupStillExpected) {
|
|
1317
|
+
preserveConcurrentBackup = false;
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1325
|
+
throw new Error(`Pi auth promotion failed; credentials were retained at ${backupPath}.`);
|
|
1326
|
+
}
|
|
1327
|
+
throw new Error(preserveConcurrentBackup
|
|
1328
|
+
? `Pi auth promotion failed; concurrent credentials were retained at ${backupPath}.`
|
|
1329
|
+
: `Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1330
|
+
}
|
|
1331
|
+
if (!await claimedPiAuthBackupStillMatches(backupPath, expected, ownerUid)) {
|
|
1332
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1333
|
+
throw new Error(`Pi auth promotion failed; concurrent credentials were retained at ${backupPath}.`);
|
|
1334
|
+
}
|
|
1335
|
+
preserveConcurrentBackup = false;
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
promotionError = targetMutationStarted && !(error instanceof PiAuthCleanupError)
|
|
1339
|
+
? new PiAuthCleanupError(preserveConcurrentBackup ? backupPath : targetPath, `credential promotion changed filesystem state but did not complete cleanly: ${error instanceof Error ? error.message : String(error)}`)
|
|
1340
|
+
: error;
|
|
1341
|
+
throw promotionError;
|
|
1342
|
+
}
|
|
1343
|
+
finally {
|
|
1344
|
+
if (backupCreated && preserveConcurrentBackup) {
|
|
1345
|
+
try {
|
|
1346
|
+
await tightenPiFileOwnerOnlyBestEffort(backupPath);
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
// The fatal result below remains authoritative and names the residue.
|
|
1350
|
+
}
|
|
1351
|
+
throw combinePiAuthCleanupError(promotionError, new PiAuthCleanupError(backupPath, `concurrent credentials were retained at ${backupPath} for manual recovery`));
|
|
1352
|
+
}
|
|
1353
|
+
if (backupCreated) {
|
|
1354
|
+
try {
|
|
1355
|
+
await hooks.beforePiAuthBackupCleanup?.(backupPath);
|
|
1356
|
+
await rm(backupPath, { force: true });
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
throw combinePiAuthCleanupError(promotionError, new PiAuthCleanupError(backupPath, `credential backup could not be removed: ${error instanceof Error ? error.message : String(error)}`));
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
async function claimedPiAuthBackupStillMatches(backupPath, expected, ownerUid) {
|
|
1365
|
+
try {
|
|
1366
|
+
const current = await readPiAuthStore(backupPath, piAuthSingleLinkPolicy(ownerUid));
|
|
1367
|
+
return samePiAuthStoreSnapshotAfterMove(current, expected);
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
function samePiAuthStoreSnapshotAfterMove(current, expected) {
|
|
1374
|
+
return current.exists && expected.exists &&
|
|
1375
|
+
current.dev === expected.dev &&
|
|
1376
|
+
current.ino === expected.ino &&
|
|
1377
|
+
current.uid === expected.uid &&
|
|
1378
|
+
current.nlink === expected.nlink &&
|
|
1379
|
+
current.mode === expected.mode &&
|
|
1380
|
+
current.size === expected.size &&
|
|
1381
|
+
current.contents === expected.contents;
|
|
1382
|
+
}
|
|
1383
|
+
async function linkPiFileIfAbsent(source, target) {
|
|
1384
|
+
try {
|
|
1385
|
+
await link(source, target);
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
if (error.code === "EEXIST")
|
|
1390
|
+
return false;
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
async function assertPromotedPiAuthStore(intended, stagedPath, targetPath, ownerUid) {
|
|
1395
|
+
// Exclusive-link installation deliberately gives the same inode two names
|
|
1396
|
+
// until the private staging directory is removed. Exactly two links prove
|
|
1397
|
+
// that no third alias can retain or mutate credential bytes after cleanup.
|
|
1398
|
+
const twoLinkPolicy = { ownerUid, allowedLinkCounts: [2] };
|
|
1399
|
+
const staged = await readPiAuthStore(stagedPath, twoLinkPolicy);
|
|
1400
|
+
const promoted = await readPiAuthStore(targetPath, twoLinkPolicy);
|
|
1401
|
+
if (!intended.exists ||
|
|
1402
|
+
!staged.exists ||
|
|
1403
|
+
!promoted.exists ||
|
|
1404
|
+
intended.uid !== ownerUid ||
|
|
1405
|
+
intended.nlink !== 1 ||
|
|
1406
|
+
staged.dev !== intended.dev ||
|
|
1407
|
+
staged.ino !== intended.ino ||
|
|
1408
|
+
promoted.dev !== intended.dev ||
|
|
1409
|
+
promoted.ino !== intended.ino ||
|
|
1410
|
+
staged.contents !== intended.contents ||
|
|
1411
|
+
intended.contents !== promoted.contents ||
|
|
1412
|
+
((promoted.mode ?? 0) & 0o777) !== 0o600) {
|
|
1413
|
+
throw new Error(`Pi auth file ${targetPath} changed during credential setup; the newer file was preserved.`);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
function piAuthSingleLinkPolicy(ownerUid) {
|
|
1417
|
+
return { ownerUid, allowedLinkCounts: [1] };
|
|
1418
|
+
}
|
|
1419
|
+
async function tightenPiFileOwnerOnlyBestEffort(path) {
|
|
1420
|
+
let handle;
|
|
1421
|
+
try {
|
|
1422
|
+
handle = await open(path, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
1423
|
+
if ((await handle.stat()).isFile())
|
|
1424
|
+
await handle.chmod(0o600);
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// Preserve recovery bytes even if their metadata cannot be tightened.
|
|
1428
|
+
}
|
|
1429
|
+
finally {
|
|
1430
|
+
await handle?.close();
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
async function assertPiAuthPathOutsideGitWorktree(path) {
|
|
1434
|
+
const result = await runGitForPi(["-C", dirname(path), "rev-parse", "--show-toplevel"]);
|
|
1435
|
+
if (result.ok) {
|
|
1436
|
+
throw new Error(`Refusing automatic Pi credential persistence inside Git worktree ${result.stdout.trim()}. Choose providers.piAuthPath outside the repository.`);
|
|
1437
|
+
}
|
|
1438
|
+
if (await hasGitMetadataForPi(dirname(path))) {
|
|
1439
|
+
throw new Error(`Cannot prove Git safety for Pi auth path ${path}; choose a credential path outside the repository.`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
function runGitForPi(args) {
|
|
1443
|
+
return new Promise((resolveResult) => {
|
|
1444
|
+
execFile("git", [...args], { encoding: "utf8" }, (error, stdout) => {
|
|
1445
|
+
resolveResult({ ok: error === null, stdout });
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
async function hasGitMetadataForPi(start) {
|
|
1450
|
+
let current = resolve(start);
|
|
1451
|
+
while (true) {
|
|
1452
|
+
try {
|
|
1453
|
+
await lstat(join(current, ".git"));
|
|
1454
|
+
return true;
|
|
1455
|
+
}
|
|
1456
|
+
catch (error) {
|
|
1457
|
+
if (error.code !== "ENOENT")
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
const parent = dirname(current);
|
|
1461
|
+
if (parent === current)
|
|
1462
|
+
return false;
|
|
1463
|
+
current = parent;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function assertOAuthCredential(value, provider) {
|
|
1467
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1468
|
+
throw new Error(`Bundled Pi login did not produce credentials for ${provider}; the configured store was not changed.`);
|
|
1469
|
+
}
|
|
1470
|
+
const credential = value;
|
|
1471
|
+
const access = typeof credential.access === "string" ? credential.access.trim() : "";
|
|
1472
|
+
const refresh = typeof credential.refresh === "string" ? credential.refresh.trim() : "";
|
|
1473
|
+
if (credential.type !== "oauth" || (access.length === 0 && refresh.length === 0)) {
|
|
1474
|
+
throw new Error(`Bundled Pi login produced invalid OAuth credentials for ${provider}; the configured store was not changed.`);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function assertUnchangedSiblingCredentials(original, staged, provider) {
|
|
1478
|
+
for (const [name, credential] of Object.entries(original)) {
|
|
1479
|
+
if (name !== provider && !isDeepStrictEqual(staged[name], credential)) {
|
|
1480
|
+
throw new Error(`Bundled Pi login unexpectedly changed sibling provider ${name}; the configured store was not changed.`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
for (const name of Object.keys(staged)) {
|
|
1484
|
+
if (name !== provider && !Object.hasOwn(original, name)) {
|
|
1485
|
+
throw new Error(`Bundled Pi login unexpectedly added sibling provider ${name}; the configured store was not changed.`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
async function syncFile(path) {
|
|
1490
|
+
const handle = await open(path, "r+");
|
|
1491
|
+
try {
|
|
1492
|
+
await handle.sync();
|
|
1493
|
+
}
|
|
1494
|
+
finally {
|
|
1495
|
+
await handle.close();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
async function syncDirectory(path) {
|
|
1499
|
+
const handle = await open(path, "r");
|
|
1500
|
+
try {
|
|
1501
|
+
await handle.sync();
|
|
1502
|
+
}
|
|
1503
|
+
finally {
|
|
1504
|
+
await handle.close();
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
function isMissingFileError(error) {
|
|
1508
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1509
|
+
}
|
|
1510
|
+
function isAlreadyExistsError(error) {
|
|
1511
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
1512
|
+
}
|
|
1513
|
+
function isSymlinkOpenError(error) {
|
|
1514
|
+
return typeof error === "object" && error !== null && "code" in error && (error.code === "ELOOP" || error.code === "EMLINK");
|
|
1515
|
+
}
|
|
1516
|
+
function isRecord(value) {
|
|
1517
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1518
|
+
}
|
|
1519
|
+
function shellQuote(value) {
|
|
1520
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) {
|
|
1521
|
+
return value;
|
|
1522
|
+
}
|
|
1523
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1524
|
+
}
|
|
1525
|
+
async function runSpawnedCommand(action, spawnImpl, cwd, preflightTimeoutMs = DEFAULT_PROVIDER_PREFLIGHT_TIMEOUT_MS, abortSignal) {
|
|
1526
|
+
const [file, ...args] = action.command;
|
|
197
1527
|
return new Promise((resolve) => {
|
|
198
1528
|
const child = spawnImpl(file, args, {
|
|
199
|
-
cwd
|
|
1529
|
+
cwd,
|
|
200
1530
|
env: process.env,
|
|
201
1531
|
stdio: "inherit",
|
|
202
1532
|
});
|
|
1533
|
+
let settled = false;
|
|
1534
|
+
let timer;
|
|
1535
|
+
let forceKillTimer;
|
|
1536
|
+
let killSettlementTimer;
|
|
1537
|
+
let childError;
|
|
1538
|
+
let terminationResult;
|
|
1539
|
+
const interruptedDetail = `${providerSetupActionCommandLine(action)} was interrupted.`;
|
|
1540
|
+
const finish = (result) => {
|
|
1541
|
+
if (settled)
|
|
1542
|
+
return;
|
|
1543
|
+
settled = true;
|
|
1544
|
+
if (timer !== undefined)
|
|
1545
|
+
clearTimeout(timer);
|
|
1546
|
+
if (forceKillTimer !== undefined)
|
|
1547
|
+
clearTimeout(forceKillTimer);
|
|
1548
|
+
if (killSettlementTimer !== undefined)
|
|
1549
|
+
clearTimeout(killSettlementTimer);
|
|
1550
|
+
abortSignal?.removeEventListener("abort", abort);
|
|
1551
|
+
resolve(result);
|
|
1552
|
+
};
|
|
1553
|
+
const terminate = (result) => {
|
|
1554
|
+
if (settled || terminationResult !== undefined)
|
|
1555
|
+
return;
|
|
1556
|
+
terminationResult = result;
|
|
1557
|
+
try {
|
|
1558
|
+
child.kill("SIGTERM");
|
|
1559
|
+
}
|
|
1560
|
+
catch { /* close/error remains authoritative */ }
|
|
1561
|
+
forceKillTimer = setTimeout(() => {
|
|
1562
|
+
if (settled)
|
|
1563
|
+
return;
|
|
1564
|
+
try {
|
|
1565
|
+
child.kill("SIGKILL");
|
|
1566
|
+
}
|
|
1567
|
+
catch { /* close/error remains authoritative */ }
|
|
1568
|
+
killSettlementTimer = setTimeout(() => {
|
|
1569
|
+
child.unref?.();
|
|
1570
|
+
finish({
|
|
1571
|
+
action,
|
|
1572
|
+
status: "failed",
|
|
1573
|
+
failureKind: "child_exit_unconfirmed",
|
|
1574
|
+
detail: `${result.detail.replace(/[.]$/u, "")}; child exit could not be confirmed after SIGKILL. ` +
|
|
1575
|
+
"Stop the provider process manually before retrying provider setup.",
|
|
1576
|
+
});
|
|
1577
|
+
}, action.kind === "preflight" ? PROVIDER_PREFLIGHT_KILL_SETTLE_MS : PROVIDER_AUTH_KILL_SETTLE_MS);
|
|
1578
|
+
killSettlementTimer.unref?.();
|
|
1579
|
+
}, action.kind === "preflight" ? PROVIDER_PREFLIGHT_TERM_GRACE_MS : PROVIDER_AUTH_TERM_GRACE_MS);
|
|
1580
|
+
forceKillTimer.unref?.();
|
|
1581
|
+
};
|
|
1582
|
+
const abort = () => terminate({ action, status: "failed", detail: interruptedDetail });
|
|
203
1583
|
child.once("error", (error) => {
|
|
204
|
-
|
|
1584
|
+
childError = error;
|
|
1585
|
+
// A ChildProcess can emit `error` because signaling an already-running
|
|
1586
|
+
// process failed (for example EPERM). That is not proof of exit: retain
|
|
1587
|
+
// the TERM→KILL escalation and wait for `close`. A true spawn failure has
|
|
1588
|
+
// no pid and can settle immediately when no cancellation is in flight.
|
|
1589
|
+
if (terminationResult === undefined && child.pid === undefined) {
|
|
1590
|
+
finish({ action, status: "failed", detail: error.message });
|
|
1591
|
+
}
|
|
205
1592
|
});
|
|
206
1593
|
child.once("close", (code, signal) => {
|
|
1594
|
+
if (terminationResult !== undefined) {
|
|
1595
|
+
finish(terminationResult);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
207
1598
|
if (code === 0) {
|
|
208
|
-
|
|
1599
|
+
finish({ action, status: "ok", detail: `${providerSetupActionCommandLine(action)} exited 0.` });
|
|
209
1600
|
return;
|
|
210
1601
|
}
|
|
211
|
-
|
|
1602
|
+
finish({
|
|
212
1603
|
action,
|
|
213
1604
|
status: "failed",
|
|
214
|
-
detail: signal === null
|
|
1605
|
+
detail: childError?.message ?? (signal === null
|
|
215
1606
|
? `${providerSetupActionCommandLine(action)} exited ${code ?? "unknown"}.`
|
|
216
|
-
: `${providerSetupActionCommandLine(action)} terminated by ${signal}
|
|
1607
|
+
: `${providerSetupActionCommandLine(action)} terminated by ${signal}.`),
|
|
217
1608
|
});
|
|
218
1609
|
});
|
|
1610
|
+
if (action.kind === "preflight") {
|
|
1611
|
+
timer = setTimeout(() => {
|
|
1612
|
+
const detail = `${providerSetupActionCommandLine(action)} timed out after ${preflightTimeoutMs}ms.`;
|
|
1613
|
+
terminate({ action, status: "failed", detail });
|
|
1614
|
+
}, preflightTimeoutMs);
|
|
1615
|
+
timer.unref?.();
|
|
1616
|
+
}
|
|
1617
|
+
abortSignal?.addEventListener("abort", abort, { once: true });
|
|
1618
|
+
if (abortSignal?.aborted === true)
|
|
1619
|
+
abort();
|
|
219
1620
|
});
|
|
220
1621
|
}
|
|
221
|
-
async function runHttpAction(action, fetchImpl) {
|
|
1622
|
+
async function runHttpAction(action, fetchImpl, preflightTimeoutMs, abortSignal) {
|
|
1623
|
+
const controller = new AbortController();
|
|
1624
|
+
let timer;
|
|
1625
|
+
const interrupted = Symbol("provider-preflight-interrupted");
|
|
1626
|
+
let resolveInterrupted;
|
|
1627
|
+
const interruptedPromise = new Promise((resolve) => {
|
|
1628
|
+
resolveInterrupted = resolve;
|
|
1629
|
+
});
|
|
1630
|
+
const onAbort = () => {
|
|
1631
|
+
resolveInterrupted?.(interrupted);
|
|
1632
|
+
controller.abort();
|
|
1633
|
+
};
|
|
1634
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
222
1635
|
try {
|
|
223
|
-
|
|
1636
|
+
if (abortSignal?.aborted === true)
|
|
1637
|
+
onAbort();
|
|
1638
|
+
const request = Promise.resolve(fetchImpl(action.url, { signal: controller.signal }));
|
|
1639
|
+
// An injected/custom fetch may ignore AbortSignal, so the race itself is the
|
|
1640
|
+
// bounded contract. Observe the original promise to avoid late rejections.
|
|
1641
|
+
void request.catch(() => undefined);
|
|
1642
|
+
const timedOut = Symbol("provider-preflight-timeout");
|
|
1643
|
+
const response = action.kind === "preflight"
|
|
1644
|
+
? await Promise.race([
|
|
1645
|
+
request,
|
|
1646
|
+
interruptedPromise,
|
|
1647
|
+
new Promise((resolveTimeout) => {
|
|
1648
|
+
timer = setTimeout(() => {
|
|
1649
|
+
resolveTimeout(timedOut);
|
|
1650
|
+
controller.abort();
|
|
1651
|
+
}, preflightTimeoutMs);
|
|
1652
|
+
timer.unref?.();
|
|
1653
|
+
}),
|
|
1654
|
+
])
|
|
1655
|
+
: await Promise.race([request, interruptedPromise]);
|
|
1656
|
+
if (response === interrupted) {
|
|
1657
|
+
return {
|
|
1658
|
+
action,
|
|
1659
|
+
status: "failed",
|
|
1660
|
+
detail: `${providerSetupActionCommandLine(action)} was interrupted.`,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
if (response === timedOut) {
|
|
1664
|
+
return {
|
|
1665
|
+
action,
|
|
1666
|
+
status: "failed",
|
|
1667
|
+
detail: `GET ${action.url} timed out after ${preflightTimeoutMs}ms.`,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
224
1670
|
if (response.ok) {
|
|
225
1671
|
return { action, status: "ok", detail: `GET ${action.url} returned ${response.status}.` };
|
|
226
1672
|
}
|
|
@@ -233,5 +1679,15 @@ async function runHttpAction(action, fetchImpl) {
|
|
|
233
1679
|
detail: error instanceof Error ? error.message : String(error),
|
|
234
1680
|
};
|
|
235
1681
|
}
|
|
1682
|
+
finally {
|
|
1683
|
+
if (timer !== undefined)
|
|
1684
|
+
clearTimeout(timer);
|
|
1685
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function positivePreflightTimeout(value) {
|
|
1689
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
1690
|
+
? value
|
|
1691
|
+
: DEFAULT_PROVIDER_PREFLIGHT_TIMEOUT_MS;
|
|
236
1692
|
}
|
|
237
1693
|
//# sourceMappingURL=provider-setup.js.map
|