@mono-agent/agent-app 0.6.1 → 0.8.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.
Files changed (211) hide show
  1. package/README.md +210 -17
  2. package/dist/adapter-send-proxy.d.ts +18 -0
  3. package/dist/adapter-send-proxy.d.ts.map +1 -0
  4. package/dist/adapter-send-proxy.js +229 -0
  5. package/dist/adapter-send-proxy.js.map +1 -0
  6. package/dist/adapter-send-tools-main.js +62 -4
  7. package/dist/adapter-send-tools-main.js.map +1 -1
  8. package/dist/adapter-send-tools.d.ts +41 -8
  9. package/dist/adapter-send-tools.d.ts.map +1 -1
  10. package/dist/adapter-send-tools.js +563 -108
  11. package/dist/adapter-send-tools.js.map +1 -1
  12. package/dist/app-config.d.ts.map +1 -1
  13. package/dist/app-config.js +8 -0
  14. package/dist/app-config.js.map +1 -1
  15. package/dist/app.d.ts +2 -0
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +532 -134
  18. package/dist/app.js.map +1 -1
  19. package/dist/broadcast-recorder.d.ts.map +1 -1
  20. package/dist/broadcast-recorder.js +24 -2
  21. package/dist/broadcast-recorder.js.map +1 -1
  22. package/dist/channels.d.ts.map +1 -1
  23. package/dist/channels.js +18 -7
  24. package/dist/channels.js.map +1 -1
  25. package/dist/cli.d.ts +77 -4
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +2106 -99
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config-reference.d.ts.map +1 -1
  30. package/dist/config-reference.js +195 -6
  31. package/dist/config-reference.js.map +1 -1
  32. package/dist/configuration-proposal-main.d.ts +3 -0
  33. package/dist/configuration-proposal-main.d.ts.map +1 -0
  34. package/dist/configuration-proposal-main.js +13 -0
  35. package/dist/configuration-proposal-main.js.map +1 -0
  36. package/dist/configuration-proposal-tool.d.ts +26 -0
  37. package/dist/configuration-proposal-tool.d.ts.map +1 -0
  38. package/dist/configuration-proposal-tool.js +139 -0
  39. package/dist/configuration-proposal-tool.js.map +1 -0
  40. package/dist/configured-agent.d.ts +12 -0
  41. package/dist/configured-agent.d.ts.map +1 -1
  42. package/dist/configured-agent.js +150 -18
  43. package/dist/configured-agent.js.map +1 -1
  44. package/dist/consumer-contract.d.ts +3 -0
  45. package/dist/consumer-contract.d.ts.map +1 -1
  46. package/dist/consumer-contract.js +44 -1
  47. package/dist/consumer-contract.js.map +1 -1
  48. package/dist/doctor.d.ts +27 -5
  49. package/dist/doctor.d.ts.map +1 -1
  50. package/dist/doctor.js +1198 -96
  51. package/dist/doctor.js.map +1 -1
  52. package/dist/first-run-managed-memory.d.ts +39 -0
  53. package/dist/first-run-managed-memory.d.ts.map +1 -0
  54. package/dist/first-run-managed-memory.js +521 -0
  55. package/dist/first-run-managed-memory.js.map +1 -0
  56. package/dist/first-run-readiness.d.ts +111 -0
  57. package/dist/first-run-readiness.d.ts.map +1 -0
  58. package/dist/first-run-readiness.js +757 -0
  59. package/dist/first-run-readiness.js.map +1 -0
  60. package/dist/index.d.ts +8 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/init.d.ts +88 -3
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +1212 -23
  67. package/dist/init.js.map +1 -1
  68. package/dist/interaction-bridge.d.ts +32 -2
  69. package/dist/interaction-bridge.d.ts.map +1 -1
  70. package/dist/interaction-bridge.js +309 -28
  71. package/dist/interaction-bridge.js.map +1 -1
  72. package/dist/local-configuration.d.ts +56 -0
  73. package/dist/local-configuration.d.ts.map +1 -0
  74. package/dist/local-configuration.js +914 -0
  75. package/dist/local-configuration.js.map +1 -0
  76. package/dist/memory-command.d.ts +4 -0
  77. package/dist/memory-command.d.ts.map +1 -1
  78. package/dist/memory-command.js +751 -27
  79. package/dist/memory-command.js.map +1 -1
  80. package/dist/memory-recall.d.ts +38 -10
  81. package/dist/memory-recall.d.ts.map +1 -1
  82. package/dist/memory-recall.js +82 -25
  83. package/dist/memory-recall.js.map +1 -1
  84. package/dist/memory-retrieval.d.ts +66 -0
  85. package/dist/memory-retrieval.d.ts.map +1 -0
  86. package/dist/memory-retrieval.js +332 -0
  87. package/dist/memory-retrieval.js.map +1 -0
  88. package/dist/modules/base.d.ts +6 -5
  89. package/dist/modules/base.d.ts.map +1 -1
  90. package/dist/modules/base.js +11 -7
  91. package/dist/modules/base.js.map +1 -1
  92. package/dist/modules/catalog.d.ts.map +1 -1
  93. package/dist/modules/catalog.js +54 -20
  94. package/dist/modules/catalog.js.map +1 -1
  95. package/dist/modules/index.d.ts +2 -2
  96. package/dist/modules/index.d.ts.map +1 -1
  97. package/dist/modules/index.js +1 -1
  98. package/dist/modules/index.js.map +1 -1
  99. package/dist/modules/known-tools.d.ts +3 -0
  100. package/dist/modules/known-tools.d.ts.map +1 -1
  101. package/dist/modules/known-tools.js +11 -1
  102. package/dist/modules/known-tools.js.map +1 -1
  103. package/dist/modules/types.d.ts +20 -6
  104. package/dist/modules/types.d.ts.map +1 -1
  105. package/dist/modules/types.js.map +1 -1
  106. package/dist/package-version.d.ts +3 -0
  107. package/dist/package-version.d.ts.map +1 -0
  108. package/dist/package-version.js +14 -0
  109. package/dist/package-version.js.map +1 -0
  110. package/dist/pi-auth-store-inspection.d.ts +21 -0
  111. package/dist/pi-auth-store-inspection.d.ts.map +1 -0
  112. package/dist/pi-auth-store-inspection.js +120 -0
  113. package/dist/pi-auth-store-inspection.js.map +1 -0
  114. package/dist/pi-oauth-login-main.d.ts +3 -0
  115. package/dist/pi-oauth-login-main.d.ts.map +1 -0
  116. package/dist/pi-oauth-login-main.js +14 -0
  117. package/dist/pi-oauth-login-main.js.map +1 -0
  118. package/dist/pi-oauth-login.d.ts +22 -0
  119. package/dist/pi-oauth-login.d.ts.map +1 -0
  120. package/dist/pi-oauth-login.js +79 -0
  121. package/dist/pi-oauth-login.js.map +1 -0
  122. package/dist/project-skills.d.ts +40 -0
  123. package/dist/project-skills.d.ts.map +1 -0
  124. package/dist/project-skills.js +573 -0
  125. package/dist/project-skills.js.map +1 -0
  126. package/dist/provider-setup.d.ts +136 -5
  127. package/dist/provider-setup.d.ts.map +1 -1
  128. package/dist/provider-setup.js +1552 -33
  129. package/dist/provider-setup.js.map +1 -1
  130. package/dist/readiness-probe-worker.d.ts +12 -0
  131. package/dist/readiness-probe-worker.d.ts.map +1 -0
  132. package/dist/readiness-probe-worker.js +276 -0
  133. package/dist/readiness-probe-worker.js.map +1 -0
  134. package/dist/readiness-probe.d.ts +100 -0
  135. package/dist/readiness-probe.d.ts.map +1 -0
  136. package/dist/readiness-probe.js +780 -0
  137. package/dist/readiness-probe.js.map +1 -0
  138. package/dist/request-model-override.d.ts +43 -1
  139. package/dist/request-model-override.d.ts.map +1 -1
  140. package/dist/request-model-override.js +178 -19
  141. package/dist/request-model-override.js.map +1 -1
  142. package/dist/run-history.d.ts +32 -0
  143. package/dist/run-history.d.ts.map +1 -0
  144. package/dist/run-history.js +859 -0
  145. package/dist/run-history.js.map +1 -0
  146. package/dist/runs-health.d.ts.map +1 -1
  147. package/dist/runs-health.js +12 -4
  148. package/dist/runs-health.js.map +1 -1
  149. package/dist/runtime-option-extensions.d.ts +14 -0
  150. package/dist/runtime-option-extensions.d.ts.map +1 -0
  151. package/dist/runtime-option-extensions.js +98 -0
  152. package/dist/runtime-option-extensions.js.map +1 -0
  153. package/dist/runtime-routes.d.ts +9 -0
  154. package/dist/runtime-routes.d.ts.map +1 -0
  155. package/dist/runtime-routes.js +13 -0
  156. package/dist/runtime-routes.js.map +1 -0
  157. package/dist/sandbox-manager.d.ts +69 -0
  158. package/dist/sandbox-manager.d.ts.map +1 -0
  159. package/dist/sandbox-manager.js +856 -0
  160. package/dist/sandbox-manager.js.map +1 -0
  161. package/dist/supermemory-plugin.d.ts +54 -0
  162. package/dist/supermemory-plugin.d.ts.map +1 -0
  163. package/dist/supermemory-plugin.js +136 -0
  164. package/dist/supermemory-plugin.js.map +1 -0
  165. package/dist/tui-command.d.ts +15 -0
  166. package/dist/tui-command.d.ts.map +1 -1
  167. package/dist/tui-command.js +46 -6
  168. package/dist/tui-command.js.map +1 -1
  169. package/dist/web-command.d.ts +8 -0
  170. package/dist/web-command.d.ts.map +1 -1
  171. package/dist/web-command.js +174 -17
  172. package/dist/web-command.js.map +1 -1
  173. package/dist/wizard/answers.d.ts +31 -4
  174. package/dist/wizard/answers.d.ts.map +1 -1
  175. package/dist/wizard/answers.js +145 -16
  176. package/dist/wizard/answers.js.map +1 -1
  177. package/dist/wizard/from-flags.d.ts +7 -0
  178. package/dist/wizard/from-flags.d.ts.map +1 -1
  179. package/dist/wizard/from-flags.js +36 -2
  180. package/dist/wizard/from-flags.js.map +1 -1
  181. package/dist/wizard/index.d.ts +4 -4
  182. package/dist/wizard/index.d.ts.map +1 -1
  183. package/dist/wizard/index.js +2 -2
  184. package/dist/wizard/index.js.map +1 -1
  185. package/dist/wizard/model-discovery.d.ts +38 -3
  186. package/dist/wizard/model-discovery.d.ts.map +1 -1
  187. package/dist/wizard/model-discovery.js +927 -102
  188. package/dist/wizard/model-discovery.js.map +1 -1
  189. package/dist/wizard/presets.d.ts +1 -1
  190. package/dist/wizard/presets.d.ts.map +1 -1
  191. package/dist/wizard/presets.js +7 -12
  192. package/dist/wizard/presets.js.map +1 -1
  193. package/dist/wizard/prompts.d.ts +66 -11
  194. package/dist/wizard/prompts.d.ts.map +1 -1
  195. package/dist/wizard/prompts.js +190 -20
  196. package/dist/wizard/prompts.js.map +1 -1
  197. package/dist/wizard/run.d.ts +60 -3
  198. package/dist/wizard/run.d.ts.map +1 -1
  199. package/dist/wizard/run.js +1161 -145
  200. package/dist/wizard/run.js.map +1 -1
  201. package/package.json +21 -16
  202. package/resources/srt/package-lock.json +66 -0
  203. package/resources/srt/package.json +9 -0
  204. package/schema/mono-agent.config.schema.json +381 -17
  205. package/skills/mono-agent-composer/SKILL.md +4 -4
  206. package/skills/mono-agent-composer/references/config-blueprint.md +5 -4
  207. package/skills/mono-agent-composer/references/discovery-questions.md +8 -8
  208. package/skills/mono-agent-composer/references/feature-coverage.md +8 -6
  209. package/skills/mono-agent-composer/references/package-map.md +1 -1
  210. package/skills/mono-agent-composer/references/playbooks.md +8 -7
  211. package/skills/mono-agent-composer/references/validation.md +1 -1
