@claudexor/cli 1.0.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-surface-runner.d.ts +17 -0
- package/dist/acp-surface-runner.d.ts.map +1 -0
- package/dist/acp-surface-runner.js +187 -0
- package/dist/acp-surface-runner.js.map +1 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js.map +1 -1
- package/dist/bridge-serve.d.ts +3 -0
- package/dist/bridge-serve.d.ts.map +1 -0
- package/dist/bridge-serve.js +39 -0
- package/dist/bridge-serve.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +12 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/claude-oauth-usage.d.ts +33 -0
- package/dist/claude-oauth-usage.d.ts.map +1 -0
- package/dist/claude-oauth-usage.js +190 -0
- package/dist/claude-oauth-usage.js.map +1 -0
- package/dist/claude-statusline.d.ts +25 -0
- package/dist/claude-statusline.d.ts.map +1 -0
- package/dist/claude-statusline.js +259 -0
- package/dist/claude-statusline.js.map +1 -0
- package/dist/claudexord.js +346 -543
- package/dist/claudexord.js.map +1 -1
- package/dist/cli-io.d.ts +8 -9
- package/dist/cli-io.d.ts.map +1 -1
- package/dist/cli-io.js +11 -7
- package/dist/cli-io.js.map +1 -1
- package/dist/cli.js +475 -516
- package/dist/cli.js.map +1 -1
- package/dist/codex-quota-source.d.ts +11 -0
- package/dist/codex-quota-source.d.ts.map +1 -0
- package/dist/codex-quota-source.js +181 -0
- package/dist/codex-quota-source.js.map +1 -0
- package/dist/command-flags.d.ts +18 -0
- package/dist/command-flags.d.ts.map +1 -0
- package/dist/command-flags.js +124 -0
- package/dist/command-flags.js.map +1 -0
- package/dist/command-registry.d.ts +2 -40
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +74 -164
- package/dist/command-registry.js.map +1 -1
- package/dist/control-services.d.ts +2490 -0
- package/dist/control-services.d.ts.map +1 -0
- package/dist/control-services.js +453 -0
- package/dist/control-services.js.map +1 -0
- package/dist/credential-commands.d.ts +9 -0
- package/dist/credential-commands.d.ts.map +1 -0
- package/dist/credential-commands.js +224 -0
- package/dist/credential-commands.js.map +1 -0
- package/dist/daemon-lifecycle.d.ts +7 -3
- package/dist/daemon-lifecycle.d.ts.map +1 -1
- package/dist/daemon-lifecycle.js +40 -12
- package/dist/daemon-lifecycle.js.map +1 -1
- package/dist/daemon-run.d.ts +2 -0
- package/dist/daemon-run.d.ts.map +1 -1
- package/dist/daemon-run.js +62 -14
- package/dist/daemon-run.js.map +1 -1
- package/dist/daemon-runtime-shutdown.d.ts +58 -0
- package/dist/daemon-runtime-shutdown.d.ts.map +1 -0
- package/dist/daemon-runtime-shutdown.js +142 -0
- package/dist/daemon-runtime-shutdown.js.map +1 -0
- package/dist/decision.d.ts.map +1 -1
- package/dist/decision.js +12 -3
- package/dist/decision.js.map +1 -1
- package/dist/live.d.ts +29 -0
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +117 -6
- package/dist/live.js.map +1 -1
- package/dist/local-attachment.d.ts +14 -0
- package/dist/local-attachment.d.ts.map +1 -0
- package/dist/local-attachment.js +67 -0
- package/dist/local-attachment.js.map +1 -0
- package/dist/mcp-runner.d.ts +4 -15
- package/dist/mcp-runner.d.ts.map +1 -1
- package/dist/mcp-runner.js +276 -145
- package/dist/mcp-runner.js.map +1 -1
- package/dist/native-login.d.ts +21 -0
- package/dist/native-login.d.ts.map +1 -0
- package/dist/native-login.js +110 -0
- package/dist/native-login.js.map +1 -0
- package/dist/ops-commands.d.ts +8 -6
- package/dist/ops-commands.d.ts.map +1 -1
- package/dist/ops-commands.js +250 -168
- package/dist/ops-commands.js.map +1 -1
- package/dist/orchestrate-options.d.ts.map +1 -1
- package/dist/orchestrate-options.js +1 -1
- package/dist/orchestrate-options.js.map +1 -1
- package/dist/orphan-reaper.d.ts +4 -5
- package/dist/orphan-reaper.d.ts.map +1 -1
- package/dist/orphan-reaper.js +29 -56
- package/dist/orphan-reaper.js.map +1 -1
- package/dist/orphan-sweeper.d.ts +1 -2
- package/dist/orphan-sweeper.d.ts.map +1 -1
- package/dist/orphan-sweeper.js +29 -48
- package/dist/orphan-sweeper.js.map +1 -1
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +196 -64
- package/dist/plugins.js.map +1 -1
- package/dist/primary-output.d.ts.map +1 -1
- package/dist/primary-output.js.map +1 -1
- package/dist/profile-compatibility.d.ts +6 -0
- package/dist/profile-compatibility.d.ts.map +1 -0
- package/dist/profile-compatibility.js +21 -0
- package/dist/profile-compatibility.js.map +1 -0
- package/dist/profile-registration.d.ts +15 -0
- package/dist/profile-registration.d.ts.map +1 -0
- package/dist/profile-registration.js +68 -0
- package/dist/profile-registration.js.map +1 -0
- package/dist/project-command.d.ts +3 -0
- package/dist/project-command.d.ts.map +1 -0
- package/dist/project-command.js +87 -0
- package/dist/project-command.js.map +1 -0
- package/dist/quota-command.d.ts +3 -0
- package/dist/quota-command.d.ts.map +1 -0
- package/dist/quota-command.js +59 -0
- package/dist/quota-command.js.map +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +16 -4
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +15 -3
- package/dist/release.js.map +1 -1
- package/dist/repl.d.ts +3 -8
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +23 -122
- package/dist/repl.js.map +1 -1
- package/dist/request-preflight.d.ts +13 -0
- package/dist/request-preflight.d.ts.map +1 -0
- package/dist/request-preflight.js +81 -0
- package/dist/request-preflight.js.map +1 -0
- package/dist/retention-service.d.ts +24 -0
- package/dist/retention-service.d.ts.map +1 -0
- package/dist/retention-service.js +100 -0
- package/dist/retention-service.js.map +1 -0
- package/dist/retry-command-specs.d.ts +3 -0
- package/dist/retry-command-specs.d.ts.map +1 -0
- package/dist/retry-command-specs.js +21 -0
- package/dist/retry-command-specs.js.map +1 -0
- package/dist/retry-command.d.ts +4 -0
- package/dist/retry-command.d.ts.map +1 -0
- package/dist/retry-command.js +57 -0
- package/dist/retry-command.js.map +1 -0
- package/dist/review-command.d.ts.map +1 -1
- package/dist/review-command.js +47 -12
- package/dist/review-command.js.map +1 -1
- package/dist/run-options.d.ts +2 -2
- package/dist/run-options.d.ts.map +1 -1
- package/dist/run-options.js +27 -5
- package/dist/run-options.js.map +1 -1
- package/dist/settings-command.d.ts.map +1 -1
- package/dist/settings-command.js +143 -188
- package/dist/settings-command.js.map +1 -1
- package/dist/settings-service.d.ts +50 -0
- package/dist/settings-service.d.ts.map +1 -1
- package/dist/settings-service.js +67 -1
- package/dist/settings-service.js.map +1 -1
- package/dist/setup-job-reducer.d.ts +9 -0
- package/dist/setup-job-reducer.d.ts.map +1 -0
- package/dist/setup-job-reducer.js +290 -0
- package/dist/setup-job-reducer.js.map +1 -0
- package/dist/setup-job-store.d.ts +250 -0
- package/dist/setup-job-store.d.ts.map +1 -0
- package/dist/setup-job-store.js +359 -0
- package/dist/setup-job-store.js.map +1 -0
- package/dist/setup-job-support.d.ts +27 -0
- package/dist/setup-job-support.d.ts.map +1 -0
- package/dist/setup-job-support.js +141 -0
- package/dist/setup-job-support.js.map +1 -0
- package/dist/setup-jobs.d.ts +437 -22
- package/dist/setup-jobs.d.ts.map +1 -1
- package/dist/setup-jobs.js +813 -423
- package/dist/setup-jobs.js.map +1 -1
- package/dist/setup-lifecycle-binding.d.ts +40 -0
- package/dist/setup-lifecycle-binding.d.ts.map +1 -0
- package/dist/setup-lifecycle-binding.js +159 -0
- package/dist/setup-lifecycle-binding.js.map +1 -0
- package/dist/setup-login-protocol.d.ts +13 -0
- package/dist/setup-login-protocol.d.ts.map +1 -0
- package/dist/setup-login-protocol.js +179 -0
- package/dist/setup-login-protocol.js.map +1 -0
- package/dist/setup-login-runner.d.ts +20 -0
- package/dist/setup-login-runner.d.ts.map +1 -0
- package/dist/setup-login-runner.js +225 -0
- package/dist/setup-login-runner.js.map +1 -0
- package/dist/setup-supervisor.d.ts +53 -0
- package/dist/setup-supervisor.d.ts.map +1 -0
- package/dist/setup-supervisor.js +177 -0
- package/dist/setup-supervisor.js.map +1 -0
- package/dist/spec-thread-scope.d.ts +3 -0
- package/dist/spec-thread-scope.d.ts.map +1 -0
- package/dist/spec-thread-scope.js +9 -0
- package/dist/spec-thread-scope.js.map +1 -0
- package/dist/spec.d.ts +9 -20
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +49 -21
- package/dist/spec.js.map +1 -1
- package/dist/thread-delivery.d.ts +18 -0
- package/dist/thread-delivery.d.ts.map +1 -0
- package/dist/thread-delivery.js +67 -0
- package/dist/thread-delivery.js.map +1 -0
- package/dist/thread-select.d.ts +9 -0
- package/dist/thread-select.d.ts.map +1 -0
- package/dist/thread-select.js +12 -0
- package/dist/thread-select.js.map +1 -0
- package/dist/trust-command.d.ts.map +1 -1
- package/dist/trust-command.js +69 -61
- package/dist/trust-command.js.map +1 -1
- package/dist/trust-services.d.ts +5 -6
- package/dist/trust-services.d.ts.map +1 -1
- package/dist/trust-services.js +42 -13
- package/dist/trust-services.js.map +1 -1
- package/package.json +24 -22
- package/dist/attachment-resolver.d.ts +0 -11
- package/dist/attachment-resolver.d.ts.map +0 -1
- package/dist/attachment-resolver.js +0 -61
- package/dist/attachment-resolver.js.map +0 -1
- package/dist/settings-display.d.ts +0 -3
- package/dist/settings-display.d.ts.map +0 -1
- package/dist/settings-display.js +0 -33
- package/dist/settings-display.js.map +0 -1
package/dist/setup-jobs.js
CHANGED
|
@@ -1,477 +1,867 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { chmodSync,
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
+
authCapabilityVerifier.cleanup(lifecycle.attemptId);
|
|
98
190
|
}
|
|
99
191
|
catch {
|
|
100
|
-
/*
|
|
192
|
+
/* receipt remains authoritative */
|
|
101
193
|
}
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
message:
|
|
128
|
-
? `
|
|
129
|
-
: `
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 (
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 (
|
|
413
|
-
throw new Error(`setup
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
797
|
+
list(filter) {
|
|
798
|
+
return store.list(filter);
|
|
421
799
|
},
|
|
422
800
|
status(input) {
|
|
423
801
|
const p = (input ?? {});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return
|
|
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
|
-
|
|
808
|
+
events(input) {
|
|
431
809
|
const p = (input ?? {});
|
|
432
|
-
const jobId = typeof p
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (
|
|
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
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|