@claudexor/cli 1.0.1 → 2.1.2

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 (213) hide show
  1. package/dist/acp-surface-runner.d.ts +17 -0
  2. package/dist/acp-surface-runner.d.ts.map +1 -0
  3. package/dist/acp-surface-runner.js +187 -0
  4. package/dist/acp-surface-runner.js.map +1 -0
  5. package/dist/args.d.ts.map +1 -1
  6. package/dist/args.js.map +1 -1
  7. package/dist/bridge-serve.d.ts +3 -0
  8. package/dist/bridge-serve.d.ts.map +1 -0
  9. package/dist/bridge-serve.js +39 -0
  10. package/dist/bridge-serve.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +12 -3
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/claude-oauth-usage.d.ts +33 -0
  15. package/dist/claude-oauth-usage.d.ts.map +1 -0
  16. package/dist/claude-oauth-usage.js +190 -0
  17. package/dist/claude-oauth-usage.js.map +1 -0
  18. package/dist/claude-statusline.d.ts +25 -0
  19. package/dist/claude-statusline.d.ts.map +1 -0
  20. package/dist/claude-statusline.js +259 -0
  21. package/dist/claude-statusline.js.map +1 -0
  22. package/dist/claudexord.js +346 -543
  23. package/dist/claudexord.js.map +1 -1
  24. package/dist/cli-io.d.ts +8 -9
  25. package/dist/cli-io.d.ts.map +1 -1
  26. package/dist/cli-io.js +11 -7
  27. package/dist/cli-io.js.map +1 -1
  28. package/dist/cli.js +475 -516
  29. package/dist/cli.js.map +1 -1
  30. package/dist/codex-quota-source.d.ts +11 -0
  31. package/dist/codex-quota-source.d.ts.map +1 -0
  32. package/dist/codex-quota-source.js +181 -0
  33. package/dist/codex-quota-source.js.map +1 -0
  34. package/dist/command-flags.d.ts +18 -0
  35. package/dist/command-flags.d.ts.map +1 -0
  36. package/dist/command-flags.js +124 -0
  37. package/dist/command-flags.js.map +1 -0
  38. package/dist/command-registry.d.ts +2 -40
  39. package/dist/command-registry.d.ts.map +1 -1
  40. package/dist/command-registry.js +74 -164
  41. package/dist/command-registry.js.map +1 -1
  42. package/dist/control-services.d.ts +2483 -0
  43. package/dist/control-services.d.ts.map +1 -0
  44. package/dist/control-services.js +437 -0
  45. package/dist/control-services.js.map +1 -0
  46. package/dist/credential-commands.d.ts +9 -0
  47. package/dist/credential-commands.d.ts.map +1 -0
  48. package/dist/credential-commands.js +224 -0
  49. package/dist/credential-commands.js.map +1 -0
  50. package/dist/daemon-lifecycle.d.ts +7 -3
  51. package/dist/daemon-lifecycle.d.ts.map +1 -1
  52. package/dist/daemon-lifecycle.js +40 -12
  53. package/dist/daemon-lifecycle.js.map +1 -1
  54. package/dist/daemon-run.d.ts +2 -0
  55. package/dist/daemon-run.d.ts.map +1 -1
  56. package/dist/daemon-run.js +62 -14
  57. package/dist/daemon-run.js.map +1 -1
  58. package/dist/daemon-runtime-shutdown.d.ts +58 -0
  59. package/dist/daemon-runtime-shutdown.d.ts.map +1 -0
  60. package/dist/daemon-runtime-shutdown.js +142 -0
  61. package/dist/daemon-runtime-shutdown.js.map +1 -0
  62. package/dist/decision.d.ts.map +1 -1
  63. package/dist/decision.js +12 -3
  64. package/dist/decision.js.map +1 -1
  65. package/dist/live.d.ts +29 -0
  66. package/dist/live.d.ts.map +1 -1
  67. package/dist/live.js +117 -6
  68. package/dist/live.js.map +1 -1
  69. package/dist/local-attachment.d.ts +14 -0
  70. package/dist/local-attachment.d.ts.map +1 -0
  71. package/dist/local-attachment.js +67 -0
  72. package/dist/local-attachment.js.map +1 -0
  73. package/dist/mcp-runner.d.ts +4 -15
  74. package/dist/mcp-runner.d.ts.map +1 -1
  75. package/dist/mcp-runner.js +273 -145
  76. package/dist/mcp-runner.js.map +1 -1
  77. package/dist/native-login.d.ts +21 -0
  78. package/dist/native-login.d.ts.map +1 -0
  79. package/dist/native-login.js +111 -0
  80. package/dist/native-login.js.map +1 -0
  81. package/dist/ops-commands.d.ts +8 -6
  82. package/dist/ops-commands.d.ts.map +1 -1
  83. package/dist/ops-commands.js +250 -168
  84. package/dist/ops-commands.js.map +1 -1
  85. package/dist/orchestrate-options.d.ts.map +1 -1
  86. package/dist/orchestrate-options.js +1 -1
  87. package/dist/orchestrate-options.js.map +1 -1
  88. package/dist/orphan-reaper.d.ts +4 -5
  89. package/dist/orphan-reaper.d.ts.map +1 -1
  90. package/dist/orphan-reaper.js +29 -56
  91. package/dist/orphan-reaper.js.map +1 -1
  92. package/dist/orphan-sweeper.d.ts +1 -2
  93. package/dist/orphan-sweeper.d.ts.map +1 -1
  94. package/dist/orphan-sweeper.js +29 -48
  95. package/dist/orphan-sweeper.js.map +1 -1
  96. package/dist/plugins.d.ts.map +1 -1
  97. package/dist/plugins.js +196 -64
  98. package/dist/plugins.js.map +1 -1
  99. package/dist/primary-output.d.ts.map +1 -1
  100. package/dist/primary-output.js.map +1 -1
  101. package/dist/profile-registration.d.ts +15 -0
  102. package/dist/profile-registration.d.ts.map +1 -0
  103. package/dist/profile-registration.js +68 -0
  104. package/dist/profile-registration.js.map +1 -0
  105. package/dist/project-command.d.ts +3 -0
  106. package/dist/project-command.d.ts.map +1 -0
  107. package/dist/project-command.js +87 -0
  108. package/dist/project-command.js.map +1 -0
  109. package/dist/quota-command.d.ts +3 -0
  110. package/dist/quota-command.d.ts.map +1 -0
  111. package/dist/quota-command.js +59 -0
  112. package/dist/quota-command.js.map +1 -0
  113. package/dist/registry.d.ts +1 -1
  114. package/dist/registry.d.ts.map +1 -1
  115. package/dist/registry.js +16 -4
  116. package/dist/registry.js.map +1 -1
  117. package/dist/release.d.ts.map +1 -1
  118. package/dist/release.js +15 -3
  119. package/dist/release.js.map +1 -1
  120. package/dist/repl.d.ts +3 -8
  121. package/dist/repl.d.ts.map +1 -1
  122. package/dist/repl.js +23 -122
  123. package/dist/repl.js.map +1 -1
  124. package/dist/request-preflight.d.ts +13 -0
  125. package/dist/request-preflight.d.ts.map +1 -0
  126. package/dist/request-preflight.js +81 -0
  127. package/dist/request-preflight.js.map +1 -0
  128. package/dist/retention-service.d.ts +24 -0
  129. package/dist/retention-service.d.ts.map +1 -0
  130. package/dist/retention-service.js +100 -0
  131. package/dist/retention-service.js.map +1 -0
  132. package/dist/retry-command-specs.d.ts +3 -0
  133. package/dist/retry-command-specs.d.ts.map +1 -0
  134. package/dist/retry-command-specs.js +21 -0
  135. package/dist/retry-command-specs.js.map +1 -0
  136. package/dist/retry-command.d.ts +4 -0
  137. package/dist/retry-command.d.ts.map +1 -0
  138. package/dist/retry-command.js +57 -0
  139. package/dist/retry-command.js.map +1 -0
  140. package/dist/review-command.d.ts.map +1 -1
  141. package/dist/review-command.js +47 -12
  142. package/dist/review-command.js.map +1 -1
  143. package/dist/run-options.d.ts +2 -2
  144. package/dist/run-options.d.ts.map +1 -1
  145. package/dist/run-options.js +27 -5
  146. package/dist/run-options.js.map +1 -1
  147. package/dist/settings-command.d.ts.map +1 -1
  148. package/dist/settings-command.js +143 -188
  149. package/dist/settings-command.js.map +1 -1
  150. package/dist/settings-service.d.ts +50 -0
  151. package/dist/settings-service.d.ts.map +1 -1
  152. package/dist/settings-service.js +67 -1
  153. package/dist/settings-service.js.map +1 -1
  154. package/dist/setup-job-reducer.d.ts +9 -0
  155. package/dist/setup-job-reducer.d.ts.map +1 -0
  156. package/dist/setup-job-reducer.js +290 -0
  157. package/dist/setup-job-reducer.js.map +1 -0
  158. package/dist/setup-job-store.d.ts +250 -0
  159. package/dist/setup-job-store.d.ts.map +1 -0
  160. package/dist/setup-job-store.js +359 -0
  161. package/dist/setup-job-store.js.map +1 -0
  162. package/dist/setup-job-support.d.ts +27 -0
  163. package/dist/setup-job-support.d.ts.map +1 -0
  164. package/dist/setup-job-support.js +141 -0
  165. package/dist/setup-job-support.js.map +1 -0
  166. package/dist/setup-jobs.d.ts +437 -22
  167. package/dist/setup-jobs.d.ts.map +1 -1
  168. package/dist/setup-jobs.js +813 -423
  169. package/dist/setup-jobs.js.map +1 -1
  170. package/dist/setup-lifecycle-binding.d.ts +40 -0
  171. package/dist/setup-lifecycle-binding.d.ts.map +1 -0
  172. package/dist/setup-lifecycle-binding.js +159 -0
  173. package/dist/setup-lifecycle-binding.js.map +1 -0
  174. package/dist/setup-login-protocol.d.ts +13 -0
  175. package/dist/setup-login-protocol.d.ts.map +1 -0
  176. package/dist/setup-login-protocol.js +179 -0
  177. package/dist/setup-login-protocol.js.map +1 -0
  178. package/dist/setup-login-runner.d.ts +20 -0
  179. package/dist/setup-login-runner.d.ts.map +1 -0
  180. package/dist/setup-login-runner.js +225 -0
  181. package/dist/setup-login-runner.js.map +1 -0
  182. package/dist/setup-supervisor.d.ts +53 -0
  183. package/dist/setup-supervisor.d.ts.map +1 -0
  184. package/dist/setup-supervisor.js +177 -0
  185. package/dist/setup-supervisor.js.map +1 -0
  186. package/dist/spec.d.ts +9 -20
  187. package/dist/spec.d.ts.map +1 -1
  188. package/dist/spec.js +49 -21
  189. package/dist/spec.js.map +1 -1
  190. package/dist/thread-delivery.d.ts +18 -0
  191. package/dist/thread-delivery.d.ts.map +1 -0
  192. package/dist/thread-delivery.js +67 -0
  193. package/dist/thread-delivery.js.map +1 -0
  194. package/dist/thread-select.d.ts +9 -0
  195. package/dist/thread-select.d.ts.map +1 -0
  196. package/dist/thread-select.js +12 -0
  197. package/dist/thread-select.js.map +1 -0
  198. package/dist/trust-command.d.ts.map +1 -1
  199. package/dist/trust-command.js +69 -61
  200. package/dist/trust-command.js.map +1 -1
  201. package/dist/trust-services.d.ts +5 -6
  202. package/dist/trust-services.d.ts.map +1 -1
  203. package/dist/trust-services.js +42 -13
  204. package/dist/trust-services.js.map +1 -1
  205. package/package.json +24 -22
  206. package/dist/attachment-resolver.d.ts +0 -11
  207. package/dist/attachment-resolver.d.ts.map +0 -1
  208. package/dist/attachment-resolver.js +0 -61
  209. package/dist/attachment-resolver.js.map +0 -1
  210. package/dist/settings-display.d.ts +0 -3
  211. package/dist/settings-display.d.ts.map +0 -1
  212. package/dist/settings-display.js +0 -33
  213. package/dist/settings-display.js.map +0 -1