@@ -1,20 +1,345 @@
1
- import { spawn } from "node:child_process";
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";
2
5
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
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";
4
11
  import { modelReferenceKey, parseMonoRuntimeModelReference } from "@mono-agent/runtime-adapter";
12
+ import { inspectPiAuthStore } from "./pi-auth-store-inspection.js";
5
13
  const DEFAULT_PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
6
- export function piLoginCommand(provider) {
7
- return ["npx", "@earendil-works/pi-ai", "login", provider];
14
+ const PI_API_KEY_PROVIDERS = {
15
+ "opencode-go": "OPENCODE_API_KEY",
16
+ };
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];
8
74
  }
9
75
  export function piLoginCommandLine(provider) {
10
- return piLoginCommand(provider).join(" ");
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)}`;
11
82
  }
12
83
  export function piAuthWorkingDirectory(piAuthPath, cwd = process.cwd()) {
13
- return dirname(piAuthPath === undefined ? DEFAULT_PI_AUTH_PATH : resolve(cwd, piAuthPath));
84
+ return dirname(piAuthPathForSetup(piAuthPath, cwd));
85
+ }
86
+ export function piAuthPathForSetup(piAuthPath, cwd = process.cwd()) {
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));
14
332
  }
15
333
  export function planProviderSetup(options) {
16
334
  const piAuthPath = options.piAuthPath ?? DEFAULT_PI_AUTH_PATH;
17
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
+ });
18
343
  for (const raw of options.modelRefs) {
19
344
  let ref;
20
345
  try {
@@ -36,6 +361,10 @@ export function planProviderSetup(options) {
36
361
  });
37
362
  };
38
363
  if (ref.sdk === "claude") {
364
+ if (authAlreadyDetected(["claude", refKey])) {
365
+ detectedModelRefs.add(refKey);
366
+ continue;
367
+ }
39
368
  add({
40
369
  id: "claude-login",
41
370
  kind: "auth",
@@ -48,20 +377,32 @@ export function planProviderSetup(options) {
48
377
  continue;
49
378
  }
50
379
  if (ref.sdk === "codex") {
380
+ if (authAlreadyDetected(["codex", refKey])) {
381
+ detectedModelRefs.add(refKey);
382
+ continue;
383
+ }
384
+ const authMode = options.codexAuthMode ?? "browser";
51
385
  add({
52
386
  id: "codex-login",
53
387
  kind: "auth",
54
388
  label: "Codex login",
55
389
  modelRefs: [refKey],
56
- command: ["codex", "login"],
390
+ command: authMode === "device" ? ["codex", "login", "--device-auth"] : ["codex", "login"],
391
+ authMode,
57
392
  cwd: options.cwd,
58
- detail: "Runs the Codex login flow for direct Codex model references.",
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.",
59
396
  });
60
397
  continue;
61
398
  }
62
399
  if (ref.sdk !== "pi" || typeof ref.provider !== "string") {
63
400
  continue;
64
401
  }
402
+ if (authAlreadyDetected([`pi:${ref.provider}`, ref.provider, refKey])) {
403
+ detectedModelRefs.add(refKey);
404
+ continue;
405
+ }
65
406
  if (ref.provider === "ollama") {
66
407
  add({
67
408
  id: "ollama-list",
@@ -86,78 +427,1246 @@ export function planProviderSetup(options) {
86
427
  });
87
428
  continue;
88
429
  }
89
- if (ref.provider === "opencode-go") {
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";
90
445
  add({
91
- id: "opencode-models",
92
- kind: "preflight",
93
- label: "OpenCode model preflight",
446
+ id: `pi-api-key:${ref.provider}`,
447
+ kind: "auth",
448
+ label: `${provider?.auth.apiKey?.name ?? ref.provider} (${persistence === "secure-store" ? "secure store" : "environment"})`,
94
449
  modelRefs: [refKey],
95
- command: ["opencode", "models", "--json"],
96
- cwd: options.cwd,
97
- detail: "Checks that OpenCode can list configured models.",
450
+ provider: ref.provider,
451
+ envVar,
452
+ persistence,
453
+ piAuthPath: piAuthPathForSetup(piAuthPath, options.cwd),
454
+ cwd: piAuthWorkingDirectory(piAuthPath, options.cwd),
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.`,
98
458
  });
