diff --git a/skills/autopilot/README.md b/skills/autopilot/README.md index d55bdb7..fe588ea 100644 --- a/skills/autopilot/README.md +++ b/skills/autopilot/README.md @@ -49,7 +49,7 @@ Return to the same repository and ask Autopilot directly: After explicit `/autopilot` invocation, natural lifecycle wording works too: `/autopilot What happened overnight?`, `/autopilot Continue the interrupted work`, `/autopilot Fix and resolve the PR comments`, or `/autopilot Clean up the merged run and leave a handoff`. Autopilot discovers the current project’s run. If more than one run matches, it lists short choices and changes nothing until you select one. -`status` reports the last durable milestone, unmet predicate identities, remaining budgets, and next legal action from the journal and receipts. `pause` asks the lock-owning coordinator to cancel active implementation work, prove quiescence, retire the exact lease, and enter nonterminal `WAITING`; cancellation solely for pause does not consume the attempt budget. `resume` reconciles a paused or interrupted nonterminal run. A verified item continues from its durable checkpoint and confirmed effects without rerunning implementation. On supported POSIX hosts, an interrupted supervised implementation execution reattaches through exact attempt artifacts and reaches terminal quiescence before any replacement; legacy, review, or incomplete supervisor state fails closed as `EXECUTION_STATE_UNKNOWN`. `address review comments` snapshots unresolved feedback from the exact open PR/MR, creates a verified amendment successor, updates the same branch, and resolves provider-resolvable threads only after the fix passes. `stop` deliberately ends a run and preserves its work; a stopped run cannot be resumed and requires a sealed successor. `wrap up` is destructive: after live provider checks, it removes the exact remote branches, clean worktrees, local branches, and run-state chain. A handoff writes Markdown and JSON summaries under `.autopilot/handoffs/` before cleanup. +`status` reports the last durable milestone, unmet predicate identities, remaining budgets, and next legal action from the journal and receipts. `pause` asks the lock-owning coordinator to cancel active implementation work, prove quiescence, retire the exact lease, and enter nonterminal `WAITING`; cancellation solely for pause does not consume the attempt budget. `resume` reconciles a paused or interrupted nonterminal run. A verified item continues from its durable checkpoint and confirmed effects without rerunning implementation. On supported POSIX hosts, an interrupted supervised implementation execution reattaches through exact attempt artifacts and reaches terminal quiescence before any replacement. Autopilot packages no Windows native containment helper; direct Windows CLI execution is session-scoped, and continuity loss fails closed as `EXECUTION_STATE_UNKNOWN`, as do legacy, review, or incomplete supervisor states. `address review comments` snapshots unresolved feedback from the exact open PR/MR, creates a verified amendment successor, updates the same branch, and resolves provider-resolvable threads only after the fix passes. `stop` deliberately ends a run and preserves its work; a stopped run cannot be resumed and requires a sealed successor. `wrap up` is destructive: after live provider checks, it removes the exact remote branches, clean worktrees, local branches, and run-state chain. A handoff writes Markdown and JSON summaries under `.autopilot/handoffs/` before cleanup. ## Read more diff --git a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md index ac0de7a..b7c40aa 100644 --- a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md +++ b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md @@ -487,7 +487,7 @@ skills/autopilot/runtime/test/fault-injection.test.ts ## Phase 7: Finish documentation and packaging -**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 200 Node tests locally, including the later Pi process-local backend; the earlier 162-test and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. +**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 187 Node tests locally after intentional removal of 13 native-helper-specific tests and their production surface. The earlier 200-test Pi process-local, 162-test, and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. ### Files diff --git a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md index 63b399f..5803fe8 100644 --- a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md +++ b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md @@ -1,6 +1,6 @@ # Cooperative harness execution implementation plan -- **Status:** Approved design; PR 1 decision/promotion shutdown, PR 2 execution assurance, PR 3 fenced unknown-execution recovery, and PR 4 Pi process-local integration are complete; Windows native-path removal remains pending +- **Status:** Implemented through PR 5: decision/promotion shutdown, execution assurance, fenced unknown-execution recovery, Pi process-local integration, and Windows native-path removal are complete; later provider investigations remain separate - **Date:** 2026-08-31 - **Audience:** Autopilot implementers and reviewers - **Related:** [Architecture](architecture.md), [continuity implementation plan](2026-08-30-continuity-evidence-implementation-plan.md), [durable event engine ADR](adr/0001-durable-event-engine.md) @@ -337,6 +337,8 @@ Validation in a disposable repository: ### PR 5: Remove Windows native containment +**Status:** Completed. + **Objective:** Complete the binary-removal boundary after cooperative behavior is available. Remove: diff --git a/skills/autopilot/docs/README.md b/skills/autopilot/docs/README.md index ae69881..20b1acb 100644 --- a/skills/autopilot/docs/README.md +++ b/skills/autopilot/docs/README.md @@ -39,4 +39,4 @@ Both figures use the vendored Diagram Design default profile. The PNG exports ar ## Verification status -The runtime has 200 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, descendant process cancellation, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository; disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on authorized private disposable targets with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on authorized private disposable targets with `glab` 1.115.0. +The runtime has 187 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, POSIX descendant process cancellation, native-free package inventory, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository; disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `glab` 1.115.0. diff --git a/skills/autopilot/docs/adr/0002-use-cooperative-harness-execution-on-windows.md b/skills/autopilot/docs/adr/0002-use-cooperative-harness-execution-on-windows.md index 1a4c787..701198a 100644 --- a/skills/autopilot/docs/adr/0002-use-cooperative-harness-execution-on-windows.md +++ b/skills/autopilot/docs/adr/0002-use-cooperative-harness-execution-on-windows.md @@ -88,11 +88,12 @@ Costs: ## Follow-up -Implementation follows the ordered boundaries in the cooperative harness execution plan: - -1. record this decision and stop native artifact promotion; -2. separate continuity from quiescence in the adapter and journal contracts; -3. add fenced abandon, adopt, and stop recovery; -4. implement and fault-test Pi structured delegation in-process; -5. remove the retained Windows helper source, build, protocol, and test surfaces; -6. investigate other harnesses independently after Pi proves the boundary. +The first five ordered boundaries in the cooperative harness execution plan are complete: + +1. this decision stopped native artifact promotion; +2. the adapter and journal contracts separate continuity from quiescence; +3. fenced abandon, adopt, and stop recovery is available; +4. Pi structured delegation runs in-process with fault coverage; +5. the retained Windows helper source, build, protocol, and test surfaces have been removed. + +Codex, OpenCode, and Claude harness-owned execution remain separate investigations. No provider inherits Pi's assurance without equivalent evidence. diff --git a/skills/autopilot/docs/architecture.md b/skills/autopilot/docs/architecture.md index 62ab672..19eadd5 100644 --- a/skills/autopilot/docs/architecture.md +++ b/skills/autopilot/docs/architecture.md @@ -1,6 +1,6 @@ # Harness-agnostic Autopilot design -- **Status:** Developer-preview implementation available; POSIX attempt-scoped process reattachment, fenced unknown recovery, Pi process-local cooperative execution, and controlled-fixture sealed restack successors are packaged. Windows native-path removal remains pending; notification wake is not promoted, and live restack mutation remains unverified without renewed target authority. +- **Status:** Developer-preview implementation available; POSIX attempt-scoped process reattachment, fenced unknown recovery, Pi process-local cooperative execution, native-free Windows packaging, and controlled-fixture sealed restack successors are complete. Notification wake is not promoted, and live restack mutation remains unverified without renewed target authority. - **Date:** 2026-08-22 - **Audience:** Coding-harness maintainers and adapter authors - **Implementation plan:** [Autopilot implementation plan](implementation-plan.md) @@ -297,7 +297,7 @@ interface ExecutionAssurance { `restartReattachment` remains a protocol-v1 compatibility field for older manifests and journals. New attempts persist their selected assurance before launch in `ATTEMPT_STARTED`, then persist the exact adapter, backend, and subject identity in `ATTEMPT_EXECUTION_ADMITTED`. A lost single-shot admission or cooperative continuity boundary becomes `EXECUTION_STATE_UNKNOWN`; the engine never converts a missing reattachment into a new launch. -Adapters return observations. They never write the journal or choose lifecycle transitions. On POSIX hosts, built-in CLI implementation executions run beneath a detached, attempt-scoped supervisor that owns the harness pipes and bounded output/activity capture. Before harness launch, a separately detached watchdog durably confirms readiness. The harness then joins the supervisor's known process group. All terminal publication is a watchdog-owned handshake: the supervisor publishes a bounded completion candidate, the watchdog terminates and confirms the group is quiescent, and only then publishes the durable result and terminal status. This also covers supervisor exit before child-identity publication. The reviewed Windows x64 Job Object helper will not be packaged because a custom process-management executable creates antivirus, application-reputation, architecture, and provenance risk. The source and runtime path remain temporarily for ordered removal, but the artifact-producing workflow is gone and Windows continues to report restart reattachment as unsupported. Windows currently uses `taskkill` only for the existing session-scoped direct-execution fallback. The approved replacement is version-pinned cooperative harness execution: only an exact terminal response from the uninterrupted harness instance may proceed to repository verification. Harness loss becomes `EXECUTION_STATE_UNKNOWN`, launches no replacement, and requires operator recovery. This boundary does not prove process-tree quiescence. The supervisor writes only fenced operational artifacts under `runs//executions//`; it cannot write `events.jsonl`, receipts, leases, snapshots, or Git state. On supported POSIX attempts, a fresh coordinator reconstructs the exact request from the journaled attempt and immutable context, reattaches to running or terminal supervisor artifacts, and waits for terminal process-tree evidence before allowing a replacement attempt. Review executions remain session-scoped. +Adapters return observations. They never write the journal or choose lifecycle transitions. On POSIX hosts, built-in CLI implementation executions run beneath a detached, attempt-scoped supervisor that owns the harness pipes and bounded output/activity capture. Before harness launch, a separately detached watchdog durably confirms readiness. The harness then joins the supervisor's known process group. All terminal publication is a watchdog-owned handshake: the supervisor publishes a bounded completion candidate, the watchdog terminates and confirms the group is quiescent, and only then publishes the durable result and terminal status. This also covers supervisor exit before child-identity publication. The reviewed Windows x64 Job Object helper was not packaged because a custom process-management executable creates antivirus, application-reputation, architecture, and provenance risk. Its source, runtime path, build scripts, and native-specific tests have been removed, and Windows reports restart reattachment as unsupported. Windows uses `taskkill` only for best-effort cancellation of the existing session-scoped direct-execution fallback; failed cancellation cannot prove terminality. The approved replacement is version-pinned cooperative harness execution: only an exact terminal response from the uninterrupted harness instance may proceed to repository verification. Harness loss becomes `EXECUTION_STATE_UNKNOWN`, launches no replacement, and requires operator recovery. This boundary does not prove process-tree quiescence. The supervisor writes only fenced operational artifacts under `runs//executions//`; it cannot write `events.jsonl`, receipts, leases, snapshots, or Git state. On supported POSIX attempts, a fresh coordinator reconstructs the exact request from the journaled attempt and immutable context, reattaches to running or terminal supervisor artifacts, and waits for terminal process-tree evidence before allowing a replacement attempt. Review executions remain session-scoped. The runtime inspects the real worktree after an agent exits. Unexpected commits, refs, or out-of-scope edits become reconciliation findings. diff --git a/skills/autopilot/docs/getting-started.md b/skills/autopilot/docs/getting-started.md index 5cbfdb9..0dbfa3a 100644 --- a/skills/autopilot/docs/getting-started.md +++ b/skills/autopilot/docs/getting-started.md @@ -70,7 +70,7 @@ Natural requests work too: `status` rebuilds progress from the sealed charter, hash-linked journal, Git identities, and receipts. It reports the last durable milestone, unmet predicate identities, normalized failure, remaining budgets, repeated no-change attempts, and next legal action. `pause` asks the live coordinator to cancel active implementation work, prove quiescence, retire the exact lease, and enter nonterminal waiting. A cancellation caused solely by pause remains auditable but does not consume an attempt. `resume` continues a paused or interrupted nonterminal run within its original limits. Verified items reconcile their checkpoint and effects without rerunning implementation. It does not restart a run that still has a live coordinator. `stop` asks a live coordinator to cancel active adapter work and record a durable terminal stop; if the coordinator is gone, Autopilot records the stop under the run lock. Branches, worktrees, receipts, and evidence remain intact. -A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts record `EXECUTION_STATE_UNKNOWN` and refuse a replacement launch until quiescence can be proven. Pi in-process implementations also become unknown when their exact owning extension instance is lost. Resume them through `/autopilot-resume [run-id]` in a loaded Autopilot extension; use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop an unknown attempt. +A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Autopilot contains no Windows native containment helper, so direct Windows CLI execution is session-scoped and continuity loss becomes `EXECUTION_STATE_UNKNOWN`. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts also refuse a replacement launch until quiescence can be proven. Pi in-process implementations become unknown when their exact owning extension instance is lost. Resume them through `/autopilot-resume [run-id]` in a loaded Autopilot extension; use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop an unknown attempt. If several runs match, Autopilot lists their title, short ID, state, progress, and last update. It changes nothing until you choose one, for example `resume 1` or `status spring-boot-4`. diff --git a/skills/autopilot/docs/implementation-plan.md b/skills/autopilot/docs/implementation-plan.md index eff41ed..e8845cb 100644 --- a/skills/autopilot/docs/implementation-plan.md +++ b/skills/autopilot/docs/implementation-plan.md @@ -1,6 +1,6 @@ # Harness-agnostic Autopilot implementation plan -- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, and the Pi process-local structured delegation backend are packaged. The reviewed Windows x64 Job Object artifact will not be packaged; its source and runtime path remain only until the ordered native-removal change. +- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, and the Pi process-local structured delegation backend are packaged. The Windows native-helper source, runtime path, build scripts, and tests have been removed; Windows restart reattachment remains unsupported. - **Date:** 2026-08-22 - **Audience:** Autopilot implementers and reviewers - **Governing design:** [Autopilot architecture](architecture.md) diff --git a/skills/autopilot/docs/runtime-cli.md b/skills/autopilot/docs/runtime-cli.md index 6008ebf..f731370 100644 --- a/skills/autopilot/docs/runtime-cli.md +++ b/skills/autopilot/docs/runtime-cli.md @@ -57,7 +57,7 @@ If no coordinator owns the run, `stop` acquires the run lock and records the ter `recover` requires an inactive coordinator and the exact current unknown item, attempt, and lease epoch. Every action records the owning run-lock token hash and explicit operator attestation. `abandon` permanently moves the uncertain worktree aside before a fresh attempt; `adopt` requires the exact freshly observed `--tree` and runs verification without an implementation launch; `stop` preserves the evidence and terminalizes the run. -`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts record `EXECUTION_STATE_UNKNOWN` and refuse a replacement launch. +`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. Autopilot packages no Windows native containment helper: direct Windows CLI executions are session-scoped, and continuity loss records `EXECUTION_STATE_UNKNOWN` without a replacement launch. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts fail closed the same way. ## Journal repair diff --git a/skills/autopilot/references/recovery.md b/skills/autopilot/references/recovery.md index c29abc6..c7d546b 100644 --- a/skills/autopilot/references/recovery.md +++ b/skills/autopilot/references/recovery.md @@ -34,7 +34,7 @@ node runtime/dist/src/cli.js --state-dir resume Resume acquires the coordinator lock, validates the sealed charter and journal, rebuilds projection state, verifies context artifacts, inspects existing worktrees and refs, and continues within the original limits. A paused unfinished item receives a fresh lease and newly hashed context; its pause-cancelled physical launch is not charged to the attempt budget. An item with a durable `ITEM_VERIFIED` checkpoint continues commit, push, change-request, check, thread, or merge reconciliation from fresh exact observations without launching another worker. Resume does not re-open `SUCCEEDED` or `STOPPED` runs. -On POSIX hosts, built-in harness adapters supervise implementation executions with a detached, attempt-scoped helper and a pre-established process-group watchdog. After coordinator loss, `resume` reconstructs the exact request from the journaled attempt and immutable context, reattaches to matching running or terminal artifacts, and observes process-group quiescence before permitting a replacement attempt. The reviewed Windows x64 Job Object helper will not be packaged. Pi process-local implementations now use cooperative terminality: only an exact terminal response through the uninterrupted owning extension instance may proceed, while harness, session, or exact-subject loss becomes `EXECUTION_STATE_UNKNOWN` and cannot launch a speculative replacement. The distinct direct Pi fallback retains process-supervised POSIX execution and session-scoped Windows execution. Other current Windows CLI execution and cancellation remain session-scoped. Legacy attempts, review executions, mismatched requests, and incomplete supervisor artifacts also remain unknown. +On POSIX hosts, built-in harness adapters supervise implementation executions with a detached, attempt-scoped helper and a pre-established process-group watchdog. After coordinator loss, `resume` reconstructs the exact request from the journaled attempt and immutable context, reattaches to matching running or terminal artifacts, and observes process-group quiescence before permitting a replacement attempt. Autopilot contains no Windows native containment helper, and Windows restart reattachment is unsupported. Pi process-local implementations use cooperative terminality: only an exact terminal response through the uninterrupted owning extension instance may proceed, while harness, session, or exact-subject loss becomes `EXECUTION_STATE_UNKNOWN` and cannot launch a speculative replacement. The distinct direct Pi fallback retains process-supervised POSIX execution and session-scoped Windows execution. Other current Windows CLI execution and cancellation remain session-scoped. Legacy attempts, review executions, mismatched requests, and incomplete supervisor artifacts also remain unknown. ## Recover an unknown execution diff --git a/skills/autopilot/runtime/.gitignore b/skills/autopilot/runtime/.gitignore index 98f5701..0702332 100644 --- a/skills/autopilot/runtime/.gitignore +++ b/skills/autopilot/runtime/.gitignore @@ -3,6 +3,5 @@ node_modules/ !dist/** .test-dist/ coverage/ -native/build/ *.log *.tmp diff --git a/skills/autopilot/runtime/dist/src/adapter-process.js b/skills/autopilot/runtime/dist/src/adapter-process.js index 7fdf8c0..ae53957 100644 --- a/skills/autopilot/runtime/dist/src/adapter-process.js +++ b/skills/autopilot/runtime/dist/src/adapter-process.js @@ -5,7 +5,6 @@ import { AutopilotError } from "./errors.js"; import { isRecord } from "./json.js"; import { cancelSupervisedProcess, launchSupervisedProcess, observeSupervisedProcess, reattachSupervisedProcess, supervisedExecutionId, supervisorDirectory, } from "./process-supervisor.js"; import { boundUtf8, runProcess } from "./process.js"; -import { verifiedWindowsJobHelperSha256 } from "./windows-job.js"; function adapterCredentialNames(request) { return [...new Set(request.grants .filter(({ actor, family }) => actor === "adapter" && family === "credentials.use") @@ -20,15 +19,12 @@ function executionPrompt(request) { ? renderReviewContext(request.context, request.reviewFocus ?? "Review the exact subject for actionable correctness defects.") : renderAttemptContext(request.context); } -async function supervisedRequest(configuration, request, environment, discoveredWindowsHelperSha256) { +function supervisedRequest(configuration, request, environment) { if (request.supervisionDirectory === undefined) { return undefined; } - const windowsHelperSha256 = process.platform === "win32" ? await verifiedWindowsJobHelperSha256() : undefined; - if (process.platform === "win32" && (windowsHelperSha256 === undefined - || discoveredWindowsHelperSha256 === null - || (discoveredWindowsHelperSha256 !== undefined && discoveredWindowsHelperSha256 !== windowsHelperSha256))) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "supervised Windows execution helper is missing or changed after capability discovery"); + if (process.platform === "win32") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "runtime-owned process supervision is unavailable on Windows"); } const executionId = supervisedExecutionId(request.runId, request.itemId, request.attemptId, request.role, request.contextHash); return { @@ -40,7 +36,6 @@ async function supervisedRequest(configuration, request, environment, discovered itemId: request.itemId, attemptId: request.attemptId, contextHash: request.contextHash, - ...(windowsHelperSha256 === undefined ? {} : { windowsHelperSha256 }), executable: configuration.executable, arguments: configuration.buildArguments(request, executionPrompt(request)), cwd: request.worktreePath, @@ -174,7 +169,6 @@ export class CliHarnessAdapter { #executions = new Map(); #cancelledExecutions = new Set(); #requests = new Map(); - #discoveredWindowsHelperSha256; constructor(configuration) { this.#configuration = configuration; } @@ -189,8 +183,7 @@ export class CliHarnessAdapter { if (version.exitCode !== 0) { throw new AutopilotError("ADAPTER_UNSUPPORTED", `${this.#configuration.name} is missing or did not report a version`); } - const windowsHelperSha256 = process.platform === "win32" ? await verifiedWindowsJobHelperSha256() : undefined; - this.#discoveredWindowsHelperSha256 = process.platform === "win32" ? windowsHelperSha256 ?? null : undefined; + const processSupervisionAvailable = process.platform !== "win32"; const manifest = { protocolVersion: 1, adapterName: this.#configuration.name, @@ -202,10 +195,10 @@ export class CliHarnessAdapter { maxConcurrency: this.#configuration.maxConcurrency, eventStreaming: this.#configuration.expectsJsonLines, cancellation: this.#configuration.cancellation, - restartReattachment: process.platform !== "win32" || windowsHelperSha256 !== undefined, + restartReattachment: processSupervisionAvailable, executionAssurance: { schemaVersion: 1, - implementation: process.platform !== "win32" || windowsHelperSha256 !== undefined + implementation: processSupervisionAvailable ? { schemaVersion: 1, owner: "runtime", @@ -231,6 +224,9 @@ export class CliHarnessAdapter { restrictions: this.#configuration.assurance, limitations: [ ...this.#configuration.limitations, + ...(processSupervisionAvailable ? [] : [ + "Windows direct CLI execution is session-scoped; continuity loss requires fenced operator recovery.", + ]), "Independent review does not require a different model or provider from implementation.", ], }; @@ -283,7 +279,7 @@ export class CliHarnessAdapter { throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); } const environment = adapterEnvironment(request); - const supervised = await supervisedRequest(this.#configuration, request, environment, this.#discoveredWindowsHelperSha256); + const supervised = supervisedRequest(this.#configuration, request, environment); if (supervised !== undefined) { const handle = await launchSupervisedProcess(supervised.directory, supervised.request, environment); this.#requests.set(handle.executionId, request); @@ -336,7 +332,7 @@ export class CliHarnessAdapter { } async reattach(request) { const environment = adapterEnvironment(request); - const supervised = await supervisedRequest(this.#configuration, request, environment, this.#discoveredWindowsHelperSha256); + const supervised = supervisedRequest(this.#configuration, request, environment); if (supervised === undefined) { return undefined; } diff --git a/skills/autopilot/runtime/dist/src/doctor.js b/skills/autopilot/runtime/dist/src/doctor.js index a8fd4b0..6332740 100644 --- a/skills/autopilot/runtime/dist/src/doctor.js +++ b/skills/autopilot/runtime/dist/src/doctor.js @@ -56,6 +56,14 @@ export async function runDoctor() { status: major >= 24 ? "ok" : "unsupported", detail: process.version, ...(major >= 24 ? {} : { setup: "Install Node.js 24 or newer; Autopilot never installs runtimes." }), + }, process.platform === "win32" ? { + name: "process-supervision", + status: "unsupported", + detail: "Windows native containment is not packaged; direct CLI execution is session-scoped and continuity loss requires operator recovery", + } : { + name: "process-supervision", + status: "ok", + detail: "POSIX attempt-scoped process-group supervision and restart reattachment are available", }]; const piSubagents = findPiSubagentsInstallation(); checks.push(await commandCheck("git", "git", ["--version"], "Install Git and make it available on PATH."), await commandCheck("pi", "pi", ["--version"], "Install Pi only if you plan to use the Pi adapter."), piSubagents === undefined diff --git a/skills/autopilot/runtime/dist/src/process-supervisor.d.ts b/skills/autopilot/runtime/dist/src/process-supervisor.d.ts index 0bfdd48..4f9e6ee 100644 --- a/skills/autopilot/runtime/dist/src/process-supervisor.d.ts +++ b/skills/autopilot/runtime/dist/src/process-supervisor.d.ts @@ -1,5 +1,4 @@ import type { ProcessResult } from "./process.js"; -import { type WindowsBrokerIdentity } from "./windows-job.js"; export interface SupervisedProcessRequest { readonly schemaVersion: 1; readonly executionId: string; @@ -7,7 +6,6 @@ export interface SupervisedProcessRequest { readonly itemId: string; readonly attemptId: string; readonly contextHash: string; - readonly windowsHelperSha256?: string; readonly executable: string; readonly arguments: readonly string[]; readonly cwd: string; @@ -41,7 +39,6 @@ export declare function supervisorDirectory(runDirectory: string, executionId: s export declare function supervisorRequestHash(request: SupervisedProcessRequest): string; export declare function readSupervisedRequest(directory: string): Promise; export declare function readSupervisedStatus(directory: string): Promise; -export declare function readWindowsBrokerIdentity(directory: string): Promise; export declare function readSupervisedResult(directory: string): Promise; export declare function readSupervisedCompletion(directory: string): Promise<{ readonly state: "completed" | "failed" | "state-unknown"; diff --git a/skills/autopilot/runtime/dist/src/process-supervisor.js b/skills/autopilot/runtime/dist/src/process-supervisor.js index b740fc8..4f56e19 100644 --- a/skills/autopilot/runtime/dist/src/process-supervisor.js +++ b/skills/autopilot/runtime/dist/src/process-supervisor.js @@ -5,7 +5,6 @@ import { join } from "node:path"; import { AutopilotError } from "./errors.js"; import { canonicalJson, expectBoolean, expectInteger, expectRecord, expectString, expectStringArray, sha256 } from "./json.js"; import { writeImmutableJson, writeJsonAtomic } from "./journal.js"; -import { queryWindowsJob, verifiedWindowsJobHelperSha256, windowsBrokerIdentity, } from "./windows-job.js"; const REQUEST_FILE = "request.json"; const STATUS_FILE = "status.json"; const RESULT_FILE = "result.json"; @@ -35,9 +34,6 @@ function parseRequest(value) { itemId: expectString(object.itemId, "supervisor request.itemId"), attemptId: expectString(object.attemptId, "supervisor request.attemptId"), contextHash: expectString(object.contextHash, "supervisor request.contextHash"), - ...(object.windowsHelperSha256 === undefined - ? {} - : { windowsHelperSha256: expectString(object.windowsHelperSha256, "supervisor request.windowsHelperSha256") }), executable: expectString(object.executable, "supervisor request.executable"), arguments: parseArguments(object.arguments), cwd: expectString(object.cwd, "supervisor request.cwd"), @@ -136,26 +132,6 @@ function parseProcessResult(value, label) { truncated: expectBoolean(object.truncated, `${label}.truncated`), }; } -export async function readWindowsBrokerIdentity(directory) { - const value = await readJson(join(directory, CHILD_FILE)); - if (value === undefined) { - return undefined; - } - const object = expectRecord(value, "Windows Job Object broker identity"); - if (object.schemaVersion !== 1 || typeof object.executionId !== "string" || typeof object.requestHash !== "string" - || typeof object.brokerName !== "string" || typeof object.brokerToken !== "string" - || typeof object.helperSha256 !== "string") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "persisted Windows Job Object broker identity is malformed"); - } - return { - schemaVersion: 1, - executionId: object.executionId, - requestHash: object.requestHash, - brokerName: object.brokerName, - brokerToken: object.brokerToken, - helperSha256: object.helperSha256, - }; -} export async function readSupervisedResult(directory) { const value = await readJson(join(directory, RESULT_FILE)); return value === undefined ? undefined : parseProcessResult(value, "supervisor result"); @@ -198,14 +174,9 @@ async function publishRequest(directory, request) { return false; } } -async function validateWindowsHelperIdentity(request) { - if (process.platform !== "win32") { - return; - } - const helperSha256 = await verifiedWindowsJobHelperSha256(); - if (request.windowsHelperSha256 === undefined || helperSha256 === undefined - || request.windowsHelperSha256 !== helperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "supervised Windows execution helper identity is missing or changed"); +function assertProcessSupervisionSupported() { + if (process.platform === "win32") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "runtime-owned process supervision is unavailable on Windows"); } } async function waitForStatus(directory, executionId, requestHash, timeoutMs) { @@ -221,7 +192,7 @@ async function waitForStatus(directory, executionId, requestHash, timeoutMs) { } } await failIfWatchdogErrored(directory, executionId, requestHash); - if (status !== undefined && (process.platform !== "win32" || status.state !== "starting")) { + if (status !== undefined) { return status; } await new Promise((resolve) => setTimeout(resolve, 25)); @@ -229,7 +200,7 @@ async function waitForStatus(directory, executionId, requestHash, timeoutMs) { throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "supervisor did not publish bootstrap status"); } export async function launchSupervisedProcess(directory, request, environment) { - await validateWindowsHelperIdentity(request); + assertProcessSupervisionSupported(); await mkdir(directory, { recursive: true, mode: 0o700 }); const created = await publishRequest(directory, request); const persistedRequest = await readSupervisedRequest(directory); @@ -275,7 +246,7 @@ export async function launchSupervisedProcess(directory, request, environment) { }; } export async function reattachSupervisedProcess(directory, request) { - await validateWindowsHelperIdentity(request); + assertProcessSupervisionSupported(); const existing = await readSupervisedRequest(directory); if (existing === undefined) { return undefined; @@ -293,28 +264,6 @@ export async function reattachSupervisedProcess(directory, request) { if (terminalResult === undefined) { await failIfWatchdogErrored(directory, request.executionId, requestHash); } - if (process.platform === "win32" && !["completed", "failed", "cancelled", "timed-out", "state-unknown"].includes(status.state)) { - const expected = await windowsBrokerIdentity(request.executionId, requestHash); - const persisted = await readWindowsBrokerIdentity(directory); - if (persisted !== undefined && canonicalJson(persisted) !== canonicalJson(expected)) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "persisted Windows Job Object broker identity changed before reattachment"); - } - const reconciliationDeadline = Date.now() + 5_000; - while (true) { - const completion = await readSupervisedCompletion(directory); - if (completion !== undefined) { - break; - } - const observation = await queryWindowsJob(expected); - if (observation.state === "ready" || observation.state === "starting") { - break; - } - if (Date.now() >= reconciliationDeadline) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object is absent before terminal publication"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } return { schemaVersion: 1, executionId: request.executionId, directory, requestHash, startedAt: status.startedAt }; } export async function observeSupervisedProcess(handle, onActivityLine) { diff --git a/skills/autopilot/runtime/dist/src/supervisor-child.js b/skills/autopilot/runtime/dist/src/supervisor-child.js index 56a0c3a..4367c60 100644 --- a/skills/autopilot/runtime/dist/src/supervisor-child.js +++ b/skills/autopilot/runtime/dist/src/supervisor-child.js @@ -8,7 +8,6 @@ import { expectRecord, expectString } from "./json.js"; import { writeJsonAtomic } from "./journal.js"; import { readSupervisedRequest, supervisorArtifactNames, supervisorRequestHash, } from "./process-supervisor.js"; import { boundUtf8, runProcess } from "./process.js"; -import { launchWindowsJob, queryWindowsJob, resolveWindowsCommand, windowsBrokerIdentity, } from "./windows-job.js"; function redactionValues(environment, credentialEnvironmentNames) { const credentialNames = new Set(credentialEnvironmentNames); return Object.entries(environment).flatMap(([name, value]) => { @@ -70,26 +69,15 @@ async function waitForWatchdogReady(directory, requestHash) { } throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "attempt watchdog did not confirm readiness before harness launch"); } -async function waitForWindowsJobReady(identity) { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - const observation = await queryWindowsJob(identity); - if (observation.state === "ready") { - return; - } - if (observation.state === "empty") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object became empty before launch readiness"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object did not confirm assignment-before-resume readiness"); -} async function main() { const directory = process.argv[2]; if (directory === undefined) { process.exitCode = 2; return; } + if (process.platform === "win32") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "runtime-owned process supervision is unavailable on Windows"); + } const request = await readSupervisedRequest(directory); if (request === undefined) { process.exitCode = 2; @@ -154,70 +142,29 @@ async function main() { let result; let state; try { - if (process.platform === "win32") { - const identity = await windowsBrokerIdentity(request.executionId, requestHash); - if (request.windowsHelperSha256 === undefined || identity.helperSha256 !== request.windowsHelperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper identity changed before launch"); - } - const command = await resolveWindowsCommand(request.executable, request.arguments, request.cwd, environment); - let resolveHelperPid = () => undefined; - const helperPid = new Promise((resolve) => { - resolveHelperPid = resolve; - }); - const launch = launchWindowsJob(identity, { - executable: command.executable, - arguments: command.arguments, - cwd: request.cwd, - environment, - }, { - maximumOutputBytes: request.maximumOutputBytes, - redactValues: sensitiveValues, - onActivity: recordActivity, - ...(onStderrLine === undefined ? {} : { onStderrLine }), - onSpawn: resolveHelperPid, - }); - void launch.catch(() => undefined); - const launchedHelperPid = await Promise.race([ - helperPid, - launch.then(() => { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper exited before publishing its process identity"); - }), - ]); - await waitForWindowsJobReady(identity); - writeChildIdentity(directory, { - ...identity, - helperPid: launchedHelperPid, - supervisorPid: process.pid, - startedAt: new Date().toISOString(), - }); - await publishStatus("running"); - result = await launch; - } - else { - await publishStatus("running"); - result = await runProcess({ - executable: request.executable, - arguments: request.arguments, - cwd: request.cwd, - environment, - maxOutputBytes: request.maximumOutputBytes, - redactValues: sensitiveValues, - detached: false, - terminationProcessGroupId: process.pid, - onActivity: recordActivity, - onSpawn: (childPid) => { - writeChildIdentity(directory, { - schemaVersion: 1, - executionId: request.executionId, - requestHash, - childPid, - supervisorPid: process.pid, - startedAt: new Date().toISOString(), - }); - }, - ...(onStderrLine === undefined ? {} : { onStderrLine }), - }); - } + await publishStatus("running"); + result = await runProcess({ + executable: request.executable, + arguments: request.arguments, + cwd: request.cwd, + environment, + maxOutputBytes: request.maximumOutputBytes, + redactValues: sensitiveValues, + detached: false, + terminationProcessGroupId: process.pid, + onActivity: recordActivity, + onSpawn: (childPid) => { + writeChildIdentity(directory, { + schemaVersion: 1, + executionId: request.executionId, + requestHash, + childPid, + supervisorPid: process.pid, + startedAt: new Date().toISOString(), + }); + }, + ...(onStderrLine === undefined ? {} : { onStderrLine }), + }); state = result.exitCode === 0 ? "completed" : "failed"; } catch (error) { diff --git a/skills/autopilot/runtime/dist/src/supervisor-watchdog.js b/skills/autopilot/runtime/dist/src/supervisor-watchdog.js index 31cd729..018bac2 100644 --- a/skills/autopilot/runtime/dist/src/supervisor-watchdog.js +++ b/skills/autopilot/runtime/dist/src/supervisor-watchdog.js @@ -3,7 +3,6 @@ import { join } from "node:path"; import { writeJsonAtomic } from "./journal.js"; import { readSupervisedCompletion, readSupervisedRequest, readSupervisedStatus, supervisedCancellationAt, supervisorArtifactNames, supervisorRequestHash, } from "./process-supervisor.js"; import { terminateProcessTree } from "./process.js"; -import { terminateWindowsJob, verifiedWindowsJobHelperSha256, windowsBrokerIdentity } from "./windows-job.js"; function processExists(pid) { try { process.kill(pid, 0); @@ -13,32 +12,11 @@ function processExists(pid) { return !(error instanceof Error && "code" in error && error.code === "ESRCH"); } } -async function quiesceExecution(supervisorPid, executable, executionId, requestHash, windowsHelperSha256) { - if (process.platform !== "win32") { - await terminateProcessTree(supervisorPid, executable); - return; - } - const helperSha256 = await verifiedWindowsJobHelperSha256(); - if (windowsHelperSha256 === undefined || helperSha256 === undefined || windowsHelperSha256 !== helperSha256) { - throw new Error("Windows Job Object helper identity changed before process-tree quiescence"); - } - await terminateWindowsJob(await windowsBrokerIdentity(executionId, requestHash)); - try { - process.kill(supervisorPid); - } - catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) { - throw error; - } - } - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - if (!processExists(supervisorPid)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 25)); +async function quiesceExecution(supervisorPid, executable) { + if (process.platform === "win32") { + throw new Error("runtime-owned process supervision is unavailable on Windows"); } - throw new Error("Windows Job Object is empty but its attempt supervisor did not terminate"); + await terminateProcessTree(supervisorPid, executable); } async function lastActivityAt(directory, startedAt) { try { @@ -147,7 +125,7 @@ async function main() { let terminalState = state; let terminalMessage = message; try { - await quiesceExecution(supervisorPid, request.executable, request.executionId, requestHash, request.windowsHelperSha256); + await quiesceExecution(supervisorPid, request.executable); } catch (error) { terminalState = "state-unknown"; diff --git a/skills/autopilot/runtime/dist/src/windows-job.d.ts b/skills/autopilot/runtime/dist/src/windows-job.d.ts deleted file mode 100644 index d303a1a..0000000 --- a/skills/autopilot/runtime/dist/src/windows-job.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type ProcessResult } from "./process.js"; -export declare const WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES = 16777216; -export declare const WINDOWS_BROKER_MAXIMUM_FIELD_BYTES = 1048576; -export declare const WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES = 16384; -export interface WindowsJobHelperLocation { - readonly executable: string; - readonly manifest: string; -} -export interface WindowsBrokerIdentity { - readonly schemaVersion: 1; - readonly executionId: string; - readonly requestHash: string; - readonly brokerName: string; - readonly brokerToken: string; - readonly helperSha256: string; -} -export interface WindowsJobObservation { - readonly state: "ready" | "starting" | "busy" | "empty" | "absent" | "terminated"; - readonly activeProcesses: number; -} -export interface WindowsResolvedCommand { - readonly executable: string; - readonly arguments: readonly string[]; -} -interface LaunchRequest { - readonly operation: "launch"; - readonly brokerName: string; - readonly brokerToken: string; - readonly executable: string; - readonly arguments: readonly string[]; - readonly cwd: string; - readonly environment: Readonly; -} -interface ControlRequest { - readonly operation: "query" | "terminate"; - readonly brokerName: string; - readonly brokerToken: string; -} -export declare function packagedWindowsJobHelper(): WindowsJobHelperLocation; -export declare function verifyWindowsJobHelper(location?: WindowsJobHelperLocation, platform?: NodeJS.Platform, architecture?: string): Promise<{ - readonly available: boolean; - readonly sha256?: string; -}>; -export declare function verifiedWindowsJobHelperSha256(): Promise; -export declare function windowsRestartReattachmentAvailable(): Promise; -export declare function windowsBrokerIdentity(executionId: string, requestHash: string): Promise; -export declare function windowsBrokerName(executionId: string, requestHash: string): string; -export declare function resolveWindowsCommand(executable: string, arguments_: readonly string[], cwd: string, environment: Readonly): Promise; -export declare function encodeWindowsJobRequest(request: LaunchRequest | ControlRequest): Buffer; -export declare function parseWindowsJobObservation(stdout: string): WindowsJobObservation; -export declare function queryWindowsJob(identity: WindowsBrokerIdentity): Promise; -export declare function terminateWindowsJob(identity: WindowsBrokerIdentity): Promise; -export declare function launchWindowsJob(identity: WindowsBrokerIdentity, request: Omit, processOptions: { - readonly maximumOutputBytes: number; - readonly redactValues: readonly string[]; - readonly onActivity: () => void; - readonly onStderrLine?: (line: string) => void; - readonly onSpawn: (helperPid: number) => void; -}): Promise; -export {}; diff --git a/skills/autopilot/runtime/dist/src/windows-job.js b/skills/autopilot/runtime/dist/src/windows-job.js deleted file mode 100644 index e3b6de3..0000000 --- a/skills/autopilot/runtime/dist/src/windows-job.js +++ /dev/null @@ -1,345 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile, stat } from "node:fs/promises"; -import { dirname, extname, isAbsolute, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { AutopilotError } from "./errors.js"; -import { expectInteger, expectLiteral, expectRecord } from "./json.js"; -import { runProcess } from "./process.js"; -const PROTOCOL_MAGIC = Buffer.from("APJOB001", "ascii"); -const PROTOCOL_VERSION = 1; -const OPERATION = { launch: 1, query: 2, terminate: 3 }; -const X64_PE_MACHINE = 0x8664; -export const WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES = 16_777_216; -export const WINDOWS_BROKER_MAXIMUM_FIELD_BYTES = 1_048_576; -export const WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES = 16_384; -export function packagedWindowsJobHelper() { - return { - executable: fileURLToPath(new URL("../native/win32-x64/job-helper.exe", import.meta.url)), - manifest: fileURLToPath(new URL("../native/win32-x64/job-helper.json", import.meta.url)), - }; -} -function parseManifest(value) { - const object = expectRecord(value, "Windows Job Object helper manifest"); - if (object.schemaVersion !== 1 || object.platform !== "win32" || object.architecture !== "x64" - || object.protocolVersion !== 1 || object.provenance !== "github-actions-workflow-dispatch" - || typeof object.sourceCommit !== "string" || !/^[a-f0-9]{40,64}$/u.test(object.sourceCommit) - || typeof object.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(object.sourceSha256) - || typeof object.workflowRunId !== "string" || !/^[1-9][0-9]*$/u.test(object.workflowRunId) - || typeof object.workflowRunAttempt !== "string" || !/^[1-9][0-9]*$/u.test(object.workflowRunAttempt) - || typeof object.workflowSha !== "string" || !/^[a-f0-9]{40,64}$/u.test(object.workflowSha) - || object.workflowName !== "Autopilot Windows Job Object helper" - || typeof object.workflowRef !== "string" - || !/^drafael\/coding-harness\/\.github\/workflows\/autopilot-windows-helper\.yml@refs\/heads\/[A-Za-z0-9._\/-]+$/u.test(object.workflowRef) - || object.workflowEvent !== "workflow_dispatch" || object.repository !== "drafael/coding-harness" - || typeof object.toolset !== "string" || object.toolset.length === 0 - || typeof object.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(object.sha256)) { - throw new AutopilotError("ADAPTER_UNSUPPORTED", "Windows Job Object helper manifest is invalid"); - } - return { - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - provenance: "github-actions-workflow-dispatch", - sourceCommit: object.sourceCommit, - sourceSha256: object.sourceSha256, - workflowRunId: object.workflowRunId, - workflowRunAttempt: object.workflowRunAttempt, - workflowSha: object.workflowSha, - workflowName: "Autopilot Windows Job Object helper", - workflowRef: object.workflowRef, - workflowEvent: "workflow_dispatch", - repository: "drafael/coding-harness", - toolset: object.toolset, - sha256: object.sha256, - }; -} -function peMachine(bytes) { - if (bytes.length < 64 || bytes[0] !== 0x4d || bytes[1] !== 0x5a) { - return undefined; - } - const peOffset = bytes.readUInt32LE(0x3c); - if (peOffset + 6 > bytes.length || bytes.subarray(peOffset, peOffset + 4).toString("binary") !== "PE\0\0") { - return undefined; - } - return bytes.readUInt16LE(peOffset + 4); -} -export async function verifyWindowsJobHelper(location = packagedWindowsJobHelper(), platform = process.platform, architecture = process.arch) { - if (platform !== "win32" || architecture !== "x64") { - return { available: false }; - } - try { - const [executable, manifestValue] = await Promise.all([ - readFile(location.executable), - readFile(location.manifest, "utf8"), - ]); - const manifest = parseManifest(JSON.parse(manifestValue)); - const sha256 = createHash("sha256").update(executable).digest("hex"); - return manifest.sha256 === sha256 && peMachine(executable) === X64_PE_MACHINE - ? { available: true, sha256 } - : { available: false }; - } - catch { - return { available: false }; - } -} -export async function verifiedWindowsJobHelperSha256() { - const verified = await verifyWindowsJobHelper(); - return verified.available ? verified.sha256 : undefined; -} -export async function windowsRestartReattachmentAvailable() { - return await verifiedWindowsJobHelperSha256() !== undefined; -} -export async function windowsBrokerIdentity(executionId, requestHash) { - const helper = await checkedHelper(); - return { - schemaVersion: 1, - executionId, - requestHash, - brokerName: windowsBrokerName(executionId, requestHash), - brokerToken: createHash("sha256").update(`broker-token\0${executionId}\0${requestHash}`, "utf8").digest("hex"), - helperSha256: helper.sha256, - }; -} -export function windowsBrokerName(executionId, requestHash) { - const identity = createHash("sha256").update(`${executionId}\0${requestHash}`, "utf8").digest("hex"); - return `\\\\.\\pipe\\AutopilotBroker_${identity}`; -} -function environmentValue(environment, name) { - const entry = Object.entries(environment).find(([candidate]) => candidate.toLowerCase() === name.toLowerCase()); - return entry?.[1]; -} -async function existingFile(path) { - try { - return (await stat(path)).isFile(); - } - catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false; - } - throw error; - } -} -async function resolveNpmCommandShim(commandPath, arguments_) { - const content = await readFile(commandPath, "utf8"); - const normalized = content.replaceAll("\r\n", "\n"); - const matches = [...normalized.matchAll(/["']%dp0%[\\/]([^"']+\.(?:cjs|mjs|js))["']\s+%\*/giu)]; - if (matches.length !== 1 || !/^@ECHO off\n/iu.test(normalized) || !normalized.includes("CALL :find_dp0")) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows command shim is not a recognized npm argv-preserving launcher"); - } - const relativeScript = matches[0]?.[1]; - if (relativeScript === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows npm command shim entry point is missing"); - } - const script = resolve(dirname(commandPath), relativeScript); - if (!await existingFile(script)) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows npm command shim entry point is unavailable"); - } - const adjacentNode = join(dirname(commandPath), "node.exe"); - return { - executable: await existingFile(adjacentNode) ? adjacentNode : process.execPath, - arguments: [script, ...arguments_], - }; -} -export async function resolveWindowsCommand(executable, arguments_, cwd, environment) { - const hasPath = isAbsolute(executable) || /[\\/]/u.test(executable); - const extensions = extname(executable) === "" - ? (environmentValue(environment, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) - : [""]; - const directories = hasPath - ? [""] - : [cwd, ...(environmentValue(environment, "PATH") ?? "").split(";").filter(Boolean)]; - const base = hasPath ? (isAbsolute(executable) ? executable : resolve(cwd, executable)) : executable; - let resolvedPath; - for (const directory of directories) { - for (const extension of extensions) { - const candidate = hasPath ? `${base}${extension}` : join(directory, `${base}${extension}`); - if (await existingFile(candidate)) { - resolvedPath = resolve(candidate); - break; - } - } - if (resolvedPath !== undefined) { - break; - } - } - if (resolvedPath === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", `Windows executable is unavailable: ${executable}`); - } - const extension = extname(resolvedPath).toLowerCase(); - if (extension === ".cmd") { - return await resolveNpmCommandShim(resolvedPath, arguments_); - } - if (extension !== ".exe" && extension !== ".com") { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", `Windows executable is not CreateProcess-compatible: ${resolvedPath}`); - } - return { executable: resolvedPath, arguments: arguments_ }; -} -function appendUInt32(parts, value) { - const bytes = Buffer.allocUnsafe(4); - bytes.writeUInt32LE(value); - parts.push(bytes); -} -function appendString(parts, value) { - const bytes = Buffer.from(value, "utf8"); - if (bytes.length > WINDOWS_BROKER_MAXIMUM_FIELD_BYTES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker field exceeds the protocol bound"); - } - appendUInt32(parts, bytes.length); - parts.push(bytes); -} -export function encodeWindowsJobRequest(request) { - const parts = [PROTOCOL_MAGIC]; - appendUInt32(parts, PROTOCOL_VERSION); - appendUInt32(parts, OPERATION[request.operation]); - appendString(parts, request.brokerName); - appendString(parts, request.brokerToken); - if (request.operation === "launch") { - if (request.arguments.length > WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker argument count exceeds the protocol bound"); - } - appendString(parts, request.executable); - appendString(parts, request.cwd); - appendUInt32(parts, request.arguments.length); - request.arguments.forEach((argument) => appendString(parts, argument)); - const environment = Object.entries(request.environment) - .filter((entry) => entry[1] !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - if (environment.length > WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker environment count exceeds the protocol bound"); - } - appendUInt32(parts, environment.length); - environment.forEach(([name, value]) => { - appendString(parts, name); - appendString(parts, value); - }); - } - const encoded = Buffer.concat(parts); - if (encoded.length > WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object helper request exceeds the protocol bound"); - } - return encoded; -} -export function parseWindowsJobObservation(stdout) { - let value; - try { - value = JSON.parse(stdout.trim()); - } - catch (error) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper returned malformed control output", { - cause: String(error), - }); - } - const object = expectRecord(value, "Windows Job Object helper response"); - if (object.schemaVersion !== 1 || object.protocolVersion !== 1) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper response version changed"); - } - return { - state: expectLiteral(object.state, ["ready", "starting", "busy", "empty", "absent", "terminated"], "Windows Job Object helper response.state"), - activeProcesses: expectInteger(object.activeProcesses, "Windows Job Object helper response.activeProcesses", 0), - }; -} -async function checkedHelper() { - const location = packagedWindowsJobHelper(); - const verified = await verifyWindowsJobHelper(location); - if (!verified.available || verified.sha256 === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "verified win32-x64 Job Object helper is unavailable"); - } - return { location, sha256: verified.sha256 }; -} -function canonicalIdentity(identity) { - return [ - identity.schemaVersion, - identity.executionId, - identity.requestHash, - identity.brokerName, - identity.brokerToken, - identity.helperSha256, - ].join("\0"); -} -function helperEnvironment(source = process.env) { - return Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "PATHEXT"].flatMap((name) => { - const value = environmentValue(source, name); - return value === undefined ? [] : [[name, value]]; - })); -} -async function terminateControlHelper(pid) { - try { - process.kill(pid); - } - catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) { - throw error; - } - } -} -async function control(operation, identity) { - const helper = await checkedHelper(); - const expected = await windowsBrokerIdentity(identity.executionId, identity.requestHash); - if (canonicalIdentity(identity) !== canonicalIdentity(expected) || helper.sha256 !== identity.helperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object broker identity changed"); - } - const result = await runProcess({ - executable: helper.location.executable, - arguments: [], - cwd: process.cwd(), - environment: helperEnvironment(), - stdin: encodeWindowsJobRequest({ - operation, - brokerName: identity.brokerName, - brokerToken: identity.brokerToken, - }), - timeoutMs: 10_000, - terminate: terminateControlHelper, - maxOutputBytes: 65_536, - detached: false, - }); - if (result.exitCode !== 0 || result.stderr !== "") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `Windows Job Object ${operation} was not confirmed`, { - exitCode: result.exitCode, - stderr: result.stderr, - }); - } - return parseWindowsJobObservation(result.stdout); -} -export async function queryWindowsJob(identity) { - return await control("query", identity); -} -export async function terminateWindowsJob(identity) { - const deadline = Date.now() + 5_000; - while (true) { - const observation = await control("terminate", identity); - if (observation.state === "terminated" || observation.state === "absent" || observation.state === "empty") { - return observation; - } - if (observation.state !== "busy" || Date.now() >= deadline) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object did not become quiescent"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } -} -export async function launchWindowsJob(identity, request, processOptions) { - const helper = await checkedHelper(); - const expected = await windowsBrokerIdentity(identity.executionId, identity.requestHash); - if (canonicalIdentity(identity) !== canonicalIdentity(expected) || helper.sha256 !== identity.helperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object broker launch identity changed"); - } - return await runProcess({ - executable: helper.location.executable, - arguments: [], - cwd: request.cwd, - environment: helperEnvironment(request.environment), - stdin: encodeWindowsJobRequest({ - ...request, - operation: "launch", - brokerName: identity.brokerName, - brokerToken: identity.brokerToken, - }), - maxOutputBytes: processOptions.maximumOutputBytes, - redactValues: processOptions.redactValues, - detached: false, - onActivity: processOptions.onActivity, - ...(processOptions.onStderrLine === undefined ? {} : { onStderrLine: processOptions.onStderrLine }), - onSpawn: processOptions.onSpawn, - }); -} diff --git a/skills/autopilot/runtime/native/README.md b/skills/autopilot/runtime/native/README.md deleted file mode 100644 index 42e56f5..0000000 --- a/skills/autopilot/runtime/native/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Windows Job Object helper source - -- **Status:** Retained temporarily for removal; not approved for packaging -- **Decision:** [Use cooperative harness execution on Windows](../../docs/adr/0002-use-cooperative-harness-execution-on-windows.md) -- **Implementation plan:** [Cooperative harness execution](../../docs/2026-08-31-cooperative-harness-execution-plan.md) - -`windows-job-helper.c` is the source of the optional `win32-x64` process-containment helper that was developed and validated before the project chose a binary-free Windows package boundary. - -The helper creates the harness suspended, assigns it to an unnamed Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, and resumes it only after assignment. A persistent broker owns the sole lifetime Job handle and exposes authenticated query and termination through a request-derived named pipe. These mechanics remain historical implementation evidence; they are not the approved future package architecture. - -Protected workflow run [`33353702353`](https://github.com/drafael/coding-harness/actions/runs/33353702353) built the x64 helper reproducibly, passed 179 tests exactly once, and produced reviewed executable SHA-256: - -```text -e9017028a38c8e564aa7b73541dd1996e5b5ddf8075a7c136e06b5d55c7effef -``` - -The artifact will not be copied into `native/bin/win32-x64/` or checked into the repository. The protected artifact-producing workflow has been removed so it cannot be promoted accidentally. Local builds remain `local-untrusted` and cannot enable packaged capability discovery. - -Until the retained source and runtime paths are removed in the planned cleanup PR: - -- no packaged helper means Windows restart reattachment remains disabled; -- Windows uses the existing session-scoped direct execution and cancellation fallback; -- helper absence or changed execution state must fail closed; -- no documentation or report may imply that the reviewed artifact ships. - -Do not build, install, or substitute a local helper. The retained source, build script, protocol implementation, and tests remain only to keep the transition reviewable until cooperative execution and native cleanup land in their ordered PRs. diff --git a/skills/autopilot/runtime/native/windows-job-helper.c b/skills/autopilot/runtime/native/windows-job-helper.c deleted file mode 100644 index e674e96..0000000 --- a/skills/autopilot/runtime/native/windows-job-helper.c +++ /dev/null @@ -1,773 +0,0 @@ -#define UNICODE -#define _UNICODE -#define _WIN32_WINNT 0x0601 -#define WIN32_LEAN_AND_MEAN -#include -#include -#include -#include -#include -#include -#include - -#define PROTOCOL_VERSION 1u -#define OPERATION_LAUNCH 1u -#define OPERATION_QUERY 2u -#define OPERATION_TERMINATE 3u -#define MAXIMUM_PROTOCOL_BYTES 16777216u -#define MAXIMUM_FIELD_BYTES 1048576u -#define MAXIMUM_LIST_ENTRIES 16384u -#define TERMINATION_EXIT_CODE 124u -#define BROKER_AUTH_TIMEOUT_MS 1000u - -static const unsigned char PROTOCOL_MAGIC[8] = { 'A', 'P', 'J', 'O', 'B', '0', '0', '1' }; - -typedef struct BufferReader { - unsigned char *bytes; - size_t length; - size_t offset; -} BufferReader; - -typedef struct LaunchRequest { - wchar_t *broker_name; - wchar_t *broker_token; - wchar_t *executable; - wchar_t *cwd; - wchar_t **arguments; - uint32_t argument_count; - wchar_t **environment_names; - wchar_t **environment_values; - uint32_t environment_count; -} LaunchRequest; - -typedef struct BrokerContext { - HANDLE job; - HANDLE pipe; - const wchar_t *broker_name; - const wchar_t *broker_token; - volatile LONG ready; - volatile LONG stopping; - volatile LONG control_active; - volatile LONG readiness_observed; -} BrokerContext; - -static void write_error(const char *message, DWORD error) { - if (error == 0) { - fprintf(stderr, "%s\n", message); - } else { - fprintf(stderr, "%s (win32=%lu)\n", message, (unsigned long)error); - } -} - -static int read_all_handle(HANDLE input, BufferReader *reader) { - DWORD capacity = 4096; - DWORD length = 0; - unsigned char *bytes = (unsigned char *)malloc(capacity); - if (bytes == NULL) { - return 0; - } - for (;;) { - DWORD read = 0; - unsigned char overflow; - if (length == MAXIMUM_PROTOCOL_BYTES) { - BOOL success = ReadFile(input, &overflow, 1, &read, NULL); - if ((success && read == 0) || (!success && GetLastError() == ERROR_BROKEN_PIPE)) { - break; - } - free(bytes); - return 0; - } - if (length == capacity) { - DWORD next_capacity = capacity > MAXIMUM_PROTOCOL_BYTES / 2 ? MAXIMUM_PROTOCOL_BYTES : capacity * 2; - unsigned char *next = (unsigned char *)realloc(bytes, next_capacity); - if (next == NULL) { - free(bytes); - return 0; - } - bytes = next; - capacity = next_capacity; - } - if (!ReadFile(input, bytes + length, capacity - length, &read, NULL)) { - if (GetLastError() == ERROR_BROKEN_PIPE) { - break; - } - free(bytes); - return 0; - } - if (read == 0) { - break; - } - length += read; - } - reader->bytes = bytes; - reader->length = length; - reader->offset = 0; - return 1; -} - -static int read_pipe_message(HANDLE pipe, BufferReader *reader) { - DWORD length = 0; - DWORD read = 0; - ULONGLONG deadline = GetTickCount64() + BROKER_AUTH_TIMEOUT_MS; - unsigned char *bytes = (unsigned char *)malloc(MAXIMUM_PROTOCOL_BYTES); - HANDLE event = CreateEventW(NULL, TRUE, FALSE, NULL); - if (bytes == NULL || event == NULL) { - free(bytes); - if (event != NULL) { - CloseHandle(event); - } - return 0; - } - for (;;) { - OVERLAPPED overlapped; - BOOL success; - DWORD error; - DWORD timeout; - memset(&overlapped, 0, sizeof(overlapped)); - overlapped.hEvent = event; - ResetEvent(event); - read = 0; - success = ReadFile(pipe, bytes + length, MAXIMUM_PROTOCOL_BYTES - length, &read, &overlapped); - error = success ? ERROR_SUCCESS : GetLastError(); - if (!success && error == ERROR_IO_PENDING) { - ULONGLONG now = GetTickCount64(); - timeout = now >= deadline ? 0 : (DWORD)(deadline - now); - if (WaitForSingleObject(event, timeout) != WAIT_OBJECT_0) { - CancelIoEx(pipe, &overlapped); - WaitForSingleObject(event, INFINITE); - SetLastError(ERROR_TIMEOUT); - free(bytes); - CloseHandle(event); - return 0; - } - success = GetOverlappedResult(pipe, &overlapped, &read, FALSE); - error = success ? ERROR_SUCCESS : GetLastError(); - } - length += read; - if (success) { - break; - } - if (error != ERROR_MORE_DATA || length == MAXIMUM_PROTOCOL_BYTES) { - SetLastError(error); - free(bytes); - CloseHandle(event); - return 0; - } - } - CloseHandle(event); - reader->bytes = bytes; - reader->length = length; - reader->offset = 0; - return 1; -} - -static int read_uint32(BufferReader *reader, uint32_t *value) { - unsigned char *bytes; - if (reader->offset + 4 > reader->length) { - return 0; - } - bytes = reader->bytes + reader->offset; - *value = (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8) | ((uint32_t)bytes[2] << 16) | ((uint32_t)bytes[3] << 24); - reader->offset += 4; - return 1; -} - -static int utf8_to_wide(const unsigned char *bytes, uint32_t length, wchar_t **value) { - int wide_length; - wchar_t *wide; - if (length > MAXIMUM_FIELD_BYTES || memchr(bytes, 0, length) != NULL) { - return 0; - } - wide_length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, (const char *)bytes, (int)length, NULL, 0); - if (wide_length <= 0 && length != 0) { - return 0; - } - wide = (wchar_t *)calloc((size_t)wide_length + 1, sizeof(wchar_t)); - if (wide == NULL) { - return 0; - } - if (wide_length > 0 && MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, (const char *)bytes, (int)length, wide, wide_length) != wide_length) { - free(wide); - return 0; - } - *value = wide; - return 1; -} - -static int read_string(BufferReader *reader, wchar_t **value) { - uint32_t length; - if (!read_uint32(reader, &length) || reader->offset + length > reader->length) { - return 0; - } - if (!utf8_to_wide(reader->bytes + reader->offset, length, value)) { - return 0; - } - reader->offset += length; - return 1; -} - -static int valid_hex_token(const wchar_t *value) { - size_t index; - if (wcslen(value) != 64) { - return 0; - } - for (index = 0; index < 64; index += 1) { - wchar_t character = value[index]; - if (!((character >= L'0' && character <= L'9') || (character >= L'a' && character <= L'f'))) { - return 0; - } - } - return 1; -} - -static int valid_broker_name(const wchar_t *name) { - static const wchar_t prefix[] = L"\\\\.\\pipe\\AutopilotBroker_"; - size_t prefix_length = wcslen(prefix); - return wcsncmp(name, prefix, prefix_length) == 0 && valid_hex_token(name + prefix_length); -} - -static void free_launch_request(LaunchRequest *request) { - uint32_t index; - free(request->broker_name); - free(request->broker_token); - free(request->executable); - free(request->cwd); - for (index = 0; index < request->argument_count; index += 1) { - free(request->arguments[index]); - } - free(request->arguments); - for (index = 0; index < request->environment_count; index += 1) { - free(request->environment_names[index]); - free(request->environment_values[index]); - } - free(request->environment_names); - free(request->environment_values); - memset(request, 0, sizeof(*request)); -} - -static int read_launch_request(BufferReader *reader, LaunchRequest *request) { - uint32_t index; - if (!read_string(reader, &request->broker_name) || !read_string(reader, &request->broker_token) - || !read_string(reader, &request->executable) || !read_string(reader, &request->cwd) - || !read_uint32(reader, &request->argument_count) || request->argument_count > MAXIMUM_LIST_ENTRIES) { - return 0; - } - request->arguments = (wchar_t **)calloc(request->argument_count, sizeof(wchar_t *)); - if (request->argument_count > 0 && request->arguments == NULL) { - return 0; - } - for (index = 0; index < request->argument_count; index += 1) { - if (!read_string(reader, &request->arguments[index])) { - return 0; - } - } - if (!read_uint32(reader, &request->environment_count) || request->environment_count > MAXIMUM_LIST_ENTRIES) { - return 0; - } - request->environment_names = (wchar_t **)calloc(request->environment_count, sizeof(wchar_t *)); - request->environment_values = (wchar_t **)calloc(request->environment_count, sizeof(wchar_t *)); - if (request->environment_count > 0 && (request->environment_names == NULL || request->environment_values == NULL)) { - return 0; - } - for (index = 0; index < request->environment_count; index += 1) { - if (!read_string(reader, &request->environment_names[index]) || !read_string(reader, &request->environment_values[index]) - || wcschr(request->environment_names[index], L'=') != NULL || request->environment_names[index][0] == L'\0') { - return 0; - } - } - return reader->offset == reader->length && valid_broker_name(request->broker_name) - && valid_hex_token(request->broker_token) && request->executable[0] != L'\0' && request->cwd[0] != L'\0'; -} - -static int read_control_request(BufferReader *reader, uint32_t *operation, wchar_t **broker_name, wchar_t **broker_token) { - uint32_t version; - if (reader->length < sizeof(PROTOCOL_MAGIC) + 8 || memcmp(reader->bytes, PROTOCOL_MAGIC, sizeof(PROTOCOL_MAGIC)) != 0) { - return 0; - } - reader->offset = sizeof(PROTOCOL_MAGIC); - if (!read_uint32(reader, &version) || version != PROTOCOL_VERSION || !read_uint32(reader, operation) - || (*operation != OPERATION_QUERY && *operation != OPERATION_TERMINATE) - || !read_string(reader, broker_name) || !read_string(reader, broker_token) || reader->offset != reader->length) { - return 0; - } - return valid_broker_name(*broker_name) && valid_hex_token(*broker_token); -} - -static size_t quoted_argument_length(const wchar_t *argument) { - size_t length = 2; - size_t slashes = 0; - const wchar_t *cursor; - for (cursor = argument; *cursor != L'\0'; cursor += 1) { - if (*cursor == L'\\') { - slashes += 1; - } else if (*cursor == L'"') { - length += slashes * 2 + 2; - slashes = 0; - } else { - length += slashes + 1; - slashes = 0; - } - } - return length + slashes * 2; -} - -static wchar_t *append_quoted_argument(wchar_t *output, const wchar_t *argument) { - size_t slashes = 0; - const wchar_t *cursor; - *output++ = L'"'; - for (cursor = argument; *cursor != L'\0'; cursor += 1) { - if (*cursor == L'\\') { - slashes += 1; - continue; - } - if (*cursor == L'"') { - while (slashes > 0) { - *output++ = L'\\'; - *output++ = L'\\'; - slashes -= 1; - } - *output++ = L'\\'; - *output++ = L'"'; - } else { - while (slashes > 0) { - *output++ = L'\\'; - slashes -= 1; - } - *output++ = *cursor; - } - slashes = 0; - } - while (slashes > 0) { - *output++ = L'\\'; - *output++ = L'\\'; - slashes -= 1; - } - *output++ = L'"'; - return output; -} - -static wchar_t *build_command_line(const LaunchRequest *request) { - size_t length = quoted_argument_length(request->executable) + 1; - uint32_t index; - wchar_t *command_line; - wchar_t *cursor; - for (index = 0; index < request->argument_count; index += 1) { - length += quoted_argument_length(request->arguments[index]) + 1; - } - command_line = (wchar_t *)calloc(length, sizeof(wchar_t)); - if (command_line == NULL) { - return NULL; - } - cursor = append_quoted_argument(command_line, request->executable); - for (index = 0; index < request->argument_count; index += 1) { - *cursor++ = L' '; - cursor = append_quoted_argument(cursor, request->arguments[index]); - } - *cursor = L'\0'; - return command_line; -} - -static wchar_t *build_environment(const LaunchRequest *request) { - size_t length = 2; - uint32_t index; - wchar_t *block; - wchar_t *cursor; - for (index = 0; index < request->environment_count; index += 1) { - length += wcslen(request->environment_names[index]) + wcslen(request->environment_values[index]) + 2; - } - block = (wchar_t *)calloc(length, sizeof(wchar_t)); - if (block == NULL) { - return NULL; - } - cursor = block; - for (index = 0; index < request->environment_count; index += 1) { - size_t remaining = length - (size_t)(cursor - block); - wcscpy_s(cursor, remaining, request->environment_names[index]); - cursor += wcslen(cursor); - *cursor++ = L'='; - wcscpy_s(cursor, length - (size_t)(cursor - block), request->environment_values[index]); - cursor += wcslen(cursor) + 1; - } - *cursor = L'\0'; - return block; -} - -static int deny_job_handle_duplication(void) { - PSECURITY_DESCRIPTOR descriptor = NULL; - static const wchar_t policy[] = L"D:P(D;;0x0040;;;WD)(A;;0x00101001;;;OW)(A;;GA;;;SY)(A;;GA;;;BA)"; - if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(policy, SDDL_REVISION_1, &descriptor, NULL)) { - return 0; - } - if (!SetKernelObjectSecurity(GetCurrentProcess(), DACL_SECURITY_INFORMATION, descriptor)) { - DWORD error = GetLastError(); - LocalFree(descriptor); - SetLastError(error); - return 0; - } - LocalFree(descriptor); - return 1; -} - -static int query_active_processes(HANDLE job, DWORD *active_processes) { - JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; - if (!QueryInformationJobObject(job, JobObjectBasicAccountingInformation, &information, sizeof(information), NULL)) { - return 0; - } - *active_processes = information.ActiveProcesses; - return 1; -} - -static int format_observation(char *output, size_t output_length, const char *state, DWORD active_processes) { - int written = sprintf_s(output, output_length, - "{\"schemaVersion\":1,\"protocolVersion\":1,\"state\":\"%s\",\"activeProcesses\":%lu}\n", - state, (unsigned long)active_processes); - return written > 0 ? written : 0; -} - -static int connect_broker_pipe(HANDLE pipe) { - OVERLAPPED overlapped; - HANDLE event = CreateEventW(NULL, TRUE, FALSE, NULL); - BOOL connected; - DWORD error; - DWORD transferred = 0; - if (event == NULL) { - return 0; - } - memset(&overlapped, 0, sizeof(overlapped)); - overlapped.hEvent = event; - connected = ConnectNamedPipe(pipe, &overlapped); - error = connected ? ERROR_SUCCESS : GetLastError(); - if (!connected && error == ERROR_IO_PENDING) { - if (WaitForSingleObject(event, INFINITE) == WAIT_OBJECT_0) { - connected = GetOverlappedResult(pipe, &overlapped, &transferred, FALSE); - } - } else if (!connected && error == ERROR_PIPE_CONNECTED) { - connected = TRUE; - } - CloseHandle(event); - return connected ? 1 : 0; -} - -static int write_pipe_message(HANDLE pipe, const char *response, DWORD response_length) { - OVERLAPPED overlapped; - HANDLE event = CreateEventW(NULL, TRUE, FALSE, NULL); - DWORD written = 0; - BOOL success; - if (event == NULL) { - return 0; - } - memset(&overlapped, 0, sizeof(overlapped)); - overlapped.hEvent = event; - success = WriteFile(pipe, response, response_length, &written, &overlapped); - if (!success && GetLastError() == ERROR_IO_PENDING) { - if (WaitForSingleObject(event, BROKER_AUTH_TIMEOUT_MS) == WAIT_OBJECT_0) { - success = GetOverlappedResult(pipe, &overlapped, &written, FALSE); - } else { - CancelIoEx(pipe, &overlapped); - WaitForSingleObject(event, INFINITE); - success = FALSE; - } - } - CloseHandle(event); - return success && written == response_length; -} - -static DWORD WINAPI broker_thread(void *parameter) { - BrokerContext *context = (BrokerContext *)parameter; - while (InterlockedCompareExchange(&context->stopping, 0, 0) == 0) { - BOOL connected = connect_broker_pipe(context->pipe); - if (connected) { - BufferReader request; - wchar_t *broker_name = NULL; - wchar_t *broker_token = NULL; - uint32_t operation = 0; - char response[256]; - int response_length = 0; - int response_marks_ready = 0; - InterlockedExchange(&context->control_active, 1); - memset(&request, 0, sizeof(request)); - if (read_pipe_message(context->pipe, &request) - && read_control_request(&request, &operation, &broker_name, &broker_token) - && wcscmp(broker_name, context->broker_name) == 0 - && wcscmp(broker_token, context->broker_token) == 0) { - DWORD active_processes = 0; - if (operation == OPERATION_TERMINATE) { - if (TerminateJobObject(context->job, TERMINATION_EXIT_CODE)) { - ULONGLONG deadline = GetTickCount64() + 5000; - do { - if (!query_active_processes(context->job, &active_processes)) { - active_processes = 1; - break; - } - if (active_processes != 0) { - Sleep(25); - } - } while (active_processes != 0 && GetTickCount64() < deadline); - if (active_processes == 0) { - response_length = format_observation(response, sizeof(response), "terminated", 0); - } - } - } else if (query_active_processes(context->job, &active_processes)) { - const char *state = InterlockedCompareExchange(&context->ready, 0, 0) == 0 - ? "starting" - : active_processes == 0 ? "empty" : "ready"; - response_marks_ready = state[0] == 'r'; - response_length = format_observation(response, sizeof(response), state, active_processes); - } - } - if (response_length > 0 - && write_pipe_message(context->pipe, response, (DWORD)response_length) - && response_marks_ready) { - InterlockedExchange(&context->readiness_observed, 1); - } - free(request.bytes); - free(broker_name); - free(broker_token); - } - DisconnectNamedPipe(context->pipe); - InterlockedExchange(&context->control_active, 0); - } - return 0; -} - -static int control_broker(BufferReader *request, const wchar_t *broker_name) { - HANDLE pipe; - DWORD mode = PIPE_READMODE_MESSAGE; - DWORD written = 0; - char response[1024]; - DWORD response_length = 0; - if (!WaitNamedPipeW(broker_name, 100)) { - DWORD error = GetLastError(); - const char *state = error == ERROR_FILE_NOT_FOUND ? "absent" : error == ERROR_SEM_TIMEOUT ? "busy" : NULL; - if (state != NULL) { - int observation_length = format_observation(response, sizeof(response), state, 0); - return observation_length > 0 - && fwrite(response, 1, (size_t)observation_length, stdout) == (size_t)observation_length ? 0 : 1; - } - write_error("Windows Job Object broker is unavailable", error); - return 1; - } - pipe = CreateFileW(broker_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); - if (pipe == INVALID_HANDLE_VALUE) { - DWORD error = GetLastError(); - const char *state = error == ERROR_FILE_NOT_FOUND ? "absent" : error == ERROR_PIPE_BUSY ? "busy" : NULL; - if (state != NULL) { - int observation_length = format_observation(response, sizeof(response), state, 0); - return observation_length > 0 - && fwrite(response, 1, (size_t)observation_length, stdout) == (size_t)observation_length ? 0 : 1; - } - write_error("cannot connect to the Windows Job Object broker", error); - return 1; - } - if (!SetNamedPipeHandleState(pipe, &mode, NULL, NULL) - || !WriteFile(pipe, request->bytes, (DWORD)request->length, &written, NULL) - || written != request->length - || !ReadFile(pipe, response, sizeof(response), &response_length, NULL) - || response_length == 0) { - write_error("Windows Job Object broker control failed", GetLastError()); - CloseHandle(pipe); - return 1; - } - CloseHandle(pipe); - if (fwrite(response, 1, response_length, stdout) != response_length) { - write_error("cannot publish Windows Job Object broker response", 0); - return 1; - } - return 0; -} - -static int launch_broker(const LaunchRequest *request) { - HANDLE job = NULL; - HANDLE pipe = INVALID_HANDLE_VALUE; - HANDLE broker_thread_handle = NULL; - wchar_t *command_line = NULL; - wchar_t *environment = NULL; - PROCESS_INFORMATION process_information; - STARTUPINFOW startup_information; - JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; - BrokerContext context; - DWORD root_exit_code = 1; - DWORD active_processes = 0; - ULONGLONG quiescence_deadline; - int result = 1; - memset(&process_information, 0, sizeof(process_information)); - memset(&startup_information, 0, sizeof(startup_information)); - memset(&limits, 0, sizeof(limits)); - memset(&context, 0, sizeof(context)); - startup_information.cb = sizeof(startup_information); - startup_information.dwFlags = STARTF_USESTDHANDLES; - startup_information.hStdInput = GetStdHandle(STD_INPUT_HANDLE); - startup_information.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); - startup_information.hStdError = GetStdHandle(STD_ERROR_HANDLE); - - if (!deny_job_handle_duplication()) { - write_error("cannot deny Job Object handle duplication from the broker", GetLastError()); - goto cleanup; - } - job = CreateJobObjectW(NULL, NULL); - if (job == NULL) { - write_error("cannot create the private Job Object", GetLastError()); - goto cleanup; - } - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { - write_error("cannot configure private Job Object kill-on-close", GetLastError()); - goto cleanup; - } - pipe = CreateNamedPipeW(request->broker_name, - PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED, - PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, - 1, 4096, 4096, 5000, NULL); - if (pipe == INVALID_HANDLE_VALUE) { - write_error("cannot create the unique Windows Job Object broker channel", GetLastError()); - goto cleanup; - } - context.job = job; - context.pipe = pipe; - context.broker_name = request->broker_name; - context.broker_token = request->broker_token; - broker_thread_handle = CreateThread(NULL, 0, broker_thread, &context, 0, NULL); - if (broker_thread_handle == NULL) { - write_error("cannot start the Windows Job Object broker", GetLastError()); - goto cleanup; - } - command_line = build_command_line(request); - environment = build_environment(request); - if (command_line == NULL || environment == NULL) { - write_error("cannot allocate the contained child launch request", 0); - goto cleanup; - } - if (!CreateProcessW(request->executable, command_line, NULL, NULL, TRUE, - CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW, - environment, request->cwd, &startup_information, &process_information)) { - write_error("cannot create the harness process suspended", GetLastError()); - goto cleanup; - } - if (!AssignProcessToJobObject(job, process_information.hProcess)) { - write_error("cannot assign the suspended harness process to the private Job Object", GetLastError()); - TerminateProcess(process_information.hProcess, TERMINATION_EXIT_CODE); - goto cleanup; - } - InterlockedExchange(&context.ready, 1); - { - ULONGLONG readiness_deadline = GetTickCount64() + 5000; - while (InterlockedCompareExchange(&context.readiness_observed, 0, 0) == 0 && GetTickCount64() < readiness_deadline) { - Sleep(10); - } - } - if (InterlockedCompareExchange(&context.readiness_observed, 0, 0) == 0) { - write_error("broker readiness was not observed before the launch deadline", 0); - TerminateJobObject(job, TERMINATION_EXIT_CODE); - goto cleanup; - } - if (ResumeThread(process_information.hThread) == (DWORD)-1) { - write_error("cannot resume the contained harness process", GetLastError()); - TerminateJobObject(job, TERMINATION_EXIT_CODE); - goto cleanup; - } - WaitForSingleObject(process_information.hProcess, INFINITE); - if (!GetExitCodeProcess(process_information.hProcess, &root_exit_code)) { - root_exit_code = 1; - } - if (!query_active_processes(job, &active_processes)) { - write_error("cannot query private Job Object completion", GetLastError()); - goto cleanup; - } - if (active_processes != 0 && !TerminateJobObject(job, TERMINATION_EXIT_CODE)) { - write_error("cannot quiesce descendants after harness completion", GetLastError()); - goto cleanup; - } - quiescence_deadline = GetTickCount64() + 5000; - do { - if (!query_active_processes(job, &active_processes)) { - write_error("cannot prove private Job Object process quiescence", GetLastError()); - goto cleanup; - } - if (active_processes != 0) { - if (GetTickCount64() >= quiescence_deadline) { - write_error("private Job Object descendants did not quiesce within five seconds", 0); - goto cleanup; - } - Sleep(25); - } - } while (active_processes != 0); - result = (int)root_exit_code; - -cleanup: - if (broker_thread_handle != NULL && InterlockedCompareExchange(&context.control_active, 0, 0) != 0) { - ULONGLONG control_deadline = GetTickCount64() + 6000; - while (InterlockedCompareExchange(&context.control_active, 0, 0) != 0 && GetTickCount64() < control_deadline) { - Sleep(10); - } - } - InterlockedExchange(&context.stopping, 1); - if (pipe != INVALID_HANDLE_VALUE) { - CancelIoEx(pipe, NULL); - } - if (broker_thread_handle != NULL) { - CancelSynchronousIo(broker_thread_handle); - WaitForSingleObject(broker_thread_handle, 1000); - CloseHandle(broker_thread_handle); - } - if (process_information.hThread != NULL) { - CloseHandle(process_information.hThread); - } - if (process_information.hProcess != NULL) { - CloseHandle(process_information.hProcess); - } - if (pipe != INVALID_HANDLE_VALUE) { - CloseHandle(pipe); - } - if (job != NULL) { - CloseHandle(job); - } - free(command_line); - free(environment); - return result; -} - -int wmain(void) { - BufferReader reader; - uint32_t version; - uint32_t operation; - wchar_t *broker_name = NULL; - wchar_t *broker_token = NULL; - LaunchRequest launch_request; - int result = 1; - memset(&reader, 0, sizeof(reader)); - memset(&launch_request, 0, sizeof(launch_request)); - if (!read_all_handle(GetStdHandle(STD_INPUT_HANDLE), &reader) - || reader.length < sizeof(PROTOCOL_MAGIC) + 8 - || memcmp(reader.bytes, PROTOCOL_MAGIC, sizeof(PROTOCOL_MAGIC)) != 0) { - write_error("malformed Windows Job Object broker protocol", 0); - goto cleanup; - } - reader.offset = sizeof(PROTOCOL_MAGIC); - if (!read_uint32(&reader, &version) || version != PROTOCOL_VERSION || !read_uint32(&reader, &operation)) { - write_error("unsupported Windows Job Object broker protocol", 0); - goto cleanup; - } - if (operation == OPERATION_LAUNCH) { - if (!read_launch_request(&reader, &launch_request)) { - write_error("malformed Windows Job Object broker launch request", 0); - goto cleanup; - } - result = launch_broker(&launch_request); - } else if (operation == OPERATION_QUERY || operation == OPERATION_TERMINATE) { - reader.offset = 0; - if (!read_control_request(&reader, &operation, &broker_name, &broker_token)) { - write_error("malformed Windows Job Object broker control request", 0); - goto cleanup; - } - result = control_broker(&reader, broker_name); - } else { - write_error("unsupported Windows Job Object broker operation", 0); - } - -cleanup: - free(broker_name); - free(broker_token); - free_launch_request(&launch_request); - free(reader.bytes); - return result; -} diff --git a/skills/autopilot/runtime/package.json b/skills/autopilot/runtime/package.json index 3df1bd0..4eec439 100644 --- a/skills/autopilot/runtime/package.json +++ b/skills/autopilot/runtime/package.json @@ -21,13 +21,12 @@ "schemas/" ], "scripts": { - "build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/copy-native-helper.mjs && node scripts/make-executable.mjs", - "build:windows-helper": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts/build-windows-helper.ps1", + "build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/make-executable.mjs", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", "lint": "tsc -p tsconfig.json --noEmit --noUnusedLocals --noUnusedParameters && tsc -p tsconfig.test.json --noEmit --noUnusedLocals --noUnusedParameters", "format:check": "node scripts/format-check.mjs", - "test": "npm run build && tsc -p tsconfig.test.json && node scripts/copy-native-helper.mjs && node --test .test-dist/test/*.test.js", - "test:coverage": "npm run build && tsc -p tsconfig.test.json && node scripts/copy-native-helper.mjs && node --test --experimental-test-coverage .test-dist/test/*.test.js" + "test": "npm run build && tsc -p tsconfig.test.json && node --test .test-dist/test/*.test.js", + "test:coverage": "npm run build && tsc -p tsconfig.test.json && node --test --experimental-test-coverage .test-dist/test/*.test.js" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*" diff --git a/skills/autopilot/runtime/scripts/build-windows-helper.ps1 b/skills/autopilot/runtime/scripts/build-windows-helper.ps1 deleted file mode 100644 index 56963f7..0000000 --- a/skills/autopilot/runtime/scripts/build-windows-helper.ps1 +++ /dev/null @@ -1,111 +0,0 @@ -param( - [Parameter(Mandatory = $false)] - [string]$OutputDirectory = "native/build/windows-job-helper" -) - -$ErrorActionPreference = "Stop" -$runtimeRoot = Split-Path -Parent $PSScriptRoot -$source = Join-Path $runtimeRoot "native/windows-job-helper.c" -$output = [System.IO.Path]::GetFullPath((Join-Path $runtimeRoot $OutputDirectory)) -$first = Join-Path $output "first" -$second = Join-Path $output "second" -$artifact = Join-Path $output "artifact" - -$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe" -if (-not (Test-Path $vswhere)) { - throw "vswhere.exe is unavailable" -} -$installation = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath -if (-not $installation) { - throw "Visual Studio x64 C++ tools are unavailable" -} -$developerShell = Join-Path $installation "Common7/Tools/VsDevCmd.bat" -if (-not (Test-Path $developerShell)) { - throw "Visual Studio developer shell is unavailable" -} - -Remove-Item -Recurse -Force $output -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force $first, $second, $artifact | Out-Null - -function Build-Helper([string]$directory) { - $executable = Join-Path $directory "job-helper.exe" - $object = Join-Path $directory "job-helper.obj" - $command = "`"$developerShell`" -no_logo -arch=amd64 -host_arch=amd64 && cl.exe /nologo /W4 /WX /O2 /MT /GS /guard:cf /Fo`"$object`" /Fe`"$executable`" `"$source`" /link advapi32.lib /Brepro /guard:cf /DYNAMICBASE /NXCOMPAT" - & $env:ComSpec /d /s /c $command | Out-Host - if ($LASTEXITCODE -ne 0 -or -not (Test-Path $executable)) { - throw "MSVC failed to build the Windows Job Object helper" - } - return $executable -} - -$firstExecutable = Build-Helper $first -$secondExecutable = Build-Helper $second -$firstHash = (Get-FileHash -Algorithm SHA256 $firstExecutable).Hash.ToLowerInvariant() -$secondHash = (Get-FileHash -Algorithm SHA256 $secondExecutable).Hash.ToLowerInvariant() -if ($firstHash -ne $secondHash) { - throw "Windows Job Object helper builds are not reproducible: $firstHash != $secondHash" -} - -$bytes = [System.IO.File]::ReadAllBytes($firstExecutable) -if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { - throw "Windows Job Object helper is not a PE image" -} -$peOffset = [BitConverter]::ToUInt32($bytes, 0x3c) -if ($peOffset + 6 -gt $bytes.Length -or [BitConverter]::ToUInt16($bytes, [int]$peOffset + 4) -ne 0x8664) { - throw "Windows Job Object helper is not an x64 PE image" -} - -$artifactExecutable = Join-Path $artifact "job-helper.exe" -$artifactManifest = Join-Path $artifact "job-helper.json" -Copy-Item $firstExecutable $artifactExecutable -$toolset = (& $env:ComSpec /d /s /c "`"$developerShell`" -no_logo -arch=amd64 -host_arch=amd64 && cl.exe /Bv 2>&1" | Out-String).Trim() -if (-not $toolset) { - throw "MSVC toolset identity is unavailable" -} -$global:LASTEXITCODE = 0 -$sourceCommit = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.ToLowerInvariant() } else { (& git rev-parse HEAD).Trim().ToLowerInvariant() } -$workflowRunId = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { "local-untrusted" } -$workflowRunAttempt = if ($env:GITHUB_RUN_ATTEMPT) { $env:GITHUB_RUN_ATTEMPT } else { "local-untrusted" } -$workflowSha = if ($env:AUTOPILOT_WORKFLOW_SHA) { $env:AUTOPILOT_WORKFLOW_SHA.ToLowerInvariant() } else { $sourceCommit } -$trustedWorkflow = $env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_EVENT_NAME -eq "workflow_dispatch" ` - -and $env:GITHUB_REPOSITORY -eq "drafael/coding-harness" -and $env:GITHUB_WORKFLOW -eq "Autopilot Windows Job Object helper" -$sourceHash = (Get-FileHash -Algorithm SHA256 $source).Hash.ToLowerInvariant() -$manifest = [ordered]@{ - schemaVersion = 1 - platform = "win32" - architecture = "x64" - protocolVersion = 1 - provenance = if ($trustedWorkflow) { "github-actions-workflow-dispatch" } else { "local-untrusted" } - sourceCommit = $sourceCommit - sourceSha256 = $sourceHash - workflowRunId = $workflowRunId - workflowRunAttempt = $workflowRunAttempt - workflowSha = $workflowSha - workflowName = if ($env:GITHUB_WORKFLOW) { $env:GITHUB_WORKFLOW } else { "local-untrusted" } - workflowRef = if ($env:GITHUB_WORKFLOW_REF) { $env:GITHUB_WORKFLOW_REF } else { "local-untrusted" } - workflowEvent = if ($env:GITHUB_EVENT_NAME) { $env:GITHUB_EVENT_NAME } else { "local-untrusted" } - repository = if ($env:GITHUB_REPOSITORY) { $env:GITHUB_REPOSITORY } else { "local-untrusted" } - toolset = $toolset - sha256 = $firstHash -} -$manifest | ConvertTo-Json | Set-Content -Encoding utf8NoBOM $artifactManifest - -$checkedDirectory = Join-Path $runtimeRoot "native/bin/win32-x64" -$checkedExecutable = Join-Path $checkedDirectory "job-helper.exe" -$checkedManifest = Join-Path $checkedDirectory "job-helper.json" -if (Test-Path $checkedExecutable) { - if (-not (Test-Path $checkedManifest)) { - throw "checked Windows helper is missing its manifest" - } - $checkedHash = (Get-FileHash -Algorithm SHA256 $checkedExecutable).Hash.ToLowerInvariant() - if ($checkedHash -ne $firstHash) { - throw "checked Windows helper differs from the reproducible build: $checkedHash != $firstHash" - } -} - -Write-Host "Windows Job Object helper SHA-256: $firstHash" -Write-Host "Windows Job Object helper source commit: $sourceCommit" -Write-Host "Windows Job Object helper source SHA-256: $sourceHash" -Write-Host "Windows Job Object helper workflow run: $workflowRunId" -Write-Host "Windows Job Object helper toolset: $toolset" -Write-Host "Artifact directory: $artifact" diff --git a/skills/autopilot/runtime/scripts/collect-windows-diagnostics.mjs b/skills/autopilot/runtime/scripts/collect-windows-diagnostics.mjs deleted file mode 100644 index 12302bd..0000000 --- a/skills/autopilot/runtime/scripts/collect-windows-diagnostics.mjs +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { basename, dirname, join, relative, sep } from "node:path"; - -const diagnosticFiles = new Set([ - "child.json", - "completion.json", - "result.json", - "status.json", - "watchdog-error.json", - "watchdog-ready.json", -]); - -const helperFailureKinds = new Map([ - ["Windows Job Object broker is unavailable", "BROKER_UNAVAILABLE"], - ["cannot connect to the Windows Job Object broker", "BROKER_CONNECT_FAILED"], - ["Windows Job Object broker control failed", "BROKER_CONTROL_FAILED"], - ["cannot publish Windows Job Object broker response", "BROKER_RESPONSE_FAILED"], - ["cannot deny Job Object handle duplication from the broker", "HANDLE_DUPLICATION_DENIAL_FAILED"], - ["cannot create the private Job Object", "JOB_CREATE_FAILED"], - ["cannot configure private Job Object kill-on-close", "JOB_CONFIGURATION_FAILED"], - ["cannot create the unique Windows Job Object broker channel", "BROKER_CHANNEL_CREATE_FAILED"], - ["cannot start the Windows Job Object broker", "BROKER_START_FAILED"], - ["cannot allocate the contained child launch request", "CHILD_REQUEST_ALLOCATION_FAILED"], - ["cannot create the harness process suspended", "SUSPENDED_PROCESS_CREATE_FAILED"], - ["cannot assign the suspended harness process to the private Job Object", "JOB_ASSIGNMENT_FAILED"], - ["broker readiness was not observed before the launch deadline", "BROKER_READINESS_TIMEOUT"], - ["cannot resume the contained harness process", "HARNESS_RESUME_FAILED"], - ["cannot query private Job Object completion", "JOB_COMPLETION_QUERY_FAILED"], - ["cannot quiesce descendants after harness completion", "DESCENDANT_QUIESCENCE_FAILED"], - ["cannot prove private Job Object process quiescence", "QUIESCENCE_PROOF_FAILED"], - ["private Job Object descendants did not quiesce within five seconds", "QUIESCENCE_TIMEOUT"], - ["malformed Windows Job Object broker protocol", "BROKER_PROTOCOL_MALFORMED"], - ["unsupported Windows Job Object broker protocol", "BROKER_PROTOCOL_UNSUPPORTED"], - ["malformed Windows Job Object broker launch request", "BROKER_LAUNCH_REQUEST_MALFORMED"], - ["malformed Windows Job Object broker control request", "BROKER_CONTROL_REQUEST_MALFORMED"], - ["unsupported Windows Job Object broker operation", "BROKER_OPERATION_UNSUPPORTED"], -]); - -const runtimeFailureKinds = new Map([ - ["attempt watchdog did not confirm readiness before harness launch", "WATCHDOG_READINESS_TIMEOUT"], - ["Windows Job Object became empty before launch readiness", "JOB_EMPTY_BEFORE_READINESS"], - ["Windows Job Object did not confirm assignment-before-resume readiness", "JOB_READINESS_TIMEOUT"], - ["Windows Job Object helper identity changed before launch", "HELPER_IDENTITY_CHANGED_BEFORE_LAUNCH"], - ["Windows Job Object helper exited before publishing its process identity", "HELPER_EXITED_BEFORE_IDENTITY"], - ["Windows Job Object broker identity changed", "BROKER_IDENTITY_CHANGED"], - ["Windows Job Object broker launch identity changed", "BROKER_LAUNCH_IDENTITY_CHANGED"], - ["Windows Job Object query was not confirmed", "JOB_QUERY_UNCONFIRMED"], - ["Windows Job Object helper identity changed before process-tree quiescence", "HELPER_IDENTITY_CHANGED_BEFORE_QUIESCENCE"], - ["Windows Job Object did not become quiescent", "JOB_NOT_QUIESCENT"], - ["Windows Job Object terminate was not confirmed", "JOB_TERMINATION_UNCONFIRMED"], - ["Windows Job Object is empty but its attempt supervisor did not terminate", "SUPERVISOR_TERMINATION_TIMEOUT"], - ["verified win32-x64 Job Object helper is unavailable", "VERIFIED_HELPER_UNAVAILABLE"], -]); - -function record(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} - -function scalar(source, name, type) { - const value = source[name]; - return typeof value === type ? { [name]: value } : {}; -} - -function integer(source, name, minimum = Number.MIN_SAFE_INTEGER) { - const value = source[name]; - return Number.isSafeInteger(value) && value >= minimum ? { [name]: value } : {}; -} - -function knownStderrFailure(stderr) { - const withoutNewline = stderr.endsWith("\r\n") - ? stderr.slice(0, -2) - : stderr.endsWith("\n") ? stderr.slice(0, -1) : stderr; - if (withoutNewline.includes("\n") || withoutNewline.includes("\r")) { - return { failureKind: "UNKNOWN" }; - } - const win32Match = /^(.*) \(win32=([0-9]{1,10})\)$/u.exec(withoutNewline); - const message = win32Match?.[1] ?? withoutNewline; - const helperFailureKind = helperFailureKinds.get(message); - if (helperFailureKind !== undefined) { - if (win32Match === null) { - return { failureKind: helperFailureKind }; - } - const win32Code = Number(win32Match[2]); - return Number.isSafeInteger(win32Code) && win32Code <= 0xffff_ffff - ? { failureKind: helperFailureKind, win32Code } - : { failureKind: "UNKNOWN" }; - } - return win32Match === null && runtimeFailureKinds.has(message) - ? { failureKind: runtimeFailureKinds.get(message) } - : { failureKind: "UNKNOWN" }; -} - -function failureMetadata(value, prefix) { - if (typeof value !== "string") { - return {}; - } - const classification = knownStderrFailure(value); - return { - [`${prefix}ByteLength`]: Buffer.byteLength(value), - [`${prefix}Sha256`]: createHash("sha256").update(value).digest("hex"), - [`${prefix}FailureKind`]: classification.failureKind, - ...(classification.win32Code === undefined ? {} : { [`${prefix}Win32Code`]: classification.win32Code }), - }; -} - -function stderrMetadata(value) { - const metadata = failureMetadata(value, "stderr"); - return metadata.stderrFailureKind === undefined ? {} : { - stderrByteLength: metadata.stderrByteLength, - stderrSha256: metadata.stderrSha256, - failureKind: metadata.stderrFailureKind, - ...(metadata.stderrWin32Code === undefined ? {} : { win32Code: metadata.stderrWin32Code }), - }; -} - -function sanitizeResult(value, prefix = "") { - const source = record(value); - const exitCode = integer(source, "exitCode"); - const truncated = scalar(source, "truncated", "boolean"); - const prefixed = (fields) => Object.fromEntries(Object.entries(fields).map(([name, fieldValue]) => [ - `${prefix}${name[0].toUpperCase()}${name.slice(1)}`, - fieldValue, - ])); - return prefix === "" - ? { ...exitCode, ...truncated, ...stderrMetadata(source.stderr) } - : prefixed({ ...exitCode, ...truncated, ...stderrMetadata(source.stderr) }); -} - -function sanitizeDiagnostic(name, value) { - const source = record(value); - switch (name) { - case "status.json": - return { - ...integer(source, "schemaVersion", 1), - ...scalar(source, "executionId", "string"), - ...scalar(source, "requestHash", "string"), - ...scalar(source, "state", "string"), - ...integer(source, "supervisorPid", 1), - ...scalar(source, "startedAt", "string"), - ...scalar(source, "updatedAt", "string"), - ...scalar(source, "completedAt", "string"), - ...integer(source, "exitCode"), - ...failureMetadata(source.error, "error"), - }; - case "completion.json": - return { - ...scalar(source, "state", "string"), - ...scalar(source, "completedAt", "string"), - ...sanitizeResult(source.result, "result"), - }; - case "result.json": - return sanitizeResult(source); - case "child.json": - return { - ...integer(source, "schemaVersion", 1), - ...scalar(source, "executionId", "string"), - ...scalar(source, "requestHash", "string"), - ...scalar(source, "brokerName", "string"), - ...scalar(source, "helperSha256", "string"), - ...integer(source, "helperPid", 1), - ...integer(source, "childPid", 1), - ...integer(source, "processGroupId", 1), - ...integer(source, "supervisorPid", 1), - ...scalar(source, "startedAt", "string"), - }; - case "watchdog-error.json": - return { - ...integer(source, "schemaVersion", 1), - ...scalar(source, "executionId", "string"), - ...scalar(source, "requestHash", "string"), - ...scalar(source, "failedAt", "string"), - ...failureMetadata(source.error, "error"), - }; - case "watchdog-ready.json": - return { - ...integer(source, "schemaVersion", 1), - ...scalar(source, "executionId", "string"), - ...scalar(source, "requestHash", "string"), - ...integer(source, "supervisorPid", 1), - ...scalar(source, "readyAt", "string"), - }; - default: - return {}; - } -} - -function pidLiveness(value) { - return ["helperPid", "childPid", "processGroupId", "supervisorPid"].flatMap((field) => { - const pid = value[field]; - if (!Number.isSafeInteger(pid) || pid < 1) { - return []; - } - try { - process.kill(pid, 0); - return [{ field, pid, state: "alive" }]; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ESRCH") { - return [{ field, pid, state: "absent" }]; - } - const permissionDenied = error instanceof Error && "code" in error && error.code === "EPERM"; - return [{ field, pid, state: permissionDenied ? "alive" : "unknown" }]; - } - }); -} - -function diagnosticPath(root, path) { - const value = relative(root, path).split(sep).join("/"); - return value === "" ? "." : value; -} - -async function collectEntries(root) { - const files = []; - const unavailable = []; - const visit = async (path) => { - let entries; - try { - entries = await readdir(path, { withFileTypes: true }); - } catch { - unavailable.push({ path: diagnosticPath(root, path), kind: "subtree", readState: "unavailable" }); - return; - } - for (const entry of entries) { - const child = join(path, entry.name); - if (entry.isDirectory()) { - await visit(child); - } else if (diagnosticFiles.has(entry.name) && relative(root, child).split(sep).includes("executions")) { - files.push(child); - } - } - }; - let topLevel; - try { - topLevel = await readdir(root, { withFileTypes: true }); - } catch { - return { files, unavailable: [{ path: ".", kind: "subtree", readState: "unavailable" }] }; - } - for (const entry of topLevel.filter((entry) => entry.isDirectory() && entry.name.startsWith("autopilot-"))) { - await visit(join(root, entry.name)); - } - files.sort((left, right) => left.localeCompare(right)); - unavailable.sort((left, right) => left.path.localeCompare(right.path)); - return { files, unavailable }; -} - -function argument(name) { - const index = process.argv.indexOf(name); - const value = index < 0 ? undefined : process.argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - throw new Error(`${name} is required`); - } - return value; -} - -async function main() { - const root = argument("--root"); - const output = argument("--output"); - const collected = await collectEntries(root); - const diagnostics = [...collected.unavailable]; - for (const path of collected.files) { - const name = basename(path); - try { - const sanitized = sanitizeDiagnostic(name, JSON.parse(await readFile(path, "utf8"))); - diagnostics.push({ - path: diagnosticPath(root, path), - file: name, - value: sanitized, - pidLiveness: pidLiveness(sanitized), - }); - } catch { - diagnostics.push({ - path: diagnosticPath(root, path), - file: name, - readState: "unavailable", - }); - } - } - diagnostics.sort((left, right) => left.path.localeCompare(right.path)); - await mkdir(dirname(output), { recursive: true }); - await writeFile(output, `${JSON.stringify({ schemaVersion: 1, diagnostics }, null, 2)}\n`, { mode: 0o600 }); -} - -await main(); diff --git a/skills/autopilot/runtime/scripts/copy-native-helper.mjs b/skills/autopilot/runtime/scripts/copy-native-helper.mjs deleted file mode 100644 index daa40cb..0000000 --- a/skills/autopilot/runtime/scripts/copy-native-helper.mjs +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -import { access, copyFile, mkdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; - -const source = join("native", "bin", "win32-x64"); -const destinations = [join("dist", "native", "win32-x64")]; -const executable = join(source, "job-helper.exe"); -const manifest = join(source, "job-helper.json"); - -try { - await Promise.all([readFile(executable), readFile(manifest)]); -} catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - process.exit(0); - } - throw error; -} - -try { - await access(".test-dist"); - destinations.push(join(".test-dist", "native", "win32-x64")); -} catch { - // Test output is optional during a production build. -} - -await Promise.all(destinations.map(async (destination) => { - await mkdir(destination, { recursive: true }); - return await Promise.all([ - copyFile(executable, join(destination, "job-helper.exe")), - copyFile(manifest, join(destination, "job-helper.json")), - ]); -})); diff --git a/skills/autopilot/runtime/src/adapter-process.ts b/skills/autopilot/runtime/src/adapter-process.ts index f470166..1b1326e 100644 --- a/skills/autopilot/runtime/src/adapter-process.ts +++ b/skills/autopilot/runtime/src/adapter-process.ts @@ -23,7 +23,6 @@ import { type SupervisedProcessRequest, } from "./process-supervisor.js"; import { boundUtf8, runProcess, type ProcessResult } from "./process.js"; -import { verifiedWindowsJobHelperSha256 } from "./windows-job.js"; export interface CliHarnessConfiguration { readonly name: string; @@ -63,22 +62,18 @@ function executionPrompt(request: ExecutionRequest): string { : renderAttemptContext(request.context); } -async function supervisedRequest( +function supervisedRequest( configuration: CliHarnessConfiguration, request: ExecutionRequest, environment: Readonly, - discoveredWindowsHelperSha256?: string | null, -): Promise<{ readonly directory: string; readonly request: SupervisedProcessRequest } | undefined> { +): { readonly directory: string; readonly request: SupervisedProcessRequest } | undefined { if (request.supervisionDirectory === undefined) { return undefined; } - const windowsHelperSha256 = process.platform === "win32" ? await verifiedWindowsJobHelperSha256() : undefined; - if (process.platform === "win32" && (windowsHelperSha256 === undefined - || discoveredWindowsHelperSha256 === null - || (discoveredWindowsHelperSha256 !== undefined && discoveredWindowsHelperSha256 !== windowsHelperSha256))) { + if (process.platform === "win32") { throw new AutopilotError( "EXECUTION_STATE_UNKNOWN", - "supervised Windows execution helper is missing or changed after capability discovery", + "runtime-owned process supervision is unavailable on Windows", ); } const executionId = supervisedExecutionId(request.runId, request.itemId, request.attemptId, request.role, request.contextHash); @@ -91,7 +86,6 @@ async function supervisedRequest( itemId: request.itemId, attemptId: request.attemptId, contextHash: request.contextHash, - ...(windowsHelperSha256 === undefined ? {} : { windowsHelperSha256 }), executable: configuration.executable, arguments: configuration.buildArguments(request, executionPrompt(request)), cwd: request.worktreePath, @@ -230,7 +224,6 @@ export class CliHarnessAdapter implements HarnessPort { readonly #executions = new Map(); readonly #cancelledExecutions = new Set(); readonly #requests = new Map(); - #discoveredWindowsHelperSha256: string | null | undefined; constructor(configuration: CliHarnessConfiguration) { this.#configuration = configuration; @@ -247,8 +240,7 @@ export class CliHarnessAdapter implements HarnessPort { if (version.exitCode !== 0) { throw new AutopilotError("ADAPTER_UNSUPPORTED", `${this.#configuration.name} is missing or did not report a version`); } - const windowsHelperSha256 = process.platform === "win32" ? await verifiedWindowsJobHelperSha256() : undefined; - this.#discoveredWindowsHelperSha256 = process.platform === "win32" ? windowsHelperSha256 ?? null : undefined; + const processSupervisionAvailable = process.platform !== "win32"; const manifest: CapabilityManifest = { protocolVersion: 1, adapterName: this.#configuration.name, @@ -260,10 +252,10 @@ export class CliHarnessAdapter implements HarnessPort { maxConcurrency: this.#configuration.maxConcurrency, eventStreaming: this.#configuration.expectsJsonLines, cancellation: this.#configuration.cancellation, - restartReattachment: process.platform !== "win32" || windowsHelperSha256 !== undefined, + restartReattachment: processSupervisionAvailable, executionAssurance: { schemaVersion: 1, - implementation: process.platform !== "win32" || windowsHelperSha256 !== undefined + implementation: processSupervisionAvailable ? { schemaVersion: 1, owner: "runtime", @@ -289,6 +281,9 @@ export class CliHarnessAdapter implements HarnessPort { restrictions: this.#configuration.assurance, limitations: [ ...this.#configuration.limitations, + ...(processSupervisionAvailable ? [] : [ + "Windows direct CLI execution is session-scoped; continuity loss requires fenced operator recovery.", + ]), "Independent review does not require a different model or provider from implementation.", ], }; @@ -357,12 +352,7 @@ export class CliHarnessAdapter implements HarnessPort { throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); } const environment = adapterEnvironment(request); - const supervised = await supervisedRequest( - this.#configuration, - request, - environment, - this.#discoveredWindowsHelperSha256, - ); + const supervised = supervisedRequest(this.#configuration, request, environment); if (supervised !== undefined) { const handle = await launchSupervisedProcess(supervised.directory, supervised.request, environment); this.#requests.set(handle.executionId, request); @@ -416,12 +406,7 @@ export class CliHarnessAdapter implements HarnessPort { async reattach(request: ExecutionRequest): Promise { const environment = adapterEnvironment(request); - const supervised = await supervisedRequest( - this.#configuration, - request, - environment, - this.#discoveredWindowsHelperSha256, - ); + const supervised = supervisedRequest(this.#configuration, request, environment); if (supervised === undefined) { return undefined; } diff --git a/skills/autopilot/runtime/src/doctor.ts b/skills/autopilot/runtime/src/doctor.ts index d002a2b..36691a0 100644 --- a/skills/autopilot/runtime/src/doctor.ts +++ b/skills/autopilot/runtime/src/doctor.ts @@ -63,6 +63,14 @@ export async function runDoctor(): Promise { status: major >= 24 ? "ok" : "unsupported", detail: process.version, ...(major >= 24 ? {} : { setup: "Install Node.js 24 or newer; Autopilot never installs runtimes." }), + }, process.platform === "win32" ? { + name: "process-supervision", + status: "unsupported", + detail: "Windows native containment is not packaged; direct CLI execution is session-scoped and continuity loss requires operator recovery", + } : { + name: "process-supervision", + status: "ok", + detail: "POSIX attempt-scoped process-group supervision and restart reattachment are available", }]; const piSubagents = findPiSubagentsInstallation(); checks.push( diff --git a/skills/autopilot/runtime/src/process-supervisor.ts b/skills/autopilot/runtime/src/process-supervisor.ts index 27ae562..fd2420f 100644 --- a/skills/autopilot/runtime/src/process-supervisor.ts +++ b/skills/autopilot/runtime/src/process-supervisor.ts @@ -6,12 +6,6 @@ import { AutopilotError } from "./errors.js"; import { canonicalJson, expectBoolean, expectInteger, expectRecord, expectString, expectStringArray, sha256 } from "./json.js"; import { writeImmutableJson, writeJsonAtomic } from "./journal.js"; import type { ProcessResult } from "./process.js"; -import { - queryWindowsJob, - verifiedWindowsJobHelperSha256, - windowsBrokerIdentity, - type WindowsBrokerIdentity, -} from "./windows-job.js"; export interface SupervisedProcessRequest { readonly schemaVersion: 1; @@ -20,7 +14,6 @@ export interface SupervisedProcessRequest { readonly itemId: string; readonly attemptId: string; readonly contextHash: string; - readonly windowsHelperSha256?: string; readonly executable: string; readonly arguments: readonly string[]; readonly cwd: string; @@ -83,9 +76,6 @@ function parseRequest(value: unknown): SupervisedProcessRequest { itemId: expectString(object.itemId, "supervisor request.itemId"), attemptId: expectString(object.attemptId, "supervisor request.attemptId"), contextHash: expectString(object.contextHash, "supervisor request.contextHash"), - ...(object.windowsHelperSha256 === undefined - ? {} - : { windowsHelperSha256: expectString(object.windowsHelperSha256, "supervisor request.windowsHelperSha256") }), executable: expectString(object.executable, "supervisor request.executable"), arguments: parseArguments(object.arguments), cwd: expectString(object.cwd, "supervisor request.cwd"), @@ -196,27 +186,6 @@ function parseProcessResult(value: unknown, label: string): ProcessResult { }; } -export async function readWindowsBrokerIdentity(directory: string): Promise { - const value = await readJson(join(directory, CHILD_FILE)); - if (value === undefined) { - return undefined; - } - const object = expectRecord(value, "Windows Job Object broker identity"); - if (object.schemaVersion !== 1 || typeof object.executionId !== "string" || typeof object.requestHash !== "string" - || typeof object.brokerName !== "string" || typeof object.brokerToken !== "string" - || typeof object.helperSha256 !== "string") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "persisted Windows Job Object broker identity is malformed"); - } - return { - schemaVersion: 1, - executionId: object.executionId, - requestHash: object.requestHash, - brokerName: object.brokerName, - brokerToken: object.brokerToken, - helperSha256: object.helperSha256, - }; -} - export async function readSupervisedResult(directory: string): Promise { const value = await readJson(join(directory, RESULT_FILE)); return value === undefined ? undefined : parseProcessResult(value, "supervisor result"); @@ -271,17 +240,9 @@ async function publishRequest(directory: string, request: SupervisedProcessReque } } -async function validateWindowsHelperIdentity(request: SupervisedProcessRequest): Promise { - if (process.platform !== "win32") { - return; - } - const helperSha256 = await verifiedWindowsJobHelperSha256(); - if (request.windowsHelperSha256 === undefined || helperSha256 === undefined - || request.windowsHelperSha256 !== helperSha256) { - throw new AutopilotError( - "EXECUTION_STATE_UNKNOWN", - "supervised Windows execution helper identity is missing or changed", - ); +function assertProcessSupervisionSupported(): void { + if (process.platform === "win32") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "runtime-owned process supervision is unavailable on Windows"); } } @@ -303,7 +264,7 @@ async function waitForStatus( } } await failIfWatchdogErrored(directory, executionId, requestHash); - if (status !== undefined && (process.platform !== "win32" || status.state !== "starting")) { + if (status !== undefined) { return status; } await new Promise((resolve) => setTimeout(resolve, 25)); @@ -316,7 +277,7 @@ export async function launchSupervisedProcess( request: SupervisedProcessRequest, environment: Readonly, ): Promise { - await validateWindowsHelperIdentity(request); + assertProcessSupervisionSupported(); await mkdir(directory, { recursive: true, mode: 0o700 }); const created = await publishRequest(directory, request); const persistedRequest = await readSupervisedRequest(directory); @@ -366,7 +327,7 @@ export async function reattachSupervisedProcess( directory: string, request: SupervisedProcessRequest, ): Promise { - await validateWindowsHelperIdentity(request); + assertProcessSupervisionSupported(); const existing = await readSupervisedRequest(directory); if (existing === undefined) { return undefined; @@ -384,28 +345,6 @@ export async function reattachSupervisedProcess( if (terminalResult === undefined) { await failIfWatchdogErrored(directory, request.executionId, requestHash); } - if (process.platform === "win32" && !["completed", "failed", "cancelled", "timed-out", "state-unknown"].includes(status.state)) { - const expected = await windowsBrokerIdentity(request.executionId, requestHash); - const persisted = await readWindowsBrokerIdentity(directory); - if (persisted !== undefined && canonicalJson(persisted) !== canonicalJson(expected)) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "persisted Windows Job Object broker identity changed before reattachment"); - } - const reconciliationDeadline = Date.now() + 5_000; - while (true) { - const completion = await readSupervisedCompletion(directory); - if (completion !== undefined) { - break; - } - const observation = await queryWindowsJob(expected); - if (observation.state === "ready" || observation.state === "starting") { - break; - } - if (Date.now() >= reconciliationDeadline) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object is absent before terminal publication"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } return { schemaVersion: 1, executionId: request.executionId, directory, requestHash, startedAt: status.startedAt }; } diff --git a/skills/autopilot/runtime/src/supervisor-child.ts b/skills/autopilot/runtime/src/supervisor-child.ts index 98070b8..44ba5e3 100644 --- a/skills/autopilot/runtime/src/supervisor-child.ts +++ b/skills/autopilot/runtime/src/supervisor-child.ts @@ -13,13 +13,6 @@ import { type SupervisedProcessStatus, } from "./process-supervisor.js"; import { boundUtf8, runProcess, type ProcessResult } from "./process.js"; -import { - launchWindowsJob, - queryWindowsJob, - resolveWindowsCommand, - windowsBrokerIdentity, - type WindowsBrokerIdentity, -} from "./windows-job.js"; function redactionValues( environment: Readonly, @@ -93,27 +86,15 @@ async function waitForWatchdogReady(directory: string, requestHash: string): Pro throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "attempt watchdog did not confirm readiness before harness launch"); } -async function waitForWindowsJobReady(identity: WindowsBrokerIdentity): Promise { - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - const observation = await queryWindowsJob(identity); - if (observation.state === "ready") { - return; - } - if (observation.state === "empty") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object became empty before launch readiness"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object did not confirm assignment-before-resume readiness"); -} - async function main(): Promise { const directory = process.argv[2]; if (directory === undefined) { process.exitCode = 2; return; } + if (process.platform === "win32") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "runtime-owned process supervision is unavailable on Windows"); + } const request = await readSupervisedRequest(directory); if (request === undefined) { process.exitCode = 2; @@ -183,69 +164,29 @@ async function main(): Promise { let result: ProcessResult; let state: SupervisedProcessStatus["state"]; try { - if (process.platform === "win32") { - const identity = await windowsBrokerIdentity(request.executionId, requestHash); - if (request.windowsHelperSha256 === undefined || identity.helperSha256 !== request.windowsHelperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper identity changed before launch"); - } - const command = await resolveWindowsCommand(request.executable, request.arguments, request.cwd, environment); - let resolveHelperPid: (pid: number) => void = () => undefined; - const helperPid = new Promise((resolve) => { - resolveHelperPid = resolve; - }); - const launch = launchWindowsJob(identity, { - executable: command.executable, - arguments: command.arguments, - cwd: request.cwd, - environment, - }, { - maximumOutputBytes: request.maximumOutputBytes, - redactValues: sensitiveValues, - onActivity: recordActivity, - ...(onStderrLine === undefined ? {} : { onStderrLine }), - onSpawn: resolveHelperPid, - }); - void launch.catch(() => undefined); - const launchedHelperPid = await Promise.race([ - helperPid, - launch.then(() => { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper exited before publishing its process identity"); - }), - ]); - await waitForWindowsJobReady(identity); - writeChildIdentity(directory, { - ...identity, - helperPid: launchedHelperPid, - supervisorPid: process.pid, - startedAt: new Date().toISOString(), - }); - await publishStatus("running"); - result = await launch; - } else { - await publishStatus("running"); - result = await runProcess({ - executable: request.executable, - arguments: request.arguments, - cwd: request.cwd, - environment, - maxOutputBytes: request.maximumOutputBytes, - redactValues: sensitiveValues, - detached: false, - terminationProcessGroupId: process.pid, - onActivity: recordActivity, - onSpawn: (childPid: number): void => { - writeChildIdentity(directory, { - schemaVersion: 1, - executionId: request.executionId, - requestHash, - childPid, - supervisorPid: process.pid, - startedAt: new Date().toISOString(), - }); - }, - ...(onStderrLine === undefined ? {} : { onStderrLine }), - }); - } + await publishStatus("running"); + result = await runProcess({ + executable: request.executable, + arguments: request.arguments, + cwd: request.cwd, + environment, + maxOutputBytes: request.maximumOutputBytes, + redactValues: sensitiveValues, + detached: false, + terminationProcessGroupId: process.pid, + onActivity: recordActivity, + onSpawn: (childPid: number): void => { + writeChildIdentity(directory, { + schemaVersion: 1, + executionId: request.executionId, + requestHash, + childPid, + supervisorPid: process.pid, + startedAt: new Date().toISOString(), + }); + }, + ...(onStderrLine === undefined ? {} : { onStderrLine }), + }); state = result.exitCode === 0 ? "completed" : "failed"; } catch (error) { state = "state-unknown"; diff --git a/skills/autopilot/runtime/src/supervisor-watchdog.ts b/skills/autopilot/runtime/src/supervisor-watchdog.ts index a9f142d..979b6e6 100644 --- a/skills/autopilot/runtime/src/supervisor-watchdog.ts +++ b/skills/autopilot/runtime/src/supervisor-watchdog.ts @@ -10,7 +10,6 @@ import { supervisorRequestHash, } from "./process-supervisor.js"; import { terminateProcessTree } from "./process.js"; -import { terminateWindowsJob, verifiedWindowsJobHelperSha256, windowsBrokerIdentity } from "./windows-job.js"; function processExists(pid: number): boolean { try { @@ -21,37 +20,11 @@ function processExists(pid: number): boolean { } } -async function quiesceExecution( - supervisorPid: number, - executable: string, - executionId: string, - requestHash: string, - windowsHelperSha256?: string, -): Promise { - if (process.platform !== "win32") { - await terminateProcessTree(supervisorPid, executable); - return; - } - const helperSha256 = await verifiedWindowsJobHelperSha256(); - if (windowsHelperSha256 === undefined || helperSha256 === undefined || windowsHelperSha256 !== helperSha256) { - throw new Error("Windows Job Object helper identity changed before process-tree quiescence"); - } - await terminateWindowsJob(await windowsBrokerIdentity(executionId, requestHash)); - try { - process.kill(supervisorPid); - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) { - throw error; - } +async function quiesceExecution(supervisorPid: number, executable: string): Promise { + if (process.platform === "win32") { + throw new Error("runtime-owned process supervision is unavailable on Windows"); } - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - if (!processExists(supervisorPid)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error("Windows Job Object is empty but its attempt supervisor did not terminate"); + await terminateProcessTree(supervisorPid, executable); } async function lastActivityAt(directory: string, startedAt: string): Promise { @@ -160,13 +133,7 @@ async function main(): Promise { let terminalState: "cancelled" | "completed" | "failed" | "timed-out" | "state-unknown" = state; let terminalMessage = message; try { - await quiesceExecution( - supervisorPid, - request.executable, - request.executionId, - requestHash, - request.windowsHelperSha256, - ); + await quiesceExecution(supervisorPid, request.executable); } catch (error) { terminalState = "state-unknown"; terminalMessage = error instanceof Error ? error.message : String(error); diff --git a/skills/autopilot/runtime/src/windows-job.ts b/skills/autopilot/runtime/src/windows-job.ts deleted file mode 100644 index efc7325..0000000 --- a/skills/autopilot/runtime/src/windows-job.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile, stat } from "node:fs/promises"; -import { dirname, extname, isAbsolute, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { AutopilotError } from "./errors.js"; -import { expectInteger, expectLiteral, expectRecord } from "./json.js"; -import { runProcess, type ProcessResult } from "./process.js"; - -const PROTOCOL_MAGIC = Buffer.from("APJOB001", "ascii"); -const PROTOCOL_VERSION = 1; -const OPERATION = { launch: 1, query: 2, terminate: 3 } as const; -const X64_PE_MACHINE = 0x8664; -export const WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES = 16_777_216; -export const WINDOWS_BROKER_MAXIMUM_FIELD_BYTES = 1_048_576; -export const WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES = 16_384; - -export interface WindowsJobHelperLocation { - readonly executable: string; - readonly manifest: string; -} - -export interface WindowsBrokerIdentity { - readonly schemaVersion: 1; - readonly executionId: string; - readonly requestHash: string; - readonly brokerName: string; - readonly brokerToken: string; - readonly helperSha256: string; -} - -export interface WindowsJobObservation { - readonly state: "ready" | "starting" | "busy" | "empty" | "absent" | "terminated"; - readonly activeProcesses: number; -} - -export interface WindowsResolvedCommand { - readonly executable: string; - readonly arguments: readonly string[]; -} - -interface WindowsJobManifest { - readonly schemaVersion: 1; - readonly platform: "win32"; - readonly architecture: "x64"; - readonly protocolVersion: 1; - readonly provenance: "github-actions-workflow-dispatch"; - readonly sourceCommit: string; - readonly sourceSha256: string; - readonly workflowRunId: string; - readonly workflowRunAttempt: string; - readonly workflowSha: string; - readonly workflowName: "Autopilot Windows Job Object helper"; - readonly workflowRef: string; - readonly workflowEvent: "workflow_dispatch"; - readonly repository: "drafael/coding-harness"; - readonly toolset: string; - readonly sha256: string; -} - -interface LaunchRequest { - readonly operation: "launch"; - readonly brokerName: string; - readonly brokerToken: string; - readonly executable: string; - readonly arguments: readonly string[]; - readonly cwd: string; - readonly environment: Readonly; -} - -interface ControlRequest { - readonly operation: "query" | "terminate"; - readonly brokerName: string; - readonly brokerToken: string; -} - -export function packagedWindowsJobHelper(): WindowsJobHelperLocation { - return { - executable: fileURLToPath(new URL("../native/win32-x64/job-helper.exe", import.meta.url)), - manifest: fileURLToPath(new URL("../native/win32-x64/job-helper.json", import.meta.url)), - }; -} - -function parseManifest(value: unknown): WindowsJobManifest { - const object = expectRecord(value, "Windows Job Object helper manifest"); - if (object.schemaVersion !== 1 || object.platform !== "win32" || object.architecture !== "x64" - || object.protocolVersion !== 1 || object.provenance !== "github-actions-workflow-dispatch" - || typeof object.sourceCommit !== "string" || !/^[a-f0-9]{40,64}$/u.test(object.sourceCommit) - || typeof object.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(object.sourceSha256) - || typeof object.workflowRunId !== "string" || !/^[1-9][0-9]*$/u.test(object.workflowRunId) - || typeof object.workflowRunAttempt !== "string" || !/^[1-9][0-9]*$/u.test(object.workflowRunAttempt) - || typeof object.workflowSha !== "string" || !/^[a-f0-9]{40,64}$/u.test(object.workflowSha) - || object.workflowName !== "Autopilot Windows Job Object helper" - || typeof object.workflowRef !== "string" - || !/^drafael\/coding-harness\/\.github\/workflows\/autopilot-windows-helper\.yml@refs\/heads\/[A-Za-z0-9._\/-]+$/u.test(object.workflowRef) - || object.workflowEvent !== "workflow_dispatch" || object.repository !== "drafael/coding-harness" - || typeof object.toolset !== "string" || object.toolset.length === 0 - || typeof object.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(object.sha256)) { - throw new AutopilotError("ADAPTER_UNSUPPORTED", "Windows Job Object helper manifest is invalid"); - } - return { - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - provenance: "github-actions-workflow-dispatch", - sourceCommit: object.sourceCommit, - sourceSha256: object.sourceSha256, - workflowRunId: object.workflowRunId, - workflowRunAttempt: object.workflowRunAttempt, - workflowSha: object.workflowSha, - workflowName: "Autopilot Windows Job Object helper", - workflowRef: object.workflowRef, - workflowEvent: "workflow_dispatch", - repository: "drafael/coding-harness", - toolset: object.toolset, - sha256: object.sha256, - }; -} - -function peMachine(bytes: Buffer): number | undefined { - if (bytes.length < 64 || bytes[0] !== 0x4d || bytes[1] !== 0x5a) { - return undefined; - } - const peOffset = bytes.readUInt32LE(0x3c); - if (peOffset + 6 > bytes.length || bytes.subarray(peOffset, peOffset + 4).toString("binary") !== "PE\0\0") { - return undefined; - } - return bytes.readUInt16LE(peOffset + 4); -} - -export async function verifyWindowsJobHelper( - location: WindowsJobHelperLocation = packagedWindowsJobHelper(), - platform: NodeJS.Platform = process.platform, - architecture: string = process.arch, -): Promise<{ readonly available: boolean; readonly sha256?: string }> { - if (platform !== "win32" || architecture !== "x64") { - return { available: false }; - } - try { - const [executable, manifestValue] = await Promise.all([ - readFile(location.executable), - readFile(location.manifest, "utf8"), - ]); - const manifest = parseManifest(JSON.parse(manifestValue) as unknown); - const sha256 = createHash("sha256").update(executable).digest("hex"); - return manifest.sha256 === sha256 && peMachine(executable) === X64_PE_MACHINE - ? { available: true, sha256 } - : { available: false }; - } catch { - return { available: false }; - } -} - -export async function verifiedWindowsJobHelperSha256(): Promise { - const verified = await verifyWindowsJobHelper(); - return verified.available ? verified.sha256 : undefined; -} - -export async function windowsRestartReattachmentAvailable(): Promise { - return await verifiedWindowsJobHelperSha256() !== undefined; -} - -export async function windowsBrokerIdentity(executionId: string, requestHash: string): Promise { - const helper = await checkedHelper(); - return { - schemaVersion: 1, - executionId, - requestHash, - brokerName: windowsBrokerName(executionId, requestHash), - brokerToken: createHash("sha256").update(`broker-token\0${executionId}\0${requestHash}`, "utf8").digest("hex"), - helperSha256: helper.sha256, - }; -} - -export function windowsBrokerName(executionId: string, requestHash: string): string { - const identity = createHash("sha256").update(`${executionId}\0${requestHash}`, "utf8").digest("hex"); - return `\\\\.\\pipe\\AutopilotBroker_${identity}`; -} - -function environmentValue(environment: Readonly, name: string): string | undefined { - const entry = Object.entries(environment).find(([candidate]) => candidate.toLowerCase() === name.toLowerCase()); - return entry?.[1]; -} - -async function existingFile(path: string): Promise { - try { - return (await stat(path)).isFile(); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false; - } - throw error; - } -} - -async function resolveNpmCommandShim(commandPath: string, arguments_: readonly string[]): Promise { - const content = await readFile(commandPath, "utf8"); - const normalized = content.replaceAll("\r\n", "\n"); - const matches = [...normalized.matchAll(/["']%dp0%[\\/]([^"']+\.(?:cjs|mjs|js))["']\s+%\*/giu)]; - if (matches.length !== 1 || !/^@ECHO off\n/iu.test(normalized) || !normalized.includes("CALL :find_dp0")) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows command shim is not a recognized npm argv-preserving launcher"); - } - const relativeScript = matches[0]?.[1]; - if (relativeScript === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows npm command shim entry point is missing"); - } - const script = resolve(dirname(commandPath), relativeScript); - if (!await existingFile(script)) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "Windows npm command shim entry point is unavailable"); - } - const adjacentNode = join(dirname(commandPath), "node.exe"); - return { - executable: await existingFile(adjacentNode) ? adjacentNode : process.execPath, - arguments: [script, ...arguments_], - }; -} - -export async function resolveWindowsCommand( - executable: string, - arguments_: readonly string[], - cwd: string, - environment: Readonly, -): Promise { - const hasPath = isAbsolute(executable) || /[\\/]/u.test(executable); - const extensions = extname(executable) === "" - ? (environmentValue(environment, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) - : [""]; - const directories = hasPath - ? [""] - : [cwd, ...(environmentValue(environment, "PATH") ?? "").split(";").filter(Boolean)]; - const base = hasPath ? (isAbsolute(executable) ? executable : resolve(cwd, executable)) : executable; - let resolvedPath: string | undefined; - for (const directory of directories) { - for (const extension of extensions) { - const candidate = hasPath ? `${base}${extension}` : join(directory, `${base}${extension}`); - if (await existingFile(candidate)) { - resolvedPath = resolve(candidate); - break; - } - } - if (resolvedPath !== undefined) { - break; - } - } - if (resolvedPath === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", `Windows executable is unavailable: ${executable}`); - } - const extension = extname(resolvedPath).toLowerCase(); - if (extension === ".cmd") { - return await resolveNpmCommandShim(resolvedPath, arguments_); - } - if (extension !== ".exe" && extension !== ".com") { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", `Windows executable is not CreateProcess-compatible: ${resolvedPath}`); - } - return { executable: resolvedPath, arguments: arguments_ }; -} - -function appendUInt32(parts: Buffer[], value: number): void { - const bytes = Buffer.allocUnsafe(4); - bytes.writeUInt32LE(value); - parts.push(bytes); -} - -function appendString(parts: Buffer[], value: string): void { - const bytes = Buffer.from(value, "utf8"); - if (bytes.length > WINDOWS_BROKER_MAXIMUM_FIELD_BYTES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker field exceeds the protocol bound"); - } - appendUInt32(parts, bytes.length); - parts.push(bytes); -} - -export function encodeWindowsJobRequest(request: LaunchRequest | ControlRequest): Buffer { - const parts: Buffer[] = [PROTOCOL_MAGIC]; - appendUInt32(parts, PROTOCOL_VERSION); - appendUInt32(parts, OPERATION[request.operation]); - appendString(parts, request.brokerName); - appendString(parts, request.brokerToken); - if (request.operation === "launch") { - if (request.arguments.length > WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker argument count exceeds the protocol bound"); - } - appendString(parts, request.executable); - appendString(parts, request.cwd); - appendUInt32(parts, request.arguments.length); - request.arguments.forEach((argument) => appendString(parts, argument)); - const environment = Object.entries(request.environment) - .filter((entry): entry is [string, string] => entry[1] !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - if (environment.length > WINDOWS_BROKER_MAXIMUM_LIST_ENTRIES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object broker environment count exceeds the protocol bound"); - } - appendUInt32(parts, environment.length); - environment.forEach(([name, value]) => { - appendString(parts, name); - appendString(parts, value); - }); - } - const encoded = Buffer.concat(parts); - if (encoded.length > WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES) { - throw new AutopilotError("ADAPTER_MALFORMED", "Windows Job Object helper request exceeds the protocol bound"); - } - return encoded; -} - -export function parseWindowsJobObservation(stdout: string): WindowsJobObservation { - let value: unknown; - try { - value = JSON.parse(stdout.trim()) as unknown; - } catch (error) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper returned malformed control output", { - cause: String(error), - }); - } - const object = expectRecord(value, "Windows Job Object helper response"); - if (object.schemaVersion !== 1 || object.protocolVersion !== 1) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object helper response version changed"); - } - return { - state: expectLiteral(object.state, ["ready", "starting", "busy", "empty", "absent", "terminated"], "Windows Job Object helper response.state"), - activeProcesses: expectInteger(object.activeProcesses, "Windows Job Object helper response.activeProcesses", 0), - }; -} - -async function checkedHelper(): Promise<{ readonly location: WindowsJobHelperLocation; readonly sha256: string }> { - const location = packagedWindowsJobHelper(); - const verified = await verifyWindowsJobHelper(location); - if (!verified.available || verified.sha256 === undefined) { - throw new AutopilotError("UNSUPPORTED_CAPABILITY", "verified win32-x64 Job Object helper is unavailable"); - } - return { location, sha256: verified.sha256 }; -} - -function canonicalIdentity(identity: WindowsBrokerIdentity): string { - return [ - identity.schemaVersion, - identity.executionId, - identity.requestHash, - identity.brokerName, - identity.brokerToken, - identity.helperSha256, - ].join("\0"); -} - -function helperEnvironment(source: Readonly = process.env): NodeJS.ProcessEnv { - return Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "PATHEXT"].flatMap((name) => { - const value = environmentValue(source, name); - return value === undefined ? [] : [[name, value]]; - })); -} - -async function terminateControlHelper(pid: number): Promise { - try { - process.kill(pid); - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) { - throw error; - } - } -} - -async function control(operation: "query" | "terminate", identity: WindowsBrokerIdentity): Promise { - const helper = await checkedHelper(); - const expected = await windowsBrokerIdentity(identity.executionId, identity.requestHash); - if (canonicalIdentity(identity) !== canonicalIdentity(expected) || helper.sha256 !== identity.helperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object broker identity changed"); - } - const result = await runProcess({ - executable: helper.location.executable, - arguments: [], - cwd: process.cwd(), - environment: helperEnvironment(), - stdin: encodeWindowsJobRequest({ - operation, - brokerName: identity.brokerName, - brokerToken: identity.brokerToken, - }), - timeoutMs: 10_000, - terminate: terminateControlHelper, - maxOutputBytes: 65_536, - detached: false, - }); - if (result.exitCode !== 0 || result.stderr !== "") { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `Windows Job Object ${operation} was not confirmed`, { - exitCode: result.exitCode, - stderr: result.stderr, - }); - } - return parseWindowsJobObservation(result.stdout); -} - -export async function queryWindowsJob(identity: WindowsBrokerIdentity): Promise { - return await control("query", identity); -} - -export async function terminateWindowsJob(identity: WindowsBrokerIdentity): Promise { - const deadline = Date.now() + 5_000; - while (true) { - const observation = await control("terminate", identity); - if (observation.state === "terminated" || observation.state === "absent" || observation.state === "empty") { - return observation; - } - if (observation.state !== "busy" || Date.now() >= deadline) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object did not become quiescent"); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } -} - -export async function launchWindowsJob( - identity: WindowsBrokerIdentity, - request: Omit, - processOptions: { - readonly maximumOutputBytes: number; - readonly redactValues: readonly string[]; - readonly onActivity: () => void; - readonly onStderrLine?: (line: string) => void; - readonly onSpawn: (helperPid: number) => void; - }, -): Promise { - const helper = await checkedHelper(); - const expected = await windowsBrokerIdentity(identity.executionId, identity.requestHash); - if (canonicalIdentity(identity) !== canonicalIdentity(expected) || helper.sha256 !== identity.helperSha256) { - throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Windows Job Object broker launch identity changed"); - } - return await runProcess({ - executable: helper.location.executable, - arguments: [], - cwd: request.cwd, - environment: helperEnvironment(request.environment), - stdin: encodeWindowsJobRequest({ - ...request, - operation: "launch", - brokerName: identity.brokerName, - brokerToken: identity.brokerToken, - }), - maxOutputBytes: processOptions.maximumOutputBytes, - redactValues: processOptions.redactValues, - detached: false, - onActivity: processOptions.onActivity, - ...(processOptions.onStderrLine === undefined ? {} : { onStderrLine: processOptions.onStderrLine }), - onSpawn: processOptions.onSpawn, - }); -} diff --git a/skills/autopilot/runtime/test/adapter-contract.test.ts b/skills/autopilot/runtime/test/adapter-contract.test.ts index dc2dc4a..0dcf3c7 100644 --- a/skills/autopilot/runtime/test/adapter-contract.test.ts +++ b/skills/autopilot/runtime/test/adapter-contract.test.ts @@ -6,7 +6,6 @@ import { test } from "node:test"; import { executionAssuranceFor, parseAdapterMessage, type ExecutionRequest } from "../src/adapter-protocol.js"; import { CliHarnessAdapter, parseReviewResult } from "../src/adapter-process.js"; import { boundUtf8, runProcess, terminateProcessTree } from "../src/process.js"; -import { windowsRestartReattachmentAvailable } from "../src/windows-job.js"; import { attemptContextFixture, writeNodeExecutable } from "./helpers.js"; test("UTF-8 output bounds retain only complete code points", () => { @@ -17,9 +16,7 @@ test("UTF-8 output bounds retain only complete code points", () => { assert.equal(bounded.truncated, true); }); -test("Windows CLI adapters advertise restart reattachment only with the verified x64 helper", { - skip: process.platform !== "win32", -}, async () => { +test("CLI adapters advertise process supervision only on POSIX", async () => { const adapter = new CliHarnessAdapter({ name: "fake", executable: process.execPath, @@ -32,11 +29,30 @@ test("Windows CLI adapters advertise restart reattachment only with the verified expectsJsonLines: false, }); - assert.equal((await adapter.describe()).restartReattachment, await windowsRestartReattachmentAvailable()); + const manifest = await adapter.describe(); + const processSupervisionAvailable = process.platform !== "win32"; + + assert.equal(manifest.restartReattachment, processSupervisionAvailable); + assert.equal( + manifest.executionAssurance?.implementation.continuity, + processSupervisionAvailable ? "durable-subject" : "session", + ); + assert.equal( + manifest.executionAssurance?.implementation.terminality, + processSupervisionAvailable ? "process-supervised" : "cooperative", + ); + assert.equal( + manifest.executionAssurance?.implementation.admission, + processSupervisionAvailable ? "idempotent" : "single-shot", + ); + assert.equal( + manifest.limitations.some((limitation) => limitation.includes("session-scoped")), + !processSupervisionAvailable, + ); }); -test("Windows supervised launch and reattach fail closed when the packaged helper is unavailable", { - skip: process.platform !== "win32" || await windowsRestartReattachmentAvailable(), +test("Windows process supervision requests fail closed instead of launching a direct replacement", { + skip: process.platform !== "win32", }, async () => { const adapter = new CliHarnessAdapter({ name: "fake", diff --git a/skills/autopilot/runtime/test/packaging.test.ts b/skills/autopilot/runtime/test/packaging.test.ts index e2fd342..39ffcd5 100644 --- a/skills/autopilot/runtime/test/packaging.test.ts +++ b/skills/autopilot/runtime/test/packaging.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { cp, mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,9 +6,6 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { isRecord } from "../src/json.js"; import { runProcess } from "../src/process.js"; -import { verifyWindowsJobHelper } from "../src/windows-job.js"; - -const checkedWindowsHelper = join(process.cwd(), "native", "bin", "win32-x64", "job-helper.exe"); test("compiled skill CLI starts from a clean copy without node_modules", async () => { const copyRoot = await mkdtemp(join(tmpdir(), "autopilot-clean-copy-")); @@ -31,23 +27,24 @@ test("compiled skill CLI starts from a clean copy without node_modules", async ( const checks: unknown = JSON.parse(doctor.stdout); assert.ok(Array.isArray(checks)); const node = checks.find((entry) => isRecord(entry) && entry.name === "node"); + const processSupervision = checks.find((entry) => isRecord(entry) && entry.name === "process-supervision"); assert.ok(isRecord(node)); + assert.ok(isRecord(processSupervision)); assert.equal(node.status, "ok"); + assert.equal(processSupervision.status, process.platform === "win32" ? "unsupported" : "ok"); }); -test("package contains exactly one verified win32-x64 Job Object helper", { - skip: !existsSync(checkedWindowsHelper), -}, async () => { - const sourceManifest = join(process.cwd(), "native", "bin", "win32-x64", "job-helper.json"); - const packagedExecutable = join(process.cwd(), "dist", "native", "win32-x64", "job-helper.exe"); - const packagedManifest = join(process.cwd(), "dist", "native", "win32-x64", "job-helper.json"); - const [source, packaged] = await Promise.all([readFile(checkedWindowsHelper), readFile(packagedExecutable)]); - assert.equal(createHash("sha256").update(source).digest("hex"), createHash("sha256").update(packaged).digest("hex")); - assert.deepEqual(await verifyWindowsJobHelper({ executable: packagedExecutable, manifest: packagedManifest }, "win32", "x64"), { - available: true, - sha256: createHash("sha256").update(source).digest("hex"), +test("source, build output, and package contain no project-owned native binaries", async () => { + const tracked = await runProcess({ + executable: "git", + arguments: ["ls-files", "--", "."], + cwd: process.cwd(), }); - assert.ok((await readFile(sourceManifest, "utf8")).includes(createHash("sha256").update(source).digest("hex"))); + assert.equal(tracked.exitCode, 0); + assert.deepEqual(tracked.stdout.split("\n").filter((path) => + existsSync(join(process.cwd(), path)) && (path.startsWith("native/") || /\.(?:exe|node)$/iu.test(path)) + ), []); + assert.equal(existsSync(join(process.cwd(), "dist", "native")), false); const npmArguments = ["pack", "--dry-run", "--json"]; const npmCli = process.env.npm_execpath ?? (process.platform === "win32" @@ -64,9 +61,8 @@ test("package contains exactly one verified win32-x64 Job Object helper", { }); assert.equal(packed.exitCode, 0); const inventory = JSON.parse(packed.stdout) as Array<{ readonly files: readonly { readonly path: string }[] }>; - const helperFiles = inventory[0]?.files.filter(({ path }) => path.startsWith("dist/native/win32-x64/")) ?? []; - assert.deepEqual(helperFiles.map(({ path }) => path).sort(), [ - "dist/native/win32-x64/job-helper.exe", - "dist/native/win32-x64/job-helper.json", - ]); + const nativeFiles = inventory[0]?.files.filter(({ path }) => + path.startsWith("dist/native/") || /\.(?:exe|node)$/iu.test(path) + ) ?? []; + assert.deepEqual(nativeFiles, []); }); diff --git a/skills/autopilot/runtime/test/process-supervisor.test.ts b/skills/autopilot/runtime/test/process-supervisor.test.ts index 72830c5..1c156fd 100644 --- a/skills/autopilot/runtime/test/process-supervisor.test.ts +++ b/skills/autopilot/runtime/test/process-supervisor.test.ts @@ -1,18 +1,10 @@ import assert from "node:assert/strict"; -import { once } from "node:events"; import { access, mkdir, mkdtemp, readFile, stat, unlink, writeFile } from "node:fs/promises"; -import { createConnection } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { writeImmutableJson, writeJsonAtomic } from "../src/journal.js"; import { runProcess } from "../src/process.js"; -import { - queryWindowsJob, - terminateWindowsJob, - verifiedWindowsJobHelperSha256, - windowsRestartReattachmentAvailable, -} from "../src/windows-job.js"; import { cancelSupervisedProcess, launchSupervisedProcess, @@ -26,9 +18,7 @@ import { type SupervisedProcessRequest, } from "../src/process-supervisor.js"; -const windowsHelperSha256 = process.platform === "win32" ? await verifiedWindowsJobHelperSha256() : undefined; -const windowsJobSupported = process.platform === "win32" && await windowsRestartReattachmentAvailable(); -const supervisedTest = process.platform === "win32" && !windowsJobSupported ? test.skip : test; +const supervisedTest = process.platform === "win32" ? test.skip : test; async function waitForFile(path: string): Promise { const deadline = Date.now() + 5_000; @@ -52,7 +42,6 @@ function requestFor(root: string, script: string, deadlineMs = 10_000): Supervis itemId: "item-1", attemptId: "attempt-1", contextHash: "context-hash", - ...(windowsHelperSha256 === undefined ? {} : { windowsHelperSha256 }), executable: process.execPath, arguments: [script], cwd: root, @@ -153,7 +142,7 @@ test("watchdog errors fail nonterminal execution without poisoning valid termina exitCode: terminalResult.exitCode, }); - if (process.platform !== "win32" || windowsJobSupported) { + if (process.platform !== "win32") { const launched = await launchSupervisedProcess(directory, request, process.env); const reattached = await reattachSupervisedProcess(directory, request); assert.ok(reattached !== undefined); @@ -202,12 +191,6 @@ supervisedTest("supervised process request identity is immutable", async () => { reattachSupervisedProcess(directory, { ...request, contextHash: "changed" }), /request changed/u, ); - if (process.platform === "win32") { - await assert.rejects( - reattachSupervisedProcess(directory, { ...request, windowsHelperSha256: "0".repeat(64) }), - (error: unknown) => error instanceof Error && "code" in error && error.code === "EXECUTION_STATE_UNKNOWN", - ); - } await observeSupervisedProcess(handle); }); @@ -319,102 +302,3 @@ supervisedTest("supervised cancellation waits for a terminal process-tree observ assert.equal(observed.state, "cancelled"); assert.equal(observed.result.exitCode, 124); }); - -test("Windows broker preserves npm shim argv and environment casing end to end", { - skip: !windowsJobSupported, -}, async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-supervisor-windows-argv-")); - const bin = join(root, "bin"); - const entry = join(bin, "adapter-entry.mjs"); - const shim = join(bin, "adapter.cmd"); - await mkdir(bin); - await writeFile(entry, `console.log(JSON.stringify({ arguments: process.argv.slice(2), environment: process.env.MiXeD_Value }));\n`); - await writeFile(shim, [ - "@ECHO off", - "GOTO start", - ":find_dp0", - "SET dp0=%~dp0", - "EXIT /b", - ":start", - "SETLOCAL", - "CALL :find_dp0", - 'endLocal & "%_prog%" "%dp0%\\adapter-entry.mjs" %*', - "", - ].join("\r\n")); - const arguments_ = ["", "quote\"value", "trailing\\", "ユニコード"]; - const environment = { - SystemRoot: process.env.SystemRoot, - Path: bin, - PATHEXT: ".EXE;.CMD", - MiXeD_Value: "Case-Preserved", - }; - const base = requestFor(root, entry); - const request = { - ...base, - executable: "adapter", - arguments: arguments_, - environmentNames: Object.keys(environment), - }; - const directory = supervisorDirectory(root, request.executionId); - const handle = await launchSupervisedProcess(directory, request, environment); - - const observed = await observeSupervisedProcess(handle); - - assert.equal(observed.state, "completed"); - assert.deepEqual(JSON.parse(observed.result.stdout), { - arguments: arguments_, - environment: "Case-Preserved", - }); -}); - -test("Windows broker retries transient unauthenticated contention before exact termination", { - skip: !windowsJobSupported, -}, async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-supervisor-broker-busy-")); - const script = join(root, "child.mjs"); - await writeFile(script, "setInterval(() => {}, 1000);\n"); - const request = requestFor(root, script, 30_000); - const directory = supervisorDirectory(root, request.executionId); - const handle = await launchSupervisedProcess(directory, request, process.env); - const identity = JSON.parse(await readFile(join(directory, "child.json"), "utf8")) as { - schemaVersion: 1; - executionId: string; - requestHash: string; - brokerName: string; - brokerToken: string; - helperSha256: string; - }; - const occupier = createConnection(identity.brokerName); - const occupierErrors: NodeJS.ErrnoException[] = []; - occupier.on("error", (error: NodeJS.ErrnoException) => occupierErrors.push(error)); - await once(occupier, "connect"); - - assert.deepEqual(await queryWindowsJob(identity), { state: "busy", activeProcesses: 0 }); - const termination = await terminateWindowsJob(identity); - occupier.destroy(); - - assert.ok(occupierErrors.every(({ code }) => code === "EPIPE" || code === "ECONNRESET")); - assert.deepEqual(termination, { state: "terminated", activeProcesses: 0 }); - assert.equal((await observeSupervisedProcess(handle)).state, "failed"); -}); - -test("Windows launch-helper death closes the Job Object and leaves no harness descendant", { - skip: !windowsJobSupported, -}, async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-supervisor-helper-death-")); - const script = join(root, "child.mjs"); - const marker = join(root, "escaped-child"); - await writeFile(script, `import { writeFileSync } from "node:fs"; setTimeout(() => writeFileSync(${JSON.stringify(marker)}, "escaped"), 2000); setInterval(() => {}, 1000);\n`); - const request = requestFor(root, script, 30_000); - const directory = supervisorDirectory(root, request.executionId); - const handle = await launchSupervisedProcess(directory, request, process.env); - await waitForFile(join(directory, "child.json")); - const child = JSON.parse(await readFile(join(directory, "child.json"), "utf8")) as { helperPid: number }; - - process.kill(child.helperPid); - const observed = await observeSupervisedProcess(handle); - await new Promise((resolve) => setTimeout(resolve, 2_250)); - - assert.equal(observed.state, "failed"); - await assert.rejects(readFile(marker), /ENOENT/u); -}); diff --git a/skills/autopilot/runtime/test/windows-diagnostics.test.ts b/skills/autopilot/runtime/test/windows-diagnostics.test.ts deleted file mode 100644 index 6cc4d31..0000000 --- a/skills/autopilot/runtime/test/windows-diagnostics.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "node:test"; -import { runChecked } from "../src/process.js"; - -const collector = join(process.cwd(), "scripts", "collect-windows-diagnostics.mjs"); - -function stderrMetadata(stderr: string, failureKind: string, win32Code?: number) { - return { - stderrByteLength: Buffer.byteLength(stderr), - stderrSha256: createHash("sha256").update(stderr).digest("hex"), - failureKind, - ...(win32Code === undefined ? {} : { win32Code }), - }; -} - -function statusErrorMetadata(error: string, failureKind: string) { - return { - errorByteLength: Buffer.byteLength(error), - errorSha256: createHash("sha256").update(error).digest("hex"), - errorFailureKind: failureKind, - }; -} - -test("Windows diagnostic collector emits typed metadata without secret process output", async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-diagnostics-test-")); - const executionRoot = join(root, "autopilot-supervisor-fixture", "runs", "run-1", "executions"); - const execution = join(executionRoot, "execution-1"); - const knownFailureExecution = join(executionRoot, "execution-2"); - const ignoredExecution = join(root, "unrelated-fixture", "executions", "execution-2"); - const output = join(root, "diagnostics", "windows.json"); - const secretStderr = "SECRET_TOKEN=hunter2\ncannot connect to the Windows Job Object broker (win32=5)\n"; - const knownStderr = "cannot connect to the Windows Job Object broker (win32=5)\n"; - const secretStatusError = "Windows Job Object query was not confirmed SECRET_TOKEN=status-hunter2"; - const secretWatchdogError = "rename failed SECRET_TOKEN=watchdog-hunter2"; - const knownStatusError = "Windows Job Object query was not confirmed"; - await mkdir(execution, { recursive: true }); - await mkdir(knownFailureExecution, { recursive: true }); - await mkdir(ignoredExecution, { recursive: true }); - await writeFile(join(execution, "status.json"), JSON.stringify({ - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - state: "state-unknown", - supervisorPid: process.pid, - startedAt: { nestedSecret: "status-start-secret" }, - updatedAt: ["status-update-secret"], - exitCode: { nestedSecret: "status-exit-secret" }, - error: secretStatusError, - environment: { SECRET_TOKEN: "secret" }, - })); - await writeFile(join(execution, "completion.json"), JSON.stringify({ - state: "failed", - completedAt: "2026-08-31T00:00:00.000Z", - result: { - exitCode: { nestedSecret: "completion-exit-secret" }, - stdout: "private output", - stderr: secretStderr, - truncated: ["completion-truncated-secret"], - }, - })); - await writeFile(join(execution, "result.json"), JSON.stringify({ - exitCode: 1, - stdout: "private output", - stderr: knownStderr, - truncated: false, - nested: { secret: "result-nested-secret" }, - })); - await writeFile(join(execution, "child.json"), JSON.stringify({ - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - brokerName: { nestedSecret: "broker-name-secret" }, - brokerToken: "broker-secret", - helperSha256: "a".repeat(64), - helperPid: process.pid, - childPid: ["child-pid-secret"], - supervisorPid: process.pid, - startedAt: { nestedSecret: "child-start-secret" }, - })); - await writeFile(join(execution, "watchdog-error.json"), JSON.stringify({ - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - failedAt: "2026-08-31T00:00:00.000Z", - error: secretWatchdogError, - environment: { SECRET_TOKEN: "secret" }, - })); - await writeFile(join(execution, "watchdog-ready.json"), JSON.stringify({ - schemaVersion: 1, - executionId: "execution-1", - requestHash: ["watchdog-request-secret"], - supervisorPid: process.pid, - readyAt: "2026-08-31T00:00:00.000Z", - })); - await writeFile(join(knownFailureExecution, "status.json"), JSON.stringify({ - schemaVersion: 1, - executionId: "execution-2", - state: "state-unknown", - error: knownStatusError, - })); - await writeFile(join(execution, "request.json"), JSON.stringify({ environment: { SECRET_TOKEN: "secret" } })); - await writeFile(join(execution, "events.jsonl"), "canonical lifecycle state must not be collected\n"); - await writeFile(join(ignoredExecution, "status.json"), JSON.stringify({ executionId: "ignored" })); - - await runChecked({ - executable: process.execPath, - arguments: [collector, "--root", root, "--output", output], - cwd: root, - }); - const report: unknown = JSON.parse(await readFile(output, "utf8")); - - assert.deepEqual(report, { - schemaVersion: 1, - diagnostics: [ - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/child.json", - file: "child.json", - value: { - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - helperSha256: "a".repeat(64), - helperPid: process.pid, - supervisorPid: process.pid, - }, - pidLiveness: [ - { field: "helperPid", pid: process.pid, state: "alive" }, - { field: "supervisorPid", pid: process.pid, state: "alive" }, - ], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/completion.json", - file: "completion.json", - value: { - state: "failed", - completedAt: "2026-08-31T00:00:00.000Z", - resultStderrByteLength: Buffer.byteLength(secretStderr), - resultStderrSha256: createHash("sha256").update(secretStderr).digest("hex"), - resultFailureKind: "UNKNOWN", - }, - pidLiveness: [], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/result.json", - file: "result.json", - value: { - exitCode: 1, - truncated: false, - ...stderrMetadata(knownStderr, "BROKER_CONNECT_FAILED", 5), - }, - pidLiveness: [], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/status.json", - file: "status.json", - value: { - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - state: "state-unknown", - supervisorPid: process.pid, - ...statusErrorMetadata(secretStatusError, "UNKNOWN"), - }, - pidLiveness: [{ field: "supervisorPid", pid: process.pid, state: "alive" }], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/watchdog-error.json", - file: "watchdog-error.json", - value: { - schemaVersion: 1, - executionId: "execution-1", - requestHash: "request-hash", - failedAt: "2026-08-31T00:00:00.000Z", - ...statusErrorMetadata(secretWatchdogError, "UNKNOWN"), - }, - pidLiveness: [], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-1/watchdog-ready.json", - file: "watchdog-ready.json", - value: { - schemaVersion: 1, - executionId: "execution-1", - supervisorPid: process.pid, - readyAt: "2026-08-31T00:00:00.000Z", - }, - pidLiveness: [{ field: "supervisorPid", pid: process.pid, state: "alive" }], - }, - { - path: "autopilot-supervisor-fixture/runs/run-1/executions/execution-2/status.json", - file: "status.json", - value: { - schemaVersion: 1, - executionId: "execution-2", - state: "state-unknown", - ...statusErrorMetadata(knownStatusError, "JOB_QUERY_UNCONFIRMED"), - }, - pidLiveness: [], - }, - ], - }); - const serialized = JSON.stringify(report); - assert.doesNotMatch(serialized, /hunter2|broker-secret|private output|SECRET_TOKEN|canonical lifecycle/u); - assert.doesNotMatch(serialized, /status-start-secret|completion-exit-secret|result-nested-secret|watchdog-request-secret/u); -}); - -test("Windows diagnostic collector continues past an unreadable execution subtree", { - skip: process.platform === "win32", -}, async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-diagnostics-unreadable-test-")); - const readable = join(root, "autopilot-readable", "executions", "execution-1"); - const unreadable = join(root, "autopilot-unreadable", "executions"); - const output = join(root, "diagnostics", "windows.json"); - await mkdir(readable, { recursive: true }); - await mkdir(unreadable, { recursive: true }); - await writeFile(join(readable, "status.json"), JSON.stringify({ schemaVersion: 1, state: "running" })); - await chmod(unreadable, 0o000); - - try { - await runChecked({ - executable: process.execPath, - arguments: [collector, "--root", root, "--output", output], - cwd: root, - }); - } finally { - await chmod(unreadable, 0o700); - } - const report: unknown = JSON.parse(await readFile(output, "utf8")); - - assert.deepEqual(report, { - schemaVersion: 1, - diagnostics: [ - { - path: "autopilot-readable/executions/execution-1/status.json", - file: "status.json", - value: { schemaVersion: 1, state: "running" }, - pidLiveness: [], - }, - { - path: "autopilot-unreadable/executions", - kind: "subtree", - readState: "unavailable", - }, - ], - }); -}); diff --git a/skills/autopilot/runtime/test/windows-job.test.ts b/skills/autopilot/runtime/test/windows-job.test.ts deleted file mode 100644 index 2e799bc..0000000 --- a/skills/autopilot/runtime/test/windows-job.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "node:test"; -import { - encodeWindowsJobRequest, - packagedWindowsJobHelper, - parseWindowsJobObservation, - resolveWindowsCommand, - verifyWindowsJobHelper, - WINDOWS_BROKER_MAXIMUM_FIELD_BYTES, - WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES, - windowsBrokerName, -} from "../src/windows-job.js"; -import { runProcess } from "../src/process.js"; - -function x64PeImage(): Buffer { - const bytes = Buffer.alloc(256); - bytes.write("MZ", 0, "ascii"); - bytes.writeUInt32LE(128, 0x3c); - bytes.write("PE\0\0", 128, "binary"); - bytes.writeUInt16LE(0x8664, 132); - return bytes; -} - -const token = "a".repeat(64); -const helperProvenance = { - provenance: "github-actions-workflow-dispatch", - sourceCommit: "b".repeat(40), - sourceSha256: "d".repeat(64), - workflowRunId: "123", - workflowRunAttempt: "1", - workflowSha: "c".repeat(40), - workflowName: "Autopilot Windows Job Object helper", - workflowRef: "drafael/coding-harness/.github/workflows/autopilot-windows-helper.yml@refs/heads/feature", - workflowEvent: "workflow_dispatch", - repository: "drafael/coding-harness", - toolset: "MSVC 19.44.35217", -} as const; - -function maximumProtocolRequest(): Buffer { - const fixture = { - operation: "launch" as const, - brokerName: windowsBrokerName("execution", "boundary"), - brokerToken: token, - executable: "C:\\runtime.exe", - arguments: Array.from({ length: 16 }, () => ""), - cwd: "C:\\worktree", - environment: {}, - }; - const base = encodeWindowsJobRequest(fixture); - let remaining = WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES - base.length; - const arguments_ = fixture.arguments.map(() => { - const length = Math.min(remaining, WINDOWS_BROKER_MAXIMUM_FIELD_BYTES); - remaining -= length; - return "x".repeat(length); - }); - assert.equal(remaining, 0); - return encodeWindowsJobRequest({ ...fixture, arguments: arguments_ }); -} - -test("Windows Job Object broker identity is deterministic and request-bound", () => { - const first = windowsBrokerName("execution", "request-a"); - const repeated = windowsBrokerName("execution", "request-a"); - const changed = windowsBrokerName("execution", "request-b"); - - assert.equal(first, repeated); - assert.notEqual(first, changed); - assert.match(first, /^\\\\\.\\pipe\\AutopilotBroker_[a-f0-9]{64}$/u); -}); - -test("Windows Job Object broker protocol preserves arguments and environment without shell syntax", () => { - const encoded = encodeWindowsJobRequest({ - operation: "launch", - brokerName: windowsBrokerName("execution", "request"), - brokerToken: token, - executable: "C:\\Program Files\\runtime.exe", - arguments: ["", "value with spaces", "literal&operator", "quote\"value", "trailing\\", "ユニコード"], - cwd: "C:\\worktree", - environment: { Path: "C:\\bin", SAFE: "value", SECRET_TOKEN: "not-persisted" }, - }); - - assert.equal(encoded.subarray(0, 8).toString("ascii"), "APJOB001"); - assert.equal(encoded.readUInt32LE(8), 1); - assert.equal(encoded.readUInt32LE(12), 1); - assert.ok(encoded.includes(Buffer.from("literal&operator", "utf8"))); - assert.ok(encoded.includes(Buffer.from("SECRET_TOKEN", "utf8"))); - assert.equal(encoded.includes(Buffer.from("cmd.exe", "utf8")), false); -}); - -test("Windows Job Object broker protocol accepts the exact maximum and rejects one byte more", () => { - const exact = maximumProtocolRequest(); - - assert.equal(exact.length, WINDOWS_BROKER_MAXIMUM_PROTOCOL_BYTES); - assert.throws(() => encodeWindowsJobRequest({ - operation: "launch", - brokerName: windowsBrokerName("execution", "field-boundary"), - brokerToken: token, - executable: "C:\\runtime.exe", - arguments: ["x".repeat(WINDOWS_BROKER_MAXIMUM_FIELD_BYTES + 1)], - cwd: "C:\\worktree", - environment: {}, - }), /field exceeds the protocol bound/u); -}); - -test("native Windows broker accepts the exact protocol maximum and rejects one byte more", { - skip: process.platform !== "win32" || process.arch !== "x64", -}, async (context) => { - const helper = packagedWindowsJobHelper(); - const verified = await verifyWindowsJobHelper(helper); - if (!verified.available) { - context.skip("verified packaged helper is absent"); - return; - } - const exact = maximumProtocolRequest(); - const exactResult = await runProcess({ - executable: helper.executable, - arguments: [], - cwd: process.cwd(), - environment: process.env, - stdin: exact, - timeoutMs: 15_000, - }); - const oversizedResult = await runProcess({ - executable: helper.executable, - arguments: [], - cwd: process.cwd(), - environment: process.env, - stdin: Buffer.concat([exact, Buffer.from([0])]), - timeoutMs: 15_000, - }); - - assert.match(exactResult.stderr, /cannot create the harness process suspended/u); - assert.doesNotMatch(exactResult.stderr, /malformed Windows Job Object broker protocol/u); - assert.match(oversizedResult.stderr, /malformed Windows Job Object broker protocol/u); -}); - -test("Windows command resolution preserves case-insensitive Path and npm shim argv", async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-windows-command-")); - const entry = join(root, "entry.mjs"); - const shim = join(root, "adapter.cmd"); - await writeFile(entry, "console.log(JSON.stringify(process.argv.slice(2)));\n"); - await writeFile(shim, [ - "@ECHO off", - "GOTO start", - ":find_dp0", - "SET dp0=%~dp0", - "EXIT /b", - ":start", - "SETLOCAL", - "CALL :find_dp0", - "endLocal & \"%_prog%\" \"%dp0%\\entry.mjs\" %*", - "", - ].join("\r\n")); - const arguments_ = ["", "quote\"value", "trailing\\", "ユニコード"]; - - const resolved = await resolveWindowsCommand("adapter", arguments_, root, { - Path: root, - PATHEXT: ".exe;.cmd", - }); - - assert.equal(resolved.executable, process.execPath); - assert.deepEqual(resolved.arguments, [entry, ...arguments_]); -}); - -test("Windows command resolution prefers a CreateProcess-compatible executable", async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-windows-executable-")); - const executable = join(root, "adapter.exe"); - await writeFile(executable, x64PeImage()); - - const resolved = await resolveWindowsCommand("adapter", ["argument"], root, { - PATH: root, - PATHEXT: ".exe;.cmd", - }); - - assert.equal(resolved.executable, executable); - assert.deepEqual(resolved.arguments, ["argument"]); -}); - -test("Windows Job Object helper verification binds digest and x64 PE architecture", async () => { - const root = await mkdtemp(join(tmpdir(), "autopilot-windows-job-helper-")); - const executable = join(root, "job-helper.exe"); - const manifest = join(root, "job-helper.json"); - const bytes = x64PeImage(); - const sha256 = createHash("sha256").update(bytes).digest("hex"); - await writeFile(executable, bytes); - await writeFile(manifest, `${JSON.stringify({ - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - ...helperProvenance, - sha256, - })}\n`); - - assert.deepEqual(await verifyWindowsJobHelper({ executable, manifest }, "win32", "x64"), { available: true, sha256 }); - assert.deepEqual(await verifyWindowsJobHelper({ executable, manifest }, "win32", "arm64"), { available: false }); - await writeFile(manifest, `${JSON.stringify({ - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - ...helperProvenance, - provenance: "local-untrusted", - sha256, - })}\n`); - assert.deepEqual(await verifyWindowsJobHelper({ executable, manifest }, "win32", "x64"), { available: false }); - await writeFile(manifest, `${JSON.stringify({ - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - sha256, - })}\n`); - assert.deepEqual(await verifyWindowsJobHelper({ executable, manifest }, "win32", "x64"), { available: false }); - await writeFile(manifest, `${JSON.stringify({ - schemaVersion: 1, - platform: "win32", - architecture: "x64", - protocolVersion: 1, - ...helperProvenance, - sha256, - })}\n`); - await writeFile(executable, Buffer.concat([bytes, Buffer.from("tampered")])); - assert.deepEqual(await verifyWindowsJobHelper({ executable, manifest }, "win32", "x64"), { available: false }); -}); - -test("Windows Job Object broker control output rejects ambiguous responses", () => { - assert.deepEqual(parseWindowsJobObservation( - '{"schemaVersion":1,"protocolVersion":1,"state":"ready","activeProcesses":2}\n', - ), { state: "ready", activeProcesses: 2 }); - assert.deepEqual(parseWindowsJobObservation( - '{"schemaVersion":1,"protocolVersion":1,"state":"busy","activeProcesses":0}\n', - ), { state: "busy", activeProcesses: 0 }); - assert.throws(() => parseWindowsJobObservation( - '{"schemaVersion":1,"protocolVersion":1,"state":"unknown","activeProcesses":1}\n', - ), /response.state/u); - assert.throws(() => parseWindowsJobObservation("not-json\n"), /malformed control output/u); -});