@@ -1,477 +1,867 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { harnessRuntimeEnv } from "@claudexor/core";
3
+ import { chmodSync, writeFileSync } from "node:fs";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { AuthCapabilityVerifier, ProcessGroupService, parseProcessGroupHandle, } from "@claudexor/core";
6
6
  import { daemonDir } from "@claudexor/daemon";
7
- import { appendLine, noProjectRepoRoot, readJsonSafe, redactSecrets } from "@claudexor/util";
8
- import { ControlSetupJob as ControlSetupJobSchema } from "@claudexor/schema";
7
+ import { ControlSetupJobCreateRequest, SetupNativeCommandReceipt, } from "@claudexor/schema";
8
+ import { noProjectRepoRoot } from "@claudexor/util";
9
+ import * as NativeLogin from "./native-login.js";
10
+ import { SETUP_PROFILES, processGroupFromJob, profileDoctorProbe, resolveProfileBinding, resolveSetupLoginRunnerPath, shellQuote, stateMatchesDurableExecution, waitWithAbort, withAbortAndTimeout, } from "./setup-job-support.js";
9
11
  import { buildGateway } from "./registry.js";
12
+ import { ACTIVE_SETUP_STATES, SetupJobStore, TERMINAL_SETUP_STATES } from "./setup-job-store.js";
13
+ import { SETUP_LOGIN_PROTOCOL_VERSION, atomicPrivateJson, captureExecutableEvidence, commandDigest, readLoginManifest, readRunnerResult, readRunnerState, sealLoginManifest, verifyExecutableEvidence, } from "./setup-login-protocol.js";
14
+ import { SetupSupervisor } from "./setup-supervisor.js";
10
15
  const NO_PROJECT_ROOT = noProjectRepoRoot();