99
459
  continue;
100
460
  }
461
+ if (selectedMethod !== "oauth" || !supportsOAuth) {
462
+ continue;
463
+ }
101
464
  add({
102
465
  id: `pi-login:${ref.provider}`,
103
466
  kind: "auth",
104
467
  label: `Pi login for ${ref.provider}`,
105
468
  modelRefs: [refKey],
106
- command: piLoginCommand(ref.provider),
469
+ command: piLoginCommand(ref.provider, options.piCliPath),
470
+ piAuthPath: piAuthPathForSetup(piAuthPath, options.cwd),
107
471
  cwd: piAuthWorkingDirectory(piAuthPath, options.cwd),
108
- detail: `Runs Pi auth for provider \`${ref.provider}\` from the providers.piAuthPath directory.`,
472
+ detail: `Runs bundled Pi auth for provider \`${ref.provider}\` and securely replaces providers.piAuthPath.`,
109
473
  });
110
474
  }
111
- return { actions: [...actionsById.values()] };
475
+ return { actions: [...actionsById.values()], detectedModelRefs: [...detectedModelRefs] };
112
476
  }
113
477
  export function providerSetupActionCommandLine(action) {
114
478
  if ("command" in action) {
115
- return action.command.join(" ");
479
+ if (isProviderSetupPiLoginAction(action)) {
480
+ return piAuthRecoveryCommand(action.id.slice("pi-login:".length), action.piAuthPath);
481
+ }
482
+ return action.command.map(shellQuote).join(" ");
483
+ }
484
+ if (isProviderSetupPiApiKeyAction(action)) {
485
+ return `${action.envVar} -> ${action.piAuthPath}`;
116
486
  }
117
487
  return `GET ${action.url}`;
118
488
  }
489
+ export function isProviderSetupPiApiKeyAction(action) {
490
+ return "provider" in action && "piAuthPath" in action && "envVar" in action;
491
+ }
492
+ export function isProviderSetupPiLoginAction(action) {
493
+ return action.id.startsWith("pi-login:") && "piAuthPath" in action && "command" in action;
494
+ }
119
495
  export async function executeProviderSetupPlan(plan, options = {}) {
496
+ const preflightTimeoutMs = positivePreflightTimeout(options.preflightTimeoutMs);
120
497
  const results = [];
121
498
  for (const action of plan.actions) {
122
- const result = "command" in action
123
- ? await runCommandAction(action, options.spawn ?? spawn)
124
- : await runHttpAction(action, options.fetch ?? fetch);
499
+ if (options.abortSignal?.aborted === true)
500
+ break;
501
+ const result = isProviderSetupPiApiKeyAction(action)
502
+ ? await runPiApiKeyAction(action, options.apiKeys ?? {}, options.platform ?? process.platform, options)
503
+ : "command" in action
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);
125
506
  results.push(result);
126
- if (result.status === "failed") {
507
+ if (result.failureKind !== undefined)
127
508
  break;
128
- }
129
509
  }
130
510
  return results;
131
511
  }
132
- async function runCommandAction(action, spawnImpl) {
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);
519
+ const key = raw?.trim();
520
+ if (key === undefined || key.length === 0) {
521
+ return {
522
+ action,
523
+ status: "skipped",
524
+ detail: `${action.envVar} was not provided; skipped saving credentials for ${action.provider}.`,
525
+ };
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
+ }
534
+ try {
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);
544
+ return {
545
+ action,
546
+ status: "ok",
547
+ detail: `Saved API key credentials for ${action.provider} to the Pi auth store.`,
548
+ };
549
+ }
550
+ catch (error) {
551
+ return {
552
+ action,
553
+ status: "failed",
554
+ ...(error instanceof PiAuthCleanupError ? { failureKind: "cleanup_failed" } : {}),
555
+ detail: error instanceof Error ? error.message : String(error),
556
+ };
557
+ }
558
+ }
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
+ };
663
+ }
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`;
670
+ return {
671
+ action,
672
+ status: "failed",
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.",
677
+ };
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();
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) {
133
1526
  const [file, ...args] = action.command;
134
1527
  return new Promise((resolve) => {
135
1528
  const child = spawnImpl(file, args, {
136
- cwd: action.cwd,
1529
+ cwd,
137
1530
  env: process.env,
138
1531
  stdio: "inherit",
139
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 });
140
1583
  child.once("error", (error) => {
141
- resolve({ action, status: "failed", detail: error.message });
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
+ }
142
1592
  });
143
1593
  child.once("close", (code, signal) => {
1594
+ if (terminationResult !== undefined) {
1595
+ finish(terminationResult);
1596
+ return;
1597
+ }
144
1598
  if (code === 0) {
145
- resolve({ action, status: "ok", detail: `${providerSetupActionCommandLine(action)} exited 0.` });
1599
+ finish({ action, status: "ok", detail: `${providerSetupActionCommandLine(action)} exited 0.` });
146
1600
  return;
147
1601
  }
148
- resolve({
1602
+ finish({
149
1603
  action,
150
1604
  status: "failed",
151
- detail: signal === null
1605
+ detail: childError?.message ?? (signal === null
152
1606
  ? `${providerSetupActionCommandLine(action)} exited ${code ?? "unknown"}.`
153
- : `${providerSetupActionCommandLine(action)} terminated by ${signal}.`,
1607
+ : `${providerSetupActionCommandLine(action)} terminated by ${signal}.`),
154
1608
  });
155
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();
156
1620
  });
157
1621
  }
158
- 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 });
159
1635
  try {
160
- const response = await fetchImpl(action.url);
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
+ }
161
1670
  if (response.ok) {
162
1671
  return { action, status: "ok", detail: `GET ${action.url} returned ${response.status}.` };
163
1672
  }
@@ -170,5 +1679,15 @@ async function runHttpAction(action, fetchImpl) {
170
1679
  detail: error instanceof Error ? error.message : String(error),
171
1680
  };
172
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;
173
1692
  }
174
1693
  //# sourceMappingURL=provider-setup.js.map