11
- /**
12
- * Doctor verification runs IN-PROCESS (buildGateway().statusAll) — never as a
13
- * `claudexor ...` shell command. The bundled .app daemon has no global
14
- * `claudexor` binary on PATH, so the previous shell form always exited 127.
15
- */
16
- const SETUP_PROFILES = {
17
- codex: {
18
- guideUrl: "https://developers.openai.com/codex",
19
- installCommand: "npm install -g @openai/codex",
20
- loginCommand: "codex login",
21
- note: "Codex native login seeds the local CLI session; API-key fallback can be stored as the openai secret ref.",
22
- installRiskFlags: ["network_download", "global_npm_install"],
23
- },
24
- claude: {
25
- guideUrl: "https://docs.anthropic.com/en/docs/claude-code",
26
- installCommand: "npm install -g @anthropic-ai/claude-code",
27
- loginCommand: "claude /login",
28
- note: "Claude Code native login is preferred; Anthropic API-key fallback can be stored as the anthropic secret ref.",
29
- installRiskFlags: ["network_download", "global_npm_install"],
30
- },
31
- cursor: {
32
- guideUrl: "https://docs.cursor.com/cli",
33
- installCommand: "curl https://cursor.com/install -fsS | bash",
34
- loginCommand: "cursor-agent login",
35
- note: "Cursor native CLI login is reused when available.",
36
- installRiskFlags: ["network_download", "shell_pipe", "no_static_sha256", "may_prompt_for_privilege"],
37
- },
38
- opencode: {
39
- guideUrl: "https://opencode.ai/docs",
40
- installCommand: "curl -fsSL https://opencode.ai/install | bash",
41
- loginCommand: "opencode auth login",
42
- note: "OpenCode native auth is reused when available.",
43
- installRiskFlags: ["network_download", "shell_pipe", "no_static_sha256", "may_prompt_for_privilege"],
44
- },
45
- raw: {
46
- guideUrl: "https://platform.openai.com/docs",
47
- installCommand: null,
48
- loginCommand: null,
49
- note: "Raw API routes use stored secret refs instead of a native CLI login.",
50
- installRiskFlags: [],
51
- },
52
- };
53
- /** Native login command for a harness (null = API-key refs only). Single
54
- * source for both the setup-job Terminal handoff and `claudexor auth login`. */
55
- export function nativeLoginCommand(harness) {
56
- return SETUP_PROFILES[harness]?.loginCommand ?? null;
57
- }
58
- /** Honest display label for the in-process doctor phase of a setup job. */
59
- function inProcessDoctorLabel(harness) {
60
- return harness === "raw" ? "(in-process doctor: all harnesses)" : `(in-process doctor: ${harness})`;
61
- }
62
- const SETUP_JOBS_DIR = join(daemonDir(), "setup-jobs");
63
- const SETUP_JOBS_REGISTRY = join(SETUP_JOBS_DIR, "jobs.json");
64
- /** A hung installer must reach a visible terminal state, not run forever. */
65
- const SETUP_JOB_TIMEOUT_MS = 15 * 60_000;
16
+ const LOGIN_EXTENSION_MS = 15 * 60_000;
66
17
  export function createSetupJobManager(opts = {}) {
67
- const jobs = new Map();
68
- const children = new Map();
69
- const timeouts = new Map();
70
- const statusAll = opts.statusAll ?? (() => buildGateway({ includeFakes: false }).statusAll({ cwd: NO_PROJECT_ROOT }));
71
- // Durable registry: a daemon restart must not erase job history. Jobs that
72
- // were mid-flight when the daemon died are marked failed (honest terminal),
73
- // except Terminal logins, which legitimately outlive the daemon process.
74
- mkdirSync(SETUP_JOBS_DIR, { recursive: true, mode: 0o700 });
75
- const persisted = readJsonSafe(SETUP_JOBS_REGISTRY);
76
- if (Array.isArray(persisted)) {
77
- for (const raw of persisted) {
78
- const parsed = ControlSetupJobSchema.safeParse(raw);
79
- if (!parsed.success)
80
- continue;
81
- const job = parsed.data;
82
- if (job.state === "running" || job.state === "queued") {
83
- jobs.set(job.jobId, {
84
- ...job,
85
- state: "failed",
86
- finishedAt: new Date().toISOString(),
87
- message: `${job.harness} ${job.action} job was interrupted by a daemon restart.`,
88
- });
89
- }
90
- else {
91
- jobs.set(job.jobId, job);
18
+ const now = opts.now ?? (() => new Date());
19
+ const sleep = opts.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
20
+ const spawnProcess = opts.spawn ?? spawn;
21
+ const rootDir = opts.rootDir ?? daemonDir();
22
+ const store = opts.store ?? new SetupJobStore(rootDir, { now });
23
+ const processGroups = opts.processGroups ?? new ProcessGroupService({ platform: opts.platform ?? process.platform });
24
+ const processing = new Map();
25
+ const terminations = new Map();
26
+ const verificationControllers = new Map();
27
+ const gateway = opts.probeAuthSource && opts.authCapabilityVerifier
28
+ ? null
29
+ : buildGateway({ includeFakes: false });
30
+ const probeAuthSource = opts.probeAuthSource ??
31
+ ((harness, source, probe) => gateway.probeAuthSource(harness, source, { cwd: NO_PROJECT_ROOT, ...probe }));
32
+ const probeCredentialProfile = opts.probeCredentialProfile ??
33
+ (gateway
34
+ ? profileDoctorProbe((harness) => gateway.get(harness))
35
+ : () => {
36
+ // Injected auth probes without an injected profile probe would
37
+ // silently verify the WRONG store — refuse loudly instead.
38
+ throw new Error("probeCredentialProfile hook is required when probeAuthSource is injected");
39
+ });
40
+ const authCapabilityVerifier = opts.authCapabilityVerifier ??
41
+ new AuthCapabilityVerifier((harness) => gateway.get(harness), {
42
+ now,
43
+ scratchRoot: join(rootDir, "auth-smokes"),
44
+ });
45
+ const runnerPath = opts.runnerPath ?? resolveSetupLoginRunnerPath();
46
+ const nodePath = opts.nodePath ?? process.execPath;
47
+ if (!isAbsolute(runnerPath) || !isAbsolute(nodePath))
48
+ throw new Error("setup login runnerPath and nodePath must be absolute");
49
+ const platform = opts.platform ?? process.platform;
50
+ const launcherTimeoutMs = opts.launcherTimeoutMs ?? 10_000;
51
+ const loginTimeoutMs = opts.loginTimeoutMs ?? 15 * 60_000;
52
+ const verifyTimeoutMs = opts.verifyTimeoutMs ?? 30_000;
53
+ const capabilityTimeoutMs = opts.capabilityTimeoutMs ?? 60_000;
54
+ const verifyPollMs = opts.verifyPollMs ?? 2_000;
55
+ const terminationGraceMs = opts.terminationGraceMs ?? 5_000;
56
+ let supervisor;
57
+ const openTerminal = opts.openTerminal ??
58
+ ((scriptPath) => spawnProcess("/usr/bin/open", ["-a", "Terminal", scriptPath], {
59
+ detached: true,
60
+ stdio: "ignore",
61
+ }));
62
+ function iso() {
63
+ return now().toISOString();
64
+ }
65
+ function update(jobId, patch) {
66
+ return store.update(jobId, patch);
67
+ }
68
+ function log(jobId, line) {
69
+ store.appendLog(jobId, line);
70
+ }
71
+ function logAfterMutation(jobId, line) {
72
+ try {
73
+ log(jobId, line);
74
+ }
75
+ catch (error) {
76
+ supervisor.reportFailure(error, "setup-log");
77
+ }
78
+ }
79
+ function finish(jobId, state, reason, message, evidence = {},
80
+ // A pre-launch failure still keeps the operator's manual command selectable
81
+ // (DESIGN_SYSTEM setup contract, INV-093) — never a null command with the
82
+ // reason buried in one HTTP response.
83
+ command) {
84
+ const done = update(jobId, {
85
+ state,
86
+ phase: "completed",
87
+ outcome: { reason, ...evidence },
88
+ finishedAt: iso(),
89
+ message,
90
+ ...(command ? { command } : {}),
91
+ });
92
+ logAfterMutation(jobId, message);
93
+ return done;
94
+ }
95
+ function persistNativeCommandOutcome(jobId, result) {
96
+ let job = store.status(jobId);
97
+ const receipt = SetupNativeCommandReceipt.parse({
98
+ executionId: result.executionId,
99
+ commandDigest: result.commandDigest,
100
+ manifestDigest: result.manifestDigest,
101
+ permitIssuedAt: result.permitIssuedAt,
102
+ commandStarted: result.commandStarted,
103
+ exitCode: result.exitCode,
104
+ signal: result.signal,
105
+ ...(result.errorCode ? { errorCode: result.errorCode } : {}),
106
+ finishedAt: result.finishedAt,
107
+ });
108
+ if (job.nativeCommand) {
109
+ if (JSON.stringify(job.nativeCommand) !== JSON.stringify(receipt)) {
110
+ throw new Error(`native command outcome changed for setup job '${jobId}'`);
92
111
  }
112
+ return job;
113
+ }
114
+ job = update(jobId, {
115
+ nativeCommand: receipt,
116
+ message: `Persisted hash-bound ${job.harness} native command evidence before verification.`,
117
+ });
118
+ if (receipt.commandStarted)
119
+ opts.onCredentialStateMayHaveChanged?.(job.harness);
120
+ return job;
121
+ }
122
+ function probeNativeSession(harness, controller, timeoutMs) {
123
+ return withAbortAndTimeout(() => probeAuthSource(harness, "native_session", {
124
+ fresh: true,
125
+ authPreference: "subscription",
126
+ abortSignal: controller.signal,
127
+ }), controller, timeoutMs, "native auth verification");
128
+ }
129
+ function probeProfileStatus(harness, profileId, controller, timeoutMs) {
130
+ return withAbortAndTimeout(() => probeCredentialProfile(harness, profileId, controller.signal), controller, timeoutMs, "profile auth verification");
131
+ }
132
+ function finishCapabilityReceipt(jobId, receipt, loginEvidence = {}) {
133
+ if (receipt.verification === "passed" &&
134
+ receipt.effective === "vendor_native" &&
135
+ receipt.effectiveSource === "native_session") {
136
+ return finish(jobId, "succeeded", "completed", `${receipt.harness} native subscription completed a real same-harness capability smoke. Billing remains unknown.`, loginEvidence);
93
137
  }
138
+ const routeMismatch = receipt.selectionReason === "route_mismatch" ||
139
+ receipt.selectionReason === "source_mismatch" ||
140
+ receipt.selectionReason === "route_missing" ||
141
+ receipt.selectionReason === "source_missing";
142
+ return finish(jobId, "failed", routeMismatch ? "credential_route_mismatch" : "capability_verification_failed", routeMismatch
143
+ ? `${receipt.harness} capability smoke did not use the exact vendor-native session route.`
144
+ : `${receipt.harness} capability smoke failed (${receipt.selectionReason}).`, loginEvidence);
94
145
  }
95
- const persist = () => {
146
+ async function runCapabilitySmoke(jobId, controller, loginEvidence) {
147
+ let job = store.status(jobId);
148
+ if (!ACTIVE_SETUP_STATES.has(job.state) || job.phase === "cancelling")
149
+ return;
150
+ const lifecycle = job.authCapability;
151
+ if (!lifecycle) {
152
+ finish(jobId, "failed", "capability_verification_failed", `${job.harness} login is missing its durable capability disclosure.`, loginEvidence);
153
+ return;
154
+ }
155
+ if (lifecycle.state !== "disclosed") {
156
+ settleDurableCapability(job, "restart");
157
+ return;
158
+ }
159
+ const startedAt = iso();
160
+ const running = { ...lifecycle, state: "running", startedAt };
161
+ update(jobId, {
162
+ state: "running",
163
+ phase: "verifying",
164
+ authCapability: running,
165
+ message: `Running one ${job.harness} capability smoke on native_session; it may consume quota and billing is unknown.`,
166
+ });
167
+ let receipt;
168
+ try {
169
+ const { state: _state, ...binding } = lifecycle;
170
+ receipt = await withAbortAndTimeout(() => authCapabilityVerifier.verify({
171
+ binding,
172
+ startedAt,
173
+ abortSignal: controller.signal,
174
+ }), controller, capabilityTimeoutMs, "auth capability smoke");
175
+ }
176
+ catch (error) {
177
+ const current = store.status(jobId);
178
+ if (!ACTIVE_SETUP_STATES.has(current.state) || current.phase === "cancelling")
179
+ return;
180
+ const interruptedAt = iso();
181
+ update(jobId, {
182
+ authCapability: { ...running, state: "interrupted_unknown", interruptedAt },
183
+ message: `${job.harness} capability smoke ended without a verifiable receipt.`,
184
+ });
185
+ finish(jobId, "interrupted_unknown", "interrupted_unknown", `${job.harness} capability smoke outcome is unknown${error instanceof Error && "code" in error && error.code === "setup_timeout" ? " after timeout" : ""}; it was not replayed.`, loginEvidence);
186
+ return;
187
+ }
96
188
  try {
97
- writeFileSync(SETUP_JOBS_REGISTRY, JSON.stringify([...jobs.values()], null, 2) + "\n", { mode: 0o600 });
189
+ authCapabilityVerifier.cleanup(lifecycle.attemptId);
98
190
  }
99
191
  catch {
100
- /* registry persistence is best-effort; state stays authoritative in memory */
192
+ /* receipt remains authoritative */
101
193
  }
102
- };
103
- const save = (job) => {
104
- jobs.set(job.jobId, job);
105
- persist();
106
- return job;
107
- };
108
- const update = (jobId, patch) => {
109
- const prev = jobs.get(jobId);
110
- if (!prev)
111
- throw Object.assign(new Error("setup job not found"), { status: 404 });
112
- return save({ ...prev, ...patch });
113
- };
114
- const writeJobLog = (job, line) => {
115
- if (!job.logPath)
194
+ const current = store.status(jobId);
195
+ if (!ACTIVE_SETUP_STATES.has(current.state) || current.phase === "cancelling")
116
196
  return;
117
- appendLine(job.logPath, `[${new Date().toISOString()}] ${redactSecrets(line)}`);
118
- };
119
- const noteJobOutput = (jobId) => {
120
- const current = jobs.get(jobId);
121
- if (!current || current.state !== "running")
197
+ update(jobId, {
198
+ authCapability: {
199
+ ...running,
200
+ state: "completed",
201
+ completedAt: receipt.completedAt,
202
+ receipt,
203
+ },
204
+ message: `${job.harness} capability receipt persisted (${receipt.verification}).`,
205
+ });
206
+ finishCapabilityReceipt(jobId, receipt, loginEvidence);
207
+ }
208
+ function runNativeVerification(jobId, loginEvidence = {}) {
209
+ const existing = processing.get(jobId);
210
+ if (existing)
211
+ return existing;
212
+ const controller = new AbortController();
213
+ verificationControllers.set(jobId, controller);
214
+ const operation = supervisor.track(`native-verification:${jobId}`, () => runNativeVerificationOnce(jobId, loginEvidence, controller));
215
+ processing.set(jobId, operation);
216
+ const cleanup = () => {
217
+ if (processing.get(jobId) === operation)
218
+ processing.delete(jobId);
219
+ if (verificationControllers.get(jobId) === controller)
220
+ verificationControllers.delete(jobId);
221
+ };
222
+ void operation.then(cleanup, cleanup);
223
+ return operation;
224
+ }
225
+ async function runNativeVerificationOnce(jobId, loginEvidence, verificationController) {
226
+ const job = store.status(jobId);
227
+ if (!ACTIVE_SETUP_STATES.has(job.state))
122
228
  return;
123
- const now = nowForDto();
124
229
  update(jobId, {
125
- firstOutputAt: current.firstOutputAt ?? now,
126
- lastOutputAt: now,
127
- message: current.firstOutputAt
128
- ? `Running allowlisted ${current.harness} ${current.action} job. Latest output at ${now}.`
129
- : `Running allowlisted ${current.harness} ${current.action} job. First output received.`,
230
+ state: "running",
231
+ phase: "verifying",
232
+ message: job.profileId
233
+ ? `Verifying the scoped ${job.harness} profile "${job.profileId}" via its doctor probe.`
234
+ : `Verifying the exact ${job.harness} native subscription source.`,
130
235
  });
131
- };
132
- /**
133
- * In-process doctor: probes the harness through the SAME gateway code the
134
- * Harness Doctor screen uses. No shell, no PATH dependency — the bundled
135
- * .app daemon has no global `claudexor` binary (the old shell form was a
136
- * guaranteed exit 127).
137
- */
138
- const doctorTimeoutMs = opts.doctorTimeoutMs ?? 60_000;
139
- const runInProcessDoctor = (jobId) => {
140
- void (async () => {
141
- const job = jobs.get(jobId);
142
- if (!job || job.state !== "running")
236
+ const deadline = now().getTime() + verifyTimeoutMs;
237
+ let lastDetail;
238
+ for (;;) {
239
+ const current = store.status(jobId);
240
+ if (!ACTIVE_SETUP_STATES.has(current.state) ||
241
+ current.phase === "cancelling" ||
242
+ verificationController.signal.aborted)
143
243
  return;
144
- writeJobLog(job, `doctor: probing ${job.harness} in-process (no shell, no PATH dependency)`);
244
+ const remaining = deadline - now().getTime();
245
+ if (remaining <= 0)
246
+ break;
247
+ // INV-135 profile jobs verify the PROFILE's scoped store via the doctor
248
+ // probe — the default native_session probe would attest the WRONG store.
249
+ let readiness = null;
250
+ let profileStatus = null;
145
251
  try {
146
- const statuses = await Promise.race([
147
- statusAll(),
148
- new Promise((_, reject) => {
149
- const t = setTimeout(() => reject(new Error(`doctor timed out after ${doctorTimeoutMs / 1000}s`)), doctorTimeoutMs);
150
- t.unref();
151
- }),
152
- ]);
153
- noteJobOutput(jobId);
154
- const targets = job.harness === "raw" ? statuses : statuses.filter((s) => s.id === job.harness);
155
- if (targets.length === 0)
156
- throw new Error(`no doctor report for harness '${job.harness}'`);
157
- for (const s of targets) {
158
- const checks = s.checks.map((c) => `${c.id}:${c.status}`).join(", ");
159
- writeJobLog(job, `doctor ${s.id}: ${s.status}${checks ? ` — ${checks}` : ""}`);
160
- for (const reason of s.reasons ?? [])
161
- writeJobLog(job, `doctor ${s.id}: ${reason}`);
252
+ if (job.profileId) {
253
+ profileStatus = await probeProfileStatus(job.harness, job.profileId, verificationController, remaining);
254
+ }
255
+ else {
256
+ readiness = await probeNativeSession(job.harness, verificationController, remaining);
162
257
  }
163
- const ok = targets.every((s) => s.status === "ok");
164
- const degraded = targets.some((s) => s.status === "degraded");
165
- const reasons = targets.flatMap((s) => s.reasons ?? []).slice(0, 2).join("; ");
166
- const message = ok
167
- ? `${job.harness} doctor passed (in-process).`
168
- : `${job.harness} doctor reports ${degraded ? "degraded" : "unavailable"}: ${reasons || "see the job log for checks"}`;
169
- // A cancel may have landed while statusAll() was in flight; a terminal
170
- // cancelled state must never be overwritten by a late doctor verdict.
171
- const beforeTerminal = jobs.get(jobId);
172
- if (!beforeTerminal || beforeTerminal.state !== "running")
173
- return;
174
- const done = update(jobId, { state: ok ? "succeeded" : "failed", finishedAt: nowForDto(), message });
175
- writeJobLog(done, message);
176
258
  }
177
259
  catch (err) {
178
- const message = `in-process doctor failed: ${err instanceof Error ? err.message : String(err)}`;
179
- const current = jobs.get(jobId);
180
- // Same cancel-race guard as the success path: only a still-running
181
- // job may take a terminal verdict from this async phase.
182
- if (!current || current.state !== "running")
260
+ const latest = store.status(jobId);
261
+ if (!ACTIVE_SETUP_STATES.has(latest.state) || latest.phase === "cancelling")
183
262
  return;
184
- const failed = update(jobId, { state: "failed", finishedAt: nowForDto(), message });
185
- writeJobLog(failed, message);
263
+ lastDetail = err instanceof Error ? err.message : String(err);
264
+ if (verificationController.signal.aborted) {
265
+ if (["draining", "stopped"].includes(supervisor.health().state))
266
+ return;
267
+ break;
268
+ }
269
+ log(jobId, `fresh native-session probe failed transiently: ${lastDetail}`);
270
+ await waitWithAbort(Math.min(verifyPollMs, Math.max(0, deadline - now().getTime())), verificationController, sleep);
271
+ continue;
186
272
  }
187
- })();
188
- };
189
- const startInProcessDoctorJob = (job) => {
190
- const started = update(job.jobId, {
191
- state: "running",
192
- startedAt: nowForDto(),
193
- message: `Running in-process ${job.harness} doctor.`,
194
- });
195
- runInProcessDoctor(job.jobId);
196
- return started;
197
- };
198
- const startShellJob = (job, command, onSuccess) => {
199
- const started = update(job.jobId, { state: "running", startedAt: nowForDto(), message: `Running allowlisted ${job.harness} ${job.action} job.` });
200
- writeJobLog(started, `$ ${command}`);
201
- const child = spawn("/bin/bash", ["-lc", command], {
202
- cwd: NO_PROJECT_ROOT,
203
- env: setupJobEnv(),
204
- stdio: ["ignore", "pipe", "pipe"],
205
- });
206
- children.set(job.jobId, child);
207
- // Watchdog: a wedged install/doctor must surface as a failed terminal state.
208
- const watchdog = setTimeout(() => {
209
- const current = jobs.get(job.jobId);
210
- if (!current || current.state !== "running")
211
- return;
212
- writeJobLog(current, `watchdog: job exceeded ${SETUP_JOB_TIMEOUT_MS / 60000} minutes; terminating`);
213
- child.kill("SIGTERM");
214
- setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
215
- const failed = update(job.jobId, {
216
- state: "failed",
217
- finishedAt: nowForDto(),
218
- message: `${job.harness} ${job.action} job timed out after ${SETUP_JOB_TIMEOUT_MS / 60000} minutes and was terminated.`,
219
- });
220
- writeJobLog(failed, failed.message);
221
- }, SETUP_JOB_TIMEOUT_MS);
222
- watchdog.unref();
223
- timeouts.set(job.jobId, watchdog);
224
- child.stdout.on("data", (chunk) => { noteJobOutput(job.jobId); writeJobLog(jobs.get(job.jobId) ?? started, String(chunk)); });
225
- child.stderr.on("data", (chunk) => { noteJobOutput(job.jobId); writeJobLog(jobs.get(job.jobId) ?? started, String(chunk)); });
226
- child.on("error", (err) => {
227
- children.delete(job.jobId);
228
- clearTimeout(timeouts.get(job.jobId));
229
- timeouts.delete(job.jobId);
230
- const failed = update(job.jobId, { state: "failed", finishedAt: nowForDto(), message: `Setup job failed to start: ${err.message}` });
231
- writeJobLog(failed, failed.message);
232
- });
233
- child.on("exit", (code, signal) => {
234
- children.delete(job.jobId);
235
- clearTimeout(timeouts.get(job.jobId));
236
- timeouts.delete(job.jobId);
237
- const current = jobs.get(job.jobId);
238
- if (!current || current.state === "cancelled" || current.state === "failed")
239
- return;
240
- const ok = code === 0;
241
- if (ok && onSuccess) {
242
- // The shell phase finished; the job stays running for the in-process
243
- // doctor phase, which owns the terminal state.
244
- const phase = update(job.jobId, { message: `${job.harness} ${job.action} shell phase finished; verifying with in-process doctor.` });
245
- writeJobLog(phase, phase.message);
246
- onSuccess(job.jobId);
273
+ const latest = store.status(jobId);
274
+ if (!ACTIVE_SETUP_STATES.has(latest.state) || latest.phase === "cancelling")
247
275
  return;
276
+ if (job.profileId) {
277
+ const verified = profileStatus?.availability === "available" && profileStatus.verification === "passed";
278
+ lastDetail = profileStatus?.detail;
279
+ log(jobId, `profile ${latest.harness}/${job.profileId}: ${profileStatus?.availability ?? "unknown"}; verification ${profileStatus?.verification ?? "not_run"}`);
280
+ if (verified && now().getTime() <= deadline) {
281
+ // Same contract as `claudexor profiles login`: the doctor probe IS
282
+ // the verification truth for a scoped profile. The capability smoke
283
+ // attests the DEFAULT route only, so it is honestly skipped here.
284
+ finish(jobId, "succeeded", "completed", `${latest.harness} profile "${job.profileId}" login verified by its doctor probe. The default-route capability smoke does not apply to scoped profiles and was skipped.`, loginEvidence);
285
+ return;
286
+ }
287
+ }
288
+ else {
289
+ const verified = readiness?.source === "native_session" &&
290
+ readiness.availability === "available" &&
291
+ readiness.verification === "passed";
292
+ lastDetail = readiness?.detail;
293
+ log(jobId, `native_session ${latest.harness}: ${readiness?.availability ?? "unknown"}; verification ${readiness?.verification ?? "not_run"}`);
294
+ if (verified && now().getTime() <= deadline) {
295
+ await runCapabilitySmoke(jobId, verificationController, loginEvidence);
296
+ return;
297
+ }
248
298
  }
249
- const message = ok
250
- ? `${job.harness} ${job.action} job finished.`
251
- : `${job.harness} ${job.action} job failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}.`;
252
- const done = update(job.jobId, { state: ok ? "succeeded" : "failed", finishedAt: nowForDto(), message });
253
- writeJobLog(done, message);
299
+ await waitWithAbort(Math.min(verifyPollMs, Math.max(0, deadline - now().getTime())), verificationController, sleep);
300
+ }
301
+ const latest = store.status(jobId);
302
+ if (!ACTIVE_SETUP_STATES.has(latest.state) || latest.phase === "cancelling")
303
+ return;
304
+ finish(jobId, "failed", "auth_not_ready", `${latest.harness} ${job.profileId ? `profile "${job.profileId}"` : "native session"} was not ready before the verification deadline${lastDetail ? `: ${lastDetail}` : "."}`, loginEvidence);
305
+ }
306
+ function manifestFor(jobId) {
307
+ try {
308
+ return readLoginManifest(store.paths(jobId).manifest);
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ }
314
+ function matchingState(jobId) {
315
+ const manifest = manifestFor(jobId);
316
+ const state = readRunnerState(store.paths(jobId).runnerState);
317
+ return manifest && state && state.jobId === jobId && state.executionId === manifest.executionId
318
+ ? state
319
+ : null;
320
+ }
321
+ function matchingResult(jobId) {
322
+ const manifest = manifestFor(jobId);
323
+ const result = readRunnerResult(store.paths(jobId).runnerResult);
324
+ return manifest &&
325
+ result &&
326
+ result.jobId === jobId &&
327
+ result.executionId === manifest.executionId &&
328
+ result.commandDigest === manifest.commandDigest &&
329
+ result.manifestDigest === manifest.manifestDigest
330
+ ? result
331
+ : null;
332
+ }
333
+ function persistPermit(job, manifest) {
334
+ const issuedAt = job.execution?.permitIssuedAt;
335
+ if (!issuedAt)
336
+ throw new Error("cannot issue a setup-login permit before its durable journal transition");
337
+ atomicPrivateJson(manifest.permitPath, {
338
+ version: SETUP_LOGIN_PROTOCOL_VERSION,
339
+ jobId: job.jobId,
340
+ executionId: manifest.executionId,
341
+ issuedAt,
342
+ commandDigest: manifest.commandDigest,
343
+ manifestDigest: manifest.manifestDigest,
254
344
  });
255
- return started;
256
- };
257
- const startTerminalLoginJob = (job, command) => {
258
- // The Terminal-handoff login uses macOS `open -a Terminal`. On non-darwin
259
- // there is no such affordance: fail with a typed, actionable state rather
260
- // than spawn a missing binary (whose unhandled ENOENT `error` event could
261
- // take down the daemon). The manual command is disclosed so the user can
262
- // run the native login themselves.
263
- if (process.platform !== "darwin") {
264
- const failed = update(job.jobId, {
265
- state: "failed",
266
- startedAt: nowForDto(),
267
- finishedAt: nowForDto(),
268
- message: `Terminal-handoff login is macOS-only. Run the ${job.harness} native login yourself, then recheck Harness Doctor: ${command}`,
345
+ }
346
+ async function observeAndPermit(jobId, state) {
347
+ let job = store.status(jobId);
348
+ const manifest = manifestFor(jobId);
349
+ if (!manifest) {
350
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login manifest became unavailable after worker observation.`);
351
+ return;
352
+ }
353
+ const handle = parseProcessGroupHandle(state.processGroup);
354
+ if (!job.authorization ||
355
+ job.authorization.executionId !== state.executionId ||
356
+ job.authorization.commandDigest !== state.commandDigest ||
357
+ job.authorization.manifestDigest !== state.manifestDigest ||
358
+ manifest.commandDigest !== job.authorization.commandDigest ||
359
+ manifest.manifestDigest !== job.authorization.manifestDigest ||
360
+ JSON.stringify(manifest.executable) !== JSON.stringify(job.authorization.executable) ||
361
+ JSON.stringify(manifest.args) !== JSON.stringify(job.authorization.args) ||
362
+ !verifyExecutableEvidence(job.authorization.executable)) {
363
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login command authorization changed before execution permit.`);
364
+ return;
365
+ }
366
+ if (!job.execution?.permitIssuedAt && processGroups.compareLeader(handle) !== "same") {
367
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login worker identity changed before execution was permitted.`);
368
+ return;
369
+ }
370
+ if (!job.execution) {
371
+ job = update(jobId, {
372
+ execution: {
373
+ executionId: state.executionId,
374
+ commandDigest: state.commandDigest,
375
+ manifestDigest: state.manifestDigest,
376
+ processGroup: state.processGroup,
377
+ observedAt: state.observedAt,
378
+ },
379
+ message: `Observed ${job.harness} login worker; recording execution permit.`,
269
380
  });
270
- writeJobLog(failed, failed.message);
271
- return failed;
272
- }
273
- const scriptPath = join(SETUP_JOBS_DIR, `${job.jobId}.command`);
274
- const script = `#!/usr/bin/env bash
275
- set -euo pipefail
276
- ${command}
277
- printf '\\nClaudexor setup command finished. Press Return to close this window. '
278
- IFS= read -r _
279
- `;
280
- writeFileSync(scriptPath, script, { mode: 0o700 });
281
- chmodSync(scriptPath, 0o700);
282
- writeJobLog(job, `Terminal script: ${scriptPath}`);
283
- const child = spawn("/usr/bin/open", ["-a", "Terminal", scriptPath], { detached: true, stdio: "ignore" });
284
- // The waiting transition lands FIRST (same synchronous tick as spawn —
285
- // child 'error'/'exit' events cannot fire mid-tick), so startedAt is
286
- // always stamped before any handler can run.
287
- const waiting = update(job.jobId, {
288
- state: "waiting_for_input",
289
- startedAt: nowForDto(),
290
- message: `Opened allowlisted ${job.harness} login in Terminal. Complete native auth there, then recheck Harness Doctor.`,
291
- });
292
- // A missing/blocked `open` must land as a typed failure, never an
293
- // unhandled 'error' event that crashes the daemon. `open` can also fail
294
- // AFTER spawning (nonzero exit, or killed by signal) — that path must not
295
- // leave the job parked on waiting_for_input either. One transition wins
296
- // (settled flag), and a job the user already CANCELLED (or any other
297
- // terminal state) is never overwritten by a late child event — the same
298
- // cancel-race guard the doctor path uses.
299
- let settled = false;
300
- const failJob = (detail) => {
301
- if (settled)
302
- return;
303
- settled = true;
304
- const current = jobs.get(job.jobId);
305
- if (!current || current.state !== "waiting_for_input")
306
- return;
307
- const failed = update(job.jobId, {
308
- state: "failed",
309
- finishedAt: nowForDto(),
310
- message: `Could not open Terminal for ${job.harness} login: ${detail}. Run it yourself, then recheck Harness Doctor: ${command}`,
381
+ }
382
+ else if (!stateMatchesDurableExecution(job, state)) {
383
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login worker contradicts durable process-group evidence.`);
384
+ return;
385
+ }
386
+ if (!job.execution?.permitIssuedAt) {
387
+ job = update(jobId, {
388
+ execution: { ...job.execution, permitIssuedAt: iso() },
389
+ message: `Durably authorized ${job.harness} native login execution.`,
311
390
  });
312
- writeJobLog(failed, failed.message);
313
- };
314
- child.on("error", (err) => failJob(err.message));
315
- child.on("exit", (code, signal) => {
316
- if (code === 0)
391
+ }
392
+ persistPermit(job, manifest);
393
+ if (job.phase === "launching") {
394
+ update(jobId, {
395
+ phase: "awaiting_user",
396
+ message: `Complete ${job.harness} native login in Terminal.`,
397
+ });
398
+ }
399
+ }
400
+ async function consumeLoginResult(jobId, result) {
401
+ let job = store.status(jobId);
402
+ if (!ACTIVE_SETUP_STATES.has(job.state) || job.phase === "cancelling")
403
+ return;
404
+ if (result.permitIssuedAt !== null) {
405
+ const state = matchingState(jobId);
406
+ if (!job.execution?.permitIssuedAt ||
407
+ job.execution.permitIssuedAt !== result.permitIssuedAt ||
408
+ !state ||
409
+ state.stage !== "running" ||
410
+ !stateMatchesDurableExecution(job, state) ||
411
+ Date.parse(result.finishedAt) < Date.parse(result.permitIssuedAt)) {
412
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login result is not bound to the durable execution permit.`);
317
413
  return;
318
- failJob(code === null ? `\`open -a Terminal\` was terminated by signal ${signal ?? "unknown"}` : `\`open -a Terminal\` exited with code ${code}`);
414
+ }
415
+ }
416
+ else if (job.execution?.permitIssuedAt || result.commandStarted) {
417
+ finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login result contradicts the durable execution permit.`);
418
+ return;
419
+ }
420
+ job = persistNativeCommandOutcome(jobId, result);
421
+ if (!result.commandStarted) {
422
+ finish(jobId, "failed", "launch_failed", result.errorCode === "permit_timeout"
423
+ ? `${job.harness} login worker timed out before a durable execution permit was issued.`
424
+ : `${job.harness} login command could not be spawned after authorization.`);
425
+ return;
426
+ }
427
+ if (result.exitCode === 0 && result.signal === null) {
428
+ await runNativeVerification(jobId, {
429
+ exitCode: job.nativeCommand.exitCode,
430
+ signal: job.nativeCommand.signal,
431
+ });
432
+ return;
433
+ }
434
+ finish(jobId, "failed", "command_failed", `${job.harness} login command failed.`, {
435
+ exitCode: result.exitCode,
436
+ signal: result.signal,
319
437
  });
320
- child.unref();
321
- return waiting;
322
- };
323
- return {
324
- create(input) {
325
- mkdirSync(SETUP_JOBS_DIR, { recursive: true, mode: 0o700 });
326
- const p = (input ?? {});
327
- const harness = typeof p["harness"] === "string" ? p["harness"] : "";
328
- const action = typeof p["action"] === "string" ? p["action"] : "";
329
- const profile = SETUP_PROFILES[harness];
330
- if (!profile)
331
- throw new Error("unknown harness");
332
- if (action !== "install" && action !== "login" && action !== "doctor" && action !== "store_key")
333
- throw new Error("unsupported setup job action");
334
- const jobId = `setup-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
335
- const logPath = join(SETUP_JOBS_DIR, `${jobId}.log`);
336
- // Honest retry accounting: a new job for the same harness+action after a
337
- // failed one IS the user retrying.
338
- const priorFailures = [...jobs.values()].filter((j) => j.harness === harness && j.action === action && (j.state === "failed" || j.state === "cancelled")).length;
339
- const base = {
340
- jobId,
341
- harness: harness,
342
- action,
343
- state: "queued",
344
- command: null,
345
- guideUrl: profile.guideUrl,
346
- logPath,
347
- message: profile.note,
348
- riskFlags: action === "install" ? profile.installRiskFlags : [],
349
- requiresConfirmation: false,
350
- createdAt: nowForDto(),
351
- startedAt: null,
352
- firstOutputAt: null,
353
- lastOutputAt: null,
354
- finishedAt: null,
355
- retryCount: priorFailures,
356
- };
357
- writeJobLog(base, `created ${harness} ${action}${priorFailures > 0 ? ` (retry #${priorFailures})` : ""}`);
358
- if (action === "login") {
359
- if (!profile.loginCommand) {
360
- return save({
361
- ...base,
362
- state: "not_supported",
363
- finishedAt: nowForDto(),
364
- message: `${harness} has no native login command; store API-key fallback refs in Settings.`,
365
- });
438
+ }
439
+ function serializeTermination(jobId, task) {
440
+ const existing = terminations.get(jobId);
441
+ if (existing)
442
+ return existing;
443
+ const operation = supervisor.track(`terminate:${jobId}`, task, { safety: true });
444
+ terminations.set(jobId, operation);
445
+ const cleanup = () => {
446
+ if (terminations.get(jobId) === operation)
447
+ terminations.delete(jobId);
448
+ };
449
+ operation.then(cleanup, cleanup);
450
+ return operation;
451
+ }
452
+ function terminateLogin(jobId, reason) {
453
+ return serializeTermination(jobId, () => terminateLoginOnce(jobId, reason));
454
+ }
455
+ async function terminateLoginOnce(jobId, reason) {
456
+ const job = store.status(jobId);
457
+ if (TERMINAL_SETUP_STATES.has(job.state))
458
+ return job;
459
+ const result = matchingResult(jobId);
460
+ if (result)
461
+ persistNativeCommandOutcome(jobId, result);
462
+ else
463
+ update(jobId, { phase: "cancelling", message: `Stopping ${job.harness} login (${reason}).` });
464
+ verificationControllers.get(jobId)?.abort(new Error(`native login ${reason}`));
465
+ if (result) {
466
+ const stateName = reason === "timed_out" ? "timed_out" : "cancelled";
467
+ return finish(jobId, stateName, reason, `${job.harness} login ${reason}.`, {
468
+ exitCode: result.exitCode,
469
+ signal: result.signal,
470
+ });
471
+ }
472
+ const group = processGroupFromJob(job);
473
+ if (!group) {
474
+ const stateName = reason === "timed_out" ? "timed_out" : "cancelled";
475
+ return finish(jobId, stateName, reason, `${job.harness} login ${reason} before execution authorization.`);
476
+ }
477
+ const alreadyEmpty = processGroups.probeEmpty(group);
478
+ if (alreadyEmpty.status !== "empty") {
479
+ const term = processGroups.signal(group, "SIGTERM");
480
+ if (term.status === "unknown") {
481
+ return finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login TERM was refused: ${term.reason}.`);
482
+ }
483
+ if (!(await waitForGroupEmpty(group, terminationGraceMs))) {
484
+ const kill = processGroups.signal(group, "SIGKILL");
485
+ if (kill.status === "unknown") {
486
+ return finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login KILL was refused: ${kill.reason}.`);
366
487
  }
367
- const job = save({ ...base, command: profile.loginCommand });
368
- return startTerminalLoginJob(job, job.command ?? "");
369
488
  }
370
- if (action === "doctor") {
371
- const job = save({ ...base, command: inProcessDoctorLabel(harness) });
372
- return startInProcessDoctorJob(job);
489
+ if (!(await waitForGroupEmpty(group, terminationGraceMs))) {
490
+ return finish(jobId, "failed", "termination_unconfirmed", `${job.harness} login process group remained nonempty after SIGKILL.`);
373
491
  }
374
- if (action === "store_key") {
375
- // Honest message: this job only verifies via doctor. The key itself is
376
- // stored by the separate Settings store-secret call, never by this job.
377
- const job = save({
378
- ...base,
379
- command: inProcessDoctorLabel(harness),
380
- message: `Running post-store doctor for ${harness}. (The key itself is saved via Settings > secrets, not by this job.)`,
381
- });
382
- return startInProcessDoctorJob(job);
492
+ }
493
+ const stateName = reason === "timed_out" ? "timed_out" : "cancelled";
494
+ return finish(jobId, stateName, reason, `${job.harness} login ${reason}.`);
495
+ }
496
+ async function waitForGroupEmpty(handle, maxMs) {
497
+ let remaining = maxMs;
498
+ for (;;) {
499
+ const probe = processGroups.probeEmpty(handle);
500
+ if (probe.status === "empty")
501
+ return true;
502
+ if (probe.status === "unknown")
503
+ return false;
504
+ if (remaining <= 0)
505
+ return false;
506
+ const step = Math.min(100, remaining);
507
+ await sleep(step);
508
+ remaining -= step;
509
+ }
510
+ }
511
+ function markAwaitingUser(jobId) {
512
+ const current = store.status(jobId);
513
+ if (current.phase === "launching") {
514
+ update(jobId, {
515
+ phase: "awaiting_user",
516
+ message: `Complete ${current.harness} native login in Terminal.`,
517
+ });
518
+ }
519
+ }
520
+ async function monitorLogin(jobId) {
521
+ if (processing.has(jobId))
522
+ return;
523
+ const job = store.status(jobId);
524
+ if (!ACTIVE_SETUP_STATES.has(job.state) || job.phase === "cancelling")
525
+ return;
526
+ const result = matchingResult(jobId);
527
+ if (result) {
528
+ markAwaitingUser(jobId);
529
+ await consumeLoginResult(jobId, result);
530
+ return;
531
+ }
532
+ const state = matchingState(jobId);
533
+ if (state) {
534
+ await observeAndPermit(jobId, state);
535
+ }
536
+ else if (!state &&
537
+ job.phase === "launching" &&
538
+ job.startedAt &&
539
+ now().getTime() - Date.parse(job.startedAt) >= launcherTimeoutMs) {
540
+ finish(jobId, "failed", "launch_failed", `${job.harness} login worker did not provide an identity handshake within ${launcherTimeoutMs / 1000}s; vendor execution was never permitted.`);
541
+ return;
542
+ }
543
+ const current = store.status(jobId);
544
+ if (current.deadlineAt && now().getTime() >= Date.parse(current.deadlineAt))
545
+ await terminateLogin(jobId, "timed_out");
546
+ }
547
+ async function tick() {
548
+ await Promise.all(store.list({ active: true }).map((job) => monitorLogin(job.jobId)));
549
+ }
550
+ function settleDurableCapability(job, boundary) {
551
+ const lifecycle = job.authCapability;
552
+ const evidence = job.nativeCommand
553
+ ? { exitCode: job.nativeCommand.exitCode, signal: job.nativeCommand.signal }
554
+ : {};
555
+ if (lifecycle?.state === "completed") {
556
+ finishCapabilityReceipt(job.jobId, lifecycle.receipt, evidence);
557
+ try {
558
+ authCapabilityVerifier.cleanup(lifecycle.attemptId);
383
559
  }
384
- if (!profile.installCommand) {
385
- return save({
386
- ...base,
387
- state: "not_supported",
388
- finishedAt: nowForDto(),
389
- message: `${harness} has no native installer; use stored API-key fallback refs instead.`,
390
- });
560
+ catch {
561
+ /* receipt remains authoritative */
391
562
  }
392
- return save({
393
- ...base,
563
+ return true;
564
+ }
565
+ if (lifecycle?.state !== "running" && lifecycle?.state !== "interrupted_unknown")
566
+ return false;
567
+ if (lifecycle.state === "running") {
568
+ update(job.jobId, {
569
+ authCapability: { ...lifecycle, state: "interrupted_unknown", interruptedAt: iso() },
570
+ message: `${job.harness} capability smoke ended during daemon ${boundary} without a verifiable receipt.`,
571
+ });
572
+ }
573
+ finish(job.jobId, "interrupted_unknown", "interrupted_unknown", `${job.harness} capability smoke outcome is unknown after daemon ${boundary}; explicit retry is required.`, evidence);
574
+ logAfterMutation(job.jobId, `retained auth smoke scratch for ${lifecycle.attemptId} because execution termination is unproven`);
575
+ return true;
576
+ }
577
+ async function reconcileRestart() {
578
+ for (const job of store.list({ active: true })) {
579
+ if (settleDurableCapability(job, "restart"))
580
+ continue;
581
+ const result = matchingResult(job.jobId);
582
+ if (result) {
583
+ markAwaitingUser(job.jobId);
584
+ await consumeLoginResult(job.jobId, result);
585
+ continue;
586
+ }
587
+ const state = matchingState(job.jobId);
588
+ if (state) {
589
+ await observeAndPermit(job.jobId, state);
590
+ continue;
591
+ }
592
+ const group = processGroupFromJob(job);
593
+ if (!group) {
594
+ finish(job.jobId, "failed", "interrupted", `${job.harness} login stopped before execution authorization during daemon restart.`);
595
+ continue;
596
+ }
597
+ const empty = processGroups.probeEmpty(group);
598
+ if (empty.status === "empty") {
599
+ finish(job.jobId, "failed", "interrupted", `${job.harness} login process ended without a terminal receipt during daemon restart.`);
600
+ }
601
+ else {
602
+ finish(job.jobId, "failed", "termination_unconfirmed", `${job.harness} login has durable process evidence but its worker sidecar is unavailable after restart.`);
603
+ }
604
+ }
605
+ }
606
+ function startObservableLogin(job, spec, profileConfigDir) {
607
+ if (platform !== "darwin") {
608
+ return finish(job.jobId, "failed", "launch_failed", `Terminal-handoff login is macOS-only. Run it yourself, then recheck Harness Doctor: ${spec.displayCommand}`, {}, spec.displayCommand);
609
+ }
610
+ try {
611
+ const paths = store.paths(job.jobId);
612
+ const executionId = randomUUID();
613
+ const executable = captureExecutableEvidence(spec.binary);
614
+ const authorizedCommandDigest = commandDigest(executable, spec.args);
615
+ const manifest = sealLoginManifest({
616
+ version: SETUP_LOGIN_PROTOCOL_VERSION,
617
+ jobId: job.jobId,
618
+ executionId,
619
+ harness: job.harness,
620
+ jobDir: paths.dir,
621
+ binary: executable.realpath,
622
+ args: [...spec.args],
623
+ cwd: paths.dir,
624
+ // Present ONLY on profile jobs: absent keeps pre-upgrade manifests'
625
+ // sealed digests valid (the field is optional, never defaulted).
626
+ ...(profileConfigDir ? { profileConfigDir } : {}),
627
+ statePath: paths.runnerState,
628
+ resultPath: paths.runnerResult,
629
+ permitPath: paths.runnerPermit,
630
+ permitDeadlineAt: new Date(now().getTime() + launcherTimeoutMs).toISOString(),
631
+ executable,
632
+ commandDigest: authorizedCommandDigest,
633
+ });
634
+ atomicPrivateJson(paths.manifest, manifest);
635
+ const script = `#!/usr/bin/env bash\nset -uo pipefail\n${NativeLogin.nativeLoginTerminalExports(job.harness)}set +e\n${shellQuote(nodePath)} ${shellQuote(runnerPath)} ${shellQuote(paths.manifest)}\nstatus=$?\nset -e\nprintf '\\nClaudexor setup command finished (exit %s). Press Return to close this window. ' "$status"\nIFS= read -r _\nexit "$status"\n`;
636
+ writeFileSync(paths.command, script, { mode: 0o700, flag: "wx" });
637
+ chmodSync(paths.command, 0o700);
638
+ const waiting = update(job.jobId, {
394
639
  state: "waiting_for_input",
395
- command: profile.installCommand,
396
- requiresConfirmation: true,
397
- message: `Confirm to run allowlisted ${harness} installer. Risks: ${profile.installRiskFlags.join(", ") || "standard process execution"}.`,
640
+ phase: "launching",
641
+ deadlineAt: new Date(now().getTime() + loginTimeoutMs).toISOString(),
642
+ startedAt: iso(),
643
+ command: spec.displayCommand,
644
+ authorization: {
645
+ executionId,
646
+ executable,
647
+ args: [...spec.args],
648
+ commandDigest: authorizedCommandDigest,
649
+ manifestDigest: manifest.manifestDigest,
650
+ },
651
+ message: `Opening allowlisted ${job.harness} native login in Terminal.`,
652
+ });
653
+ log(job.jobId, `Terminal script: ${paths.command}`);
654
+ const opener = openTerminal(paths.command);
655
+ const failLaunch = (detail) => {
656
+ if (!["idle", "healthy"].includes(supervisor.health().state))
657
+ return;
658
+ const current = store.status(job.jobId);
659
+ if (current.phase !== "launching")
660
+ return;
661
+ finish(job.jobId, "failed", "launch_failed", `Could not open Terminal for ${job.harness} login: ${detail}.`, {}, spec.displayCommand);
662
+ };
663
+ opener.once("error", (err) => failLaunch(err.message));
664
+ opener.once("exit", (code, signal) => {
665
+ if (code !== 0)
666
+ failLaunch(signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`);
398
667
  });
668
+ opener.unref();
669
+ return waiting;
670
+ }
671
+ catch (err) {
672
+ return finish(job.jobId, "failed", "launch_failed", `Could not prepare or open Terminal for ${job.harness} login: ${err instanceof Error ? err.message : String(err)}.`, {}, spec.displayCommand);
673
+ }
674
+ }
675
+ supervisor = new SetupSupervisor({
676
+ pollMs: opts.monitorPollMs ?? 250,
677
+ recoveryRequired: () => store.recoveryState().status === "recovery_required",
678
+ reconcile: reconcileRestart,
679
+ tick,
680
+ abortInFlight: (reason) => {
681
+ for (const controller of verificationControllers.values())
682
+ controller.abort(reason);
399
683
  },
400
- confirm(input) {
401
- const p = (input ?? {});
402
- const jobId = typeof p["jobId"] === "string" ? p["jobId"] : "";
403
- const confirmed = p["confirmed"] !== false;
404
- const job = jobs.get(jobId);
405
- if (!job)
406
- throw Object.assign(new Error("setup job not found"), { status: 404 });
407
- if (!confirmed)
408
- return job;
409
- if (!job.requiresConfirmation || job.action !== "install" || !job.command) {
410
- throw new Error("setup job does not require confirmation");
684
+ });
685
+ let shutdownPromise = null;
686
+ function beginDrain() {
687
+ supervisor.beginDrain();
688
+ }
689
+ function shutdown() {
690
+ shutdownPromise ??= (async () => {
691
+ beginDrain();
692
+ let firstError;
693
+ let unconfirmed;
694
+ const rememberError = (error) => {
695
+ firstError ??= error;
696
+ supervisor.reportFailure(error, "setup-shutdown");
697
+ };
698
+ try {
699
+ const active = store.recoveryState().status === "ready" ? store.list({ active: true }) : [];
700
+ const waitingLogins = active.filter((job) => job.authCapability?.state !== "running" &&
701
+ job.authCapability?.state !== "completed" &&
702
+ job.authCapability?.state !== "interrupted_unknown");
703
+ const stopped = await Promise.allSettled(waitingLogins.map((job) => terminateLogin(job.jobId, "cancelled_on_restart")));
704
+ for (const result of stopped) {
705
+ if (result.status === "rejected")
706
+ rememberError(result.reason);
707
+ else if (result.value.outcome?.reason === "termination_unconfirmed") {
708
+ unconfirmed ??= result.value;
709
+ }
710
+ }
711
+ }
712
+ catch (error) {
713
+ rememberError(error);
714
+ }
715
+ await supervisor.shutdown();
716
+ try {
717
+ if (store.recoveryState().status === "ready") {
718
+ for (const job of store.list({ active: true })) {
719
+ settleDurableCapability(job, "shutdown");
720
+ }
721
+ }
722
+ }
723
+ catch (error) {
724
+ rememberError(error);
411
725
  }
412
- if (job.state !== "waiting_for_input") {
413
- throw new Error(`setup job cannot be confirmed while ${job.state}`);
726
+ if (unconfirmed) {
727
+ throw Object.assign(new Error(`setup shutdown could not prove termination for ${unconfirmed.jobId}`), { code: "setup_shutdown_unconfirmed", status: 503 });
414
728
  }
415
- const ready = save({ ...job, requiresConfirmation: false });
416
- // Shell phase = the native installer only; verification runs in-process.
417
- return startShellJob(ready, ready.command ?? "", runInProcessDoctor);
729
+ if (firstError !== undefined)
730
+ throw firstError;
731
+ })();
732
+ return shutdownPromise;
733
+ }
734
+ return {
735
+ start: () => supervisor.start(),
736
+ beginDrain,
737
+ shutdown,
738
+ create(input, idempotency) {
739
+ const request = ControlSetupJobCreateRequest.parse(input);
740
+ const { harness, action } = request;
741
+ const profileBinding = resolveProfileBinding(harness, request.profileId);
742
+ const binding = idempotency ? { ...idempotency, request } : undefined;
743
+ const prior = binding ? store.resolveCreate(binding) : null;
744
+ if (prior)
745
+ return prior;
746
+ supervisor.assertCreateAllowed();
747
+ const jobs = store.list({ harness });
748
+ const active = jobs.findLast((job) => ACTIVE_SETUP_STATES.has(job.state));
749
+ if (active) {
750
+ // Same target store → idempotent reuse. A DIFFERENT target (default vs
751
+ // profile, or two profiles) must refuse loudly: returning the other job
752
+ // would hand the caller a login into the wrong store.
753
+ if ((active.profileId ?? null) === (profileBinding?.profileId ?? null)) {
754
+ return binding ? store.bindCreate(active.jobId, binding) : active;
755
+ }
756
+ throw Object.assign(new Error(`another ${harness} login job is active (${active.jobId}, ${active.profileId ? `profile "${active.profileId}"` : "default store"}); finish or cancel it before starting a ${profileBinding ? `profile "${profileBinding.profileId}"` : "default-store"} login`), { status: 409 });
757
+ }
758
+ const replacementFence = jobs.findLast((job) => job.outcome?.reason === "termination_unconfirmed" && !job.terminationReconciliation);
759
+ if (replacementFence) {
760
+ return binding ? store.bindCreate(replacementFence.jobId, binding) : replacementFence;
761
+ }
762
+ const profile = SETUP_PROFILES[harness];
763
+ const jobId = `setup-${now().getTime().toString(36)}-${randomUUID().slice(0, 8)}`;
764
+ const authCapability = {
765
+ ...authCapabilityVerifier.prepare({
766
+ attemptId: `auth-${randomUUID()}`,
767
+ harness,
768
+ requested: "subscription",
769
+ requiredRoute: "vendor_native",
770
+ requiredSource: "native_session",
771
+ }).binding,
772
+ state: "disclosed",
773
+ };
774
+ const base = store.create({
775
+ jobId,
776
+ harness,
777
+ action,
778
+ state: "queued",
779
+ phase: "preparing",
780
+ command: null,
781
+ guideUrl: profile.guideUrl,
782
+ message: profileBinding
783
+ ? `${profile.note} This login targets the scoped Claudexor profile "${profileBinding.profileId}" (INV-135); the default vendor store is never touched.`
784
+ : `${profile.note} The required same-harness smoke may consume quota; incremental billing is unknown.`,
785
+ createdAt: iso(),
786
+ startedAt: null,
787
+ finishedAt: null,
788
+ authCapability,
789
+ profileId: profileBinding?.profileId ?? null,
790
+ }, binding);
791
+ log(jobId, `created ${harness} ${action}${profileBinding ? ` for profile "${profileBinding.profileId}"` : ""}`);
792
+ const spec = NativeLogin.nativeLoginSpec(harness);
793
+ if (!spec)
794
+ return finish(jobId, "not_supported", "not_supported", `${harness} native login is unavailable; install the vendor CLI first.`);
795
+ return startObservableLogin(base, spec, profileBinding?.configDir);
418
796
  },
419
- list() {
420
- return [...jobs.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
797
+ list(filter) {
798
+ return store.list(filter);
421
799
  },
422
800
  status(input) {
423
801
  const p = (input ?? {});
424
- const jobId = typeof p["jobId"] === "string" ? p["jobId"] : "";
425
- const job = jobs.get(jobId);
426
- if (!job)
427
- throw Object.assign(new Error("setup job not found"), { status: 404 });
428
- return job;
802
+ return store.status(typeof p.jobId === "string" ? p.jobId : "");
803
+ },
804
+ snapshot(input) {
805
+ const p = (input ?? {});
806
+ return store.snapshot(typeof p.jobId === "string" ? p.jobId : "");
429
807
  },
430
- cancel(input) {
808
+ events(input) {
431
809
  const p = (input ?? {});
432
- const jobId = typeof p["jobId"] === "string" ? p["jobId"] : "";
433
- const job = jobs.get(jobId);
434
- if (!job)
435
- throw Object.assign(new Error("setup job not found"), { status: 404 });
436
- // A terminal job has nothing to cancel; overwriting `succeeded`/`failed`
437
- // with `cancelled` would falsify history (the Terminal-handoff child
438
- // handlers already refuse this overwrite the endpoint must too).
439
- // Idempotent: return the terminal record unchanged.
440
- if (job.state === "succeeded" || job.state === "failed" || job.state === "cancelled" || job.state === "not_supported") {
810
+ const jobId = typeof p.jobId === "string" ? p.jobId : "";
811
+ const afterCursor = typeof p.afterCursor === "string" ? p.afterCursor : null;
812
+ return store.events(jobId, afterCursor);
813
+ },
814
+ async cancel(input) {
815
+ const p = (input ?? {});
816
+ const jobId = typeof p.jobId === "string" ? p.jobId : "";
817
+ const job = store.status(jobId);
818
+ if (TERMINAL_SETUP_STATES.has(job.state))
441
819
  return job;
820
+ return terminateLogin(jobId, "cancelled_by_user");
821
+ },
822
+ reconcile(input) {
823
+ supervisor.assertCreateAllowed();
824
+ const p = (input ?? {});
825
+ const jobId = typeof p.jobId === "string" ? p.jobId : "";
826
+ const job = store.status(jobId);
827
+ if (job.terminationReconciliation)
828
+ return job;
829
+ if (job.outcome?.reason !== "termination_unconfirmed") {
830
+ throw Object.assign(new Error("setup job has no unconfirmed termination to reconcile"), {
831
+ code: "setup_reconciliation_not_applicable",
832
+ status: 409,
833
+ });
442
834
  }
443
- const child = children.get(jobId);
444
- if (child) {
445
- child.kill("SIGTERM");
446
- children.delete(jobId);
835
+ const group = processGroupFromJob(job);
836
+ if (!group || processGroups.probeEmpty(group).status !== "empty") {
837
+ throw Object.assign(new Error("setup process group is not proven empty"), {
838
+ code: "setup_termination_unconfirmed",
839
+ status: 409,
840
+ requiredActions: ["retry_setup_reconciliation"],
841
+ });
447
842
  }
448
- clearTimeout(timeouts.get(jobId));
449
- timeouts.delete(jobId);
450
- const cancelled = update(jobId, { state: "cancelled", finishedAt: nowForDto(), message: `Cancelled ${job.harness} ${job.action} setup job.` });
451
- writeJobLog(cancelled, cancelled.message);
452
- return cancelled;
843
+ return update(jobId, {
844
+ terminationReconciliation: { status: "empty", observedAt: iso() },
845
+ message: `${job.harness} login process group is confirmed empty; replacement is allowed.`,
846
+ });
453
847
  },
848
+ extend(input) {
849
+ supervisor.assertCreateAllowed();
850
+ const p = (input ?? {});
851
+ const jobId = typeof p.jobId === "string" ? p.jobId : "";
852
+ const job = store.status(jobId);
853
+ if (!ACTIVE_SETUP_STATES.has(job.state) ||
854
+ !["launching", "awaiting_user"].includes(job.phase ?? "") ||
855
+ !job.deadlineAt) {
856
+ throw Object.assign(new Error("setup job cannot be extended"), { status: 409 });
857
+ }
858
+ return update(jobId, {
859
+ deadlineAt: new Date(Date.parse(job.deadlineAt) + LOGIN_EXTENSION_MS).toISOString(),
860
+ message: `${job.harness} login deadline extended by 15 minutes.`,
861
+ });
862
+ },
863
+ _store: store,
864
+ _supervisorHealth: () => supervisor.health(),
454
865
  };
455
866
  }
456
- function setupJobEnv() {
457
- const runtime = harnessRuntimeEnv();
458
- const env = {
459
- HOME: runtime.HOME,
460
- PATH: runtime.PATH,
461
- SHELL: process.env.SHELL ?? "/bin/bash",
462
- TMPDIR: process.env.TMPDIR,
463
- LANG: process.env.LANG,
464
- LC_ALL: process.env.LC_ALL,
465
- USER: process.env.USER,
466
- LOGNAME: process.env.LOGNAME,
467
- };
468
- for (const key of Object.keys(env)) {
469
- if (env[key] === undefined)
470
- delete env[key];
471
- }
472
- return env;
473
- }
474
- function nowForDto() {
475
- return new Date().toISOString();
476
- }
477
867
  //# sourceMappingURL=setup-jobs.js.map