diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02086dc5c..69d93baf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,19 +34,14 @@ jobs: - name: Cheap preflight (non-Windows default) if: runner.os != 'Windows' timeout-minutes: 7 - run: npm run preflight -- --ci-qualification + run: npm run preflight -- --ci-qualification --static-only - name: Cheap preflight (Windows bounded) if: runner.os == 'Windows' timeout-minutes: 7 - run: npm.cmd run preflight -- --ci-qualification --bound-targeted-test-concurrency - - name: DevBridge identity audit - timeout-minutes: 1 - run: node --test test/product-identity.test.js - - name: Standalone installer regression - timeout-minutes: 1 - run: node --test test/standalone-launcher.test.js + run: npm.cmd run preflight -- --ci-qualification --static-only test: + needs: smoke timeout-minutes: 14 strategy: fail-fast: false @@ -63,9 +58,6 @@ jobs: with: node-version: 22.16.0 package-manager-cache: false - - name: Repository-execution architecture gates - timeout-minutes: 1 - run: node --test test/repository-execution.test.js test/repository-execution-boundary-absence.test.js test/deterministic-execution-boundary.test.js test/process-runner.test.js test/worker-exchange.test.js test/local-operation-manifest.test.js test/tool-onboarding.test.js - name: Tests (non-Windows default) if: runner.os != 'Windows' timeout-minutes: 6 diff --git a/docs/architecture.md b/docs/architecture.md index 90dc4925c..434849c64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -352,7 +352,7 @@ Future/qualified resource governance may include: - memory/vCPU limits; - storage growth/retention; - active profile/warm-pool policy; -- idle shutdown/suspend; +- opt-in idle shutdown/suspend (ready profiles stay running between jobs by default); - GPU/device exclusivity; - task/process quotas; - guest buffer/cache/history retention budgets. diff --git a/docs/execution-profile-environments.md b/docs/execution-profile-environments.md index 189721d94..010ae4629 100644 --- a/docs/execution-profile-environments.md +++ b/docs/execution-profile-environments.md @@ -139,12 +139,17 @@ Profiles that are merely possible but not needed are not created. Resource policy belongs primarily to execution profiles, not repositories. +Ready profile VMs remain running between jobs by default, as required by DB-020. +Workspace/session cleanup releases task resources without stopping the shared VM. +Startup preparation and readiness reuse follow DB-020's running-lifetime contract; +a new job does not imply a new boot or another full profile qualification. + DevBridge accounts for: - maximum concurrently running profile VMs; - profile memory and vCPU policy; - host available memory/storage; -- idle shutdown/suspend policy without discarding persistent profile/workspace state; +- opt-in idle shutdown/suspend policy without discarding persistent profile/workspace state; - GPU/device exclusivity where relevant; - per-task/process resource limits inside a running profile where supported. diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index 771ca49be..2ca1828fb 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -53,8 +53,23 @@ below to pass; partial success remains useful evidence rather than completion. | Guest | Required operational evidence | Current status | | --- | --- | --- | -| Linux | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Unproved; Ubuntu image `img-c91420f1765ac0c9f23f0267f1fcb825` / `ubuntu-2604-production-v14` is qualified; protected environment activation/recovery remains pending | -| Windows | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Unproved; Windows image `img-04524f455e9061343aa176837ab84fdd` / `windows-production-v6` is qualified; profile activation/setup/route readiness remains pending | +| Linux | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Passed September 8 on the unchanged accepted v14 image: #523 success, #524 compiler failure, #525 test failure. All registered operations were observed once, host source identity/cleanup validated, and normal GitHub results confirmed. Terminal-delivery restart recovery preserved #523's completed operations exactly. | +| Windows | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Unproved; the accepted v6 image remains unchanged. Fresh bootstrap qualification passed. The same production create operation resumed September 8 after sufficient host storage became available; operational cases remain outstanding. | + +September 8 native bootstrap qualification created disposable guests from both +accepted finalized images and verified the actual first-access seed path, contents, +bridge access and owned cleanup. The Linux qualification identity is +`883c3d67-a7d4-4e60-87a2-be59da68ca68`; the Windows identity is +`597b4a1b-12e6-4f50-b042-96f31f3adb29`. These qualify bootstrap on this Hyper-V +host. They do not establish compilation, task result delivery or KVM support. + +The operational milestone requires six workflow cases: success, compiler failure +and test failure on each guest route. A terminal-delivery interruption followed by +a fresh installed process must demonstrate that completed repository work is not +repeated. The Linux terminal-delivery interruption/restart preserved all completed +operation records and reconciled the same GitHub comment. Source transfer batching +has consumer/provider tests and was exercised by the installed runner for #524 and +#525; Windows workflow qualification remains outstanding. Guest OS and host provider are separate axes. Proving both guests on the current Windows/Hyper-V host satisfies these operational rows only. Linux-host @@ -64,6 +79,64 @@ Hyper-V evidence. Common code and changed contracts must retain both adapters. ## Failure behavior on the exercised path +### September 8 latency repair cycle + +Assess every task against the fact it establishes and the consumer needing that +fact. Existing valid evidence should remove work, not merely add another check. +The measured defects are full retransmission after small source changes, repeated +preparation, and one native inspection/connection setup per transfer frame. + +Repair source synchronization first: ask the guest which staged parts still match +the exact manifest, send only missing/corrupt parts, and retain full application +digest validation. Then move connection lifetime and readiness reuse into the +activity/provider owner, with current declaration/generation/policy checks and +disconnect/cancellation recovery. Keep the existing public v1 formats readable. +Microsoft documents persistent PowerShell Direct sessions; Node's child-process +pipes provide the bounded transport mechanism. These support connection reuse, +not cross-subject authority caching. Research: [PowerShell Direct](https://learn.microsoft.com/en-us/windows-server/virtualization/hyper-v/powershell-direct), +[Node child processes](https://nodejs.org/docs/latest-v22.x/api/child_process.html). + +Qualify each repair with focused normal/failure/recovery tests, then measure the +normal installed workflow on both running guests. Compare guest compute time, +transferred bytes, required exchanges and external API time separately from +startup. A reduced duration alone is insufficient: every remaining substantial +cost needs a necessary task or a concrete next repair. The previous nine-minute +Linux workflow is not an acceptable warm Hello World result. + +CI now runs static/artifact prerequisites first, followed by one full behavioral +suite per host platform. The static-only preflight explicitly reports zero tests; +the normal/candidate preflight default still runs its behavioral checks. Separate +identity, installer and architecture invocations were duplicate subsets of the +full suite and are removed from CI. This changes engineering verification cost, +not guest admission or runtime authority. + +September 8 transfer investigation: task #523's first `cmake.configure` attempt +spent over an hour preparing its workspace, before compiler execution. Read-only +guest observations at 21:02:36 and 21:03:00 UTC found 316 then 318 of 355 source +parts, no applied manifest, and no compiler process. The source snapshot contains +2,672,576 bytes and required 397 original 16 KiB transfer frames. A 53-second host +sample saw six successive activity workers and repeated global foundation/image +and selected-environment inspections. Direct verified SSH exchanges measured +244–296 ms; repeated control-plane work dominated the transfer delay. + +The correction packs source parts without changing accepted images, separates +activity composition from aggregate image health, and scopes physical lookup +before native observation. Transfer/preparation phases use the existing durable +liveness contract and are identified separately from registered tool execution. +Focused contract and consumer tests pass. The installed corrected runner completed +#524 and #525 in about nine minutes each, compared with about 74 minutes for the +earlier #523 workflow. These are different cases, not a controlled benchmark. +For #525, actual configure/build/test durations were 405/370/142 ms. The surrounding +preparation, transfer and control-plane work remains disproportionately expensive. + +At 21:48 UTC on September 8, Hyper-V reported the Linux guest +`9c0d8a6b-2aba-4c9d-be35-bc0b755293b0` running with about 44 hours of continuous +uptime. The three Linux jobs therefore did not pay for a guest boot. Repeated +startup preparation is a separate implementation gap, documented in Stage 5/6; +DB-020 now explicitly requires keeping ready guests running and reusing valid +owner-produced readiness between jobs. These Linux observations do not establish +Windows or KVM behavior. + Before calling the milestone complete, exercise a compile error and a failed test on each claimed guest route. The originating GitHub task must receive useful redacted error text, stage, known exit status and missing/truncated-evidence @@ -79,12 +152,99 @@ desktop access, SSH, a debug flag or a later log request a production prerequisi ## Work order and ownership -September 7 checkpoint: the supported Ubuntu and Windows construction and image -qualification paths have completed. Reuse both accepted images. The interrupted -Linux create has been reconciled and the service disconnect fix is installed. -The next Linux step is resuming its existing rebuild from the image in the -current declaration (HO196), followed by accepted profile activation and ordinary -setup verification. Neither guest has completed the GitHub Hello World task. +The warm-transport repair preserves public v1 activity requests and existing +guest journals. The protected Windows service reuses one bounded activity worker; +Hyper-V retains an authenticated connection bound to current authority and physical +generation. Committed identity reads replace repeated native attachment scans. +Focused provider, stream, state, routing and consumer tests pass, including a +compiled Windows host serving 100 sequential requests from one worker, replacing +it after cancellation and retaining it across read-only configuration access. Preflight's three fixed inventory-count +assertions were replaced with actual invocation/inventory checks; their focused +tests pass. Both native Hyper-V connection timings now pass; installed workflow +timings remain outstanding. This does not qualify native KVM or claim the latency milestone done. + +The next native qualification uses the current accepted, running Linux and Windows +guests to measure repeated exact bridge health exchanges, verify separate target +binding and connection-loss recovery, and inspect current Windows readiness through +its owner. This transport change neither modifies the accepted image nor changes +first-access enrollment; no image reconstruction is part of this qualification. +Activate the exact qualified component once, then measure the ordinary installed +source/compile/test/result path. Compare actual guest compute with necessary +source validation, transfer, authority and delivery cost, identifying remaining +disproportionate phases rather than accepting the earlier nine-minute runs. + +Native continuation: Linux exchanges passed at 116–131 ms after a 4562 ms initial +connection; closing and reopening the same target passed in 4216 ms. The direct +administrator helper could not resolve Windows credentials because those records +use the service account's DPAPI `CurrentUser` scope. That is a qualification-context +error, not evidence that the installed Windows access is broken. Microsoft defines +this scope as readable only under the protecting account's context: +[DataProtectionScope](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope). +Run Windows consumer qualification through the service's ordinary activity port +inside the existing exact-generation activation/health/rollback transaction. +Keep the previous service generation available if candidate health fails. +An isolated compiled-host fixture does not prove access to production credentials. + +Windows access material now reuses an already verified decrypted value within its +own bounded process cache, reading and validating the current protected record +on every lookup. Changed/deleted records and process restart invalidate reuse. +Focused tests include actual Windows DPAPI and record substitution/recovery; the +accepted guest credentials and encryption scope remain unchanged. This removes +repeated PowerShell decryption from warm frames without exporting credentials. + +The operator explicitly requested retaining reusable work contexts. Healthy +activity workers and provider connections therefore remain owned by the running +service instead of expiring on an idle timer or request count. Message/response +bounds, serialized effects, bounded caches, cancellation and shutdown cleanup +remain enforced. The current qualification controller likewise retains its +elevated task context; that tooling is outside the product and does not alter +DevBridge's service authorization model. + +The installed `83600db` candidate passed its normal service probes and both guest +consumer checks inside the existing activation/rollback transaction. Linux health +requests took 4368 ms initially and 112–130 ms warm; Windows took 6335 ms initially +and 88–114 ms warm. Windows resumed the same accepted lifecycle operation and +reported healthy with the same physical generation and accepted image. Native +evidence is retained in `activity-session-candidate-20260908d/result.json` under +the local review evidence directory. No new image construction was necessary. + +The next ordinary execution repair selects only the requested subject/profile +observation after startup inventory. Its contract tests prove a changed selected +route still rejects admission without repeating global listing. The existing +service and its native connection evidence remain applicable: this correction +changes the ordinary consumer's calls through unchanged v1 activity operations. + +The first Windows workflow (#526) reached CMake configure in about 51 seconds +after run creation. Configure itself took 3369 ms and reported MSBuild's 260-character +path limit: DevBridge's generated scratch prefix made the diagnostic file path +261 characters. Microsoft documents that long-path opt-in is application-specific: +[maximum path length](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation). +The execution owner now chooses a compact run scratch root while retaining existing +legacy roots for interrupted work. Native workflow qualification is still pending. + +The same run exposed an interrupted host-state replacement (`EPERM`). The prior +JSON store kept uncommitted mutations in memory and left its write chain rejected, +so subsequent failure and cleanup saves could not proceed. It now uses the existing +record-file owner, commits memory after observed replacement, and keeps later writes +usable after failure. Replacement observes ambiguous success, rejects intervening +changes, and bounds Windows sharing retries. Twenty focused tests pass, including +a real Windows file handle that temporarily denies delete sharing. + +Controller continuation now retains observed operations bound to the accepted plan +instead of executing them again. Legacy v1 work uses its existing accepted receipt; +conflicting plan identity or invalid retained result evidence rejects reuse. Sixteen +focused controller tests pass, including restart after a saved first result. + +Operator follow-up after the current test/fix pass: discuss the firm requirement +that unattended DevBridge operation must never wait for permission prompts. Keep +this product discussion separate from completing the current workflow repairs. + +September 8 checkpoint: the supported Ubuntu and Windows construction and image +qualification paths have completed. Reuse both accepted images. Linux recovery +and all three workflow cases have completed, including terminal-delivery restart +recovery. Continue the accepted Windows operation and then exercise its three +normal workflow cases. Warm-guest preparation overhead remains an identified +ownership repair; it does not justify another image build or Linux replacement. The responsibilities below remain acceptance scope, not instructions to repeat completed image or package-basis qualification. diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md new file mode 100644 index 000000000..8812964e4 --- /dev/null +++ b/docs/refactoring-plan.md @@ -0,0 +1,144 @@ +# Structural refactoring + +The owner approved this program on 8 September 2026, following the structural +review of c9e83fe. Apply LEGO ownership recursively: each owner encapsulates its +decisions, state, effects, failure, and recovery; parents compose narrow child +contracts. The completion criterion is local repair and qualification, not file +geometry. Existing security, provenance, generation, and fencing contracts remain +normative. + +## Sequence + +1. **Native delivery:** one Hyper-V file-copy implementation, exact destination + and content semantics, structured failures, and fresh-consumer qualification. +2. **Recovery:** explicit immutable operation subjects, crash-releasing mutation + leases, readable committed status, continued accepted serial preparation, and + independent readiness of selected profiles. +3. **Hello World:** GitHub-initiated compile/run/test success, compiler failure, + and test failure in both Linux and Windows guests, with automatic results and + restart/delivery recovery. Existing accepted images and identities are reused. +4. **Operational ownership:** independent installation, accepted image supply, + lifecycle recovery, readiness decisions, and supported diagnostics; setup + coordinates these owners. +5. **Component deployment:** generated dependency closure and owned assets, + versioned component manifests, isolated candidate qualification, and compatible + activation/rollback. Use pinned esbuild 0.28.2 for build-time graph information; + preserve source layout and keep the installed verifier independent of it. +6. **Nested state and tasks:** bounded ledger transactions, provider-owned native + semantics, opaque task/repository subjects, and run-owner persistence queries. + Preserve existing GitHub durable identities through compatibility translation. +7. **Verification and documentation:** behavioral boundary tests, dependency-aware + test selection, deduplicated CI, and explicit separation of specification, + implementation status, and native evidence. + +Real GPU support for CUDA-JS follows this program; MCP follows GPU support. + +## Migration and acceptance + +Preserve durable v1 formats when only ownership changes. Never run two native +mutation implementations concurrently for comparison. A changed declaration or +lost lease prevents further effects; ambiguous effects are observed before retry. +Preserve old generations, backing identity, accepted images, and useful evidence. +Legacy token locks can be migrated only after the previous authority is quiescent +and the replacement exclusive lease is held, never on age alone. + +Qualify fresh first-access bootstrap separately from image construction and disk +acceptance. Test crashes before and after effects and state commits, stale +declarations, partial profile readiness, diagnostics before normal guest access, +artifact stability under unrelated changes, source substitution, and rollback. +Hyper-V evidence does not qualify KVM/libvirt. Keep native limitations explicit. + +Use focused owner/consumer tests first, native qualification for relevant changes, +and full Windows/Linux regression before merging each coherent block. Track +implementation and evidence in ordinary commits and existing issues. Do not add a +parallel accounting system. No Python. + +## Current evidence + +Fresh Hyper-V consumer qualification now passes for both Linux and Windows using +the existing accepted images and the exact installed-source candidate at +`4aa0da1152d9a9f9350bb821bd2638b960254dab`. Each disposable guest received its +network seed through production delivery, established bridge access, completed +bootstrap, and returned the exact seed bytes from the requested path. Both owned +fixtures were removed successfully. Qualification identities are +`883c3d67-a7d4-4e60-87a2-be59da68ca68` (Linux) and +`597b4a1b-12e6-4f50-b042-96f31f3adb29` (Windows). The measured package digest is +`5ffc8961a67ec68d19e6bdc18ee0a380f1c3def9ca577154e8b132eb0987a4b1`. +This qualifies first access on Hyper-V; the six Hello World workflows and native +KVM/libvirt qualification remain outstanding. + +Windows and Ubuntu smoke/full CI passed at `2775572`. The subsequent `4aa0da1` +matrix exposed a Windows integration-fixture cleanup race after child-process +termination; bounded cleanup now waits for executable handles to close. Focused +compiled-host and delivery-recovery checks pass. The existing local fault +injection facility now also supports interruption before status delivery and +after its external effect, retaining durable intent for restart qualification. + +### Earlier implementation and diagnostic evidence + +Full Windows and Linux smoke/regression CI passed at `93909ad`. Production Linux +continuation then completed bootstrap in its existing generation and checkpointed +preparation, exposing a workspace composition defect. The parent now passes the +exact physical generation to the real preparation contract and resolves provider +identity from protected foundation state. Consumer tests cover both guest families. +The lifecycle owner also retains bounded failures in its existing state and serves +them through the optional `diagnostics-v1` read operation. These changes still +require installed production continuation; neither is a Hello World result. + +Normal setup also re-entered Ubuntu construction metadata downloads despite +already accepted, locally verified images. That redundant read was stopped and +accepted preparation continued through the installed lifecycle owner. Removing +this setup dependency remains required operational-ownership work. + +The first implementation block consolidates native Hyper-V delivery, passes +explicit lifecycle operation subjects into construction, replaces the persistent +environment mutation token with process-held leases, and leaves committed status +readable during mutation. Installation owns legacy-token retirement after +quiescence. Native effect invocations receive lease-loss cancellation. Accepted +setup activation continues through ready changes and assesses independent +profiles; the elevated workflow uses the installed lifecycle authority. + +Focused contract tests and the broad local Windows suite pass on Node 24.15.0: +2,638 passed, 46 skipped. The Windows executable lease has actual process-death +qualification, including Windows 8.3 path spelling and requester/holder death. +Ubuntu smoke and full regression pass on Node 22.16.0, including Linux `flock` +process qualification. Windows CI exposed short-path handling defects; their +focused reproduction passes and the full matrix must pass before merge. These +results do not establish native VM readiness. + +A repeatable disposable-guest fixture now consumes the accepted finalized image, +uses production first access, and checks exact network-seed bytes through the +resulting bridge. Its contract tests reject successful delivery to the wrong path. +The first native Linux consumer received successful native copy results but failed +SSH first access. Its owned disposable guest is retained, and the previous service +is restored after each bounded inspection. The existing provider console now +supports bounded 640×480 and 1024×768 captures as well as 320×240; native capture +shows the guest waiting for network readiness during boot. The same retained +guest passed strict SSH at 129 seconds with its existing delivered seed and keys; +the earlier 90-second first-access allowance was insufficient. Access preparation +now allows at most three minutes, clips native probes and delivery to its remaining +deadline, and accepts enclosing cancellation/deadline constraints. This is +diagnostic evidence, not a successful bootstrap qualification. A subsequent fresh +guest passed SSH and bridge access, then exposed a DNS translation defect: Node +reported `127.0.0.1` and bootstrap supplied it to the guest. The provider now uses +DNS configured on an active Windows route, excludes host-local addresses, and +fails explicitly when no usable local policy exists. Native observation selects +the host's configured router. Full local Windows regression before this DNS repair +passed 2,643 tests with 46 skips; its focused tests pass. Windows first access and +both Hello World routes remain outstanding. + +The next fresh Linux attempt reached healthy network and bridge access, then +revealed that its unprivileged bootstrap helper shared the network agent's +privileged state directory. Host composition now selects a bootstrap subdirectory +inside the bridge-owned guest cache through the agent's existing configuration +contract. Windows preparation reached its storage preflight and stopped before +VM creation because free space was just below the configured reserve. Diagnosed +disposable Linux fixtures are retired through the environment owner to recover +space; their native evidence and the accepted images remain retained. + +Remaining recovery and separation work includes bootstrap allocation's legacy +token, lifecycle-owned retirement authorization, consolidated capability status, +image-supply handoff, component-specific packaging, and bounded child transactions. +Bootstrap allocation's lease must be supplied by both its construction and service +parents; it must not depend on the caller's Node installation layout. Continue the +approved sequence above rather than treating this first block as the full refactor. diff --git a/docs/vm-stage4-environment-bridge.md b/docs/vm-stage4-environment-bridge.md index 936930cbd..b2b45cd75 100644 --- a/docs/vm-stage4-environment-bridge.md +++ b/docs/vm-stage4-environment-bridge.md @@ -92,7 +92,12 @@ The provider attachment selects every host management executable, provider argum ### Asynchronous execution and durable observation -The bridge does not hold one provider session open for the lifetime of a build or test. +The bridge may reuse a bounded provider connection across requests. Guest work +must not depend on that connection staying open: connection loss discards the +transport, and recovery observes the same durable operation before any repeat. +The provider owns connection authentication, exact physical target binding, +idle lifetime and failure. Current route, declaration and generation authority +are checked by their owners before dispatch; connection reuse cannot grant them. `execute` starts one exact request and returns a state such as `planned`/`running`. The guest helper durably journals the request before launching a detached local monitor process. The host then polls `observe` using the same request identity. diff --git a/docs/vm-stage5-guest-bootstrap.md b/docs/vm-stage5-guest-bootstrap.md index 78fe39001..40a53fa12 100644 --- a/docs/vm-stage5-guest-bootstrap.md +++ b/docs/vm-stage5-guest-bootstrap.md @@ -33,6 +33,17 @@ The guest records the generation only after all required observations are ready. Reset/reseed already replaces the Stage-3 writable layer and changes the exact environment identity/generation. Stage 5 binds its record to that exact basis; a replacement environment starts with no valid Stage-5 generation until it is prepared and observed again. +DB-020 now explicitly requires a running guest to be reused between jobs and +expensive preparation to follow startup, material change, or failure recovery. +`ensure` first makes a bounded observation of the exact generation. A ready +observation skips provider preparation and guest `apply`. An unavailable or +degraded observation enters preparation once and rechecks basis/policy before +application. A malformed or incorrectly bound response fails validation instead +of authorizing preparation. This fast path has focused contract coverage; native +performance qualification remains outstanding. A durable generation record alone +does not prove that a guest has not rebooted or that a capability observation is +still usable. + ## Baseline development capability contract The default Stage-5 plan requests neutral capability identities rather than concrete upstream/downstream names: diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index c447be655..98514643b 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -24,6 +24,54 @@ There is no direct-host or legacy sandbox fallback. Missing routes, an unavailab Connections are transient. A session is opened for one exact source/operation/candidate exchange and owns the selected workspace target for that session. It does not imply ownership of the entire shared physical profile VM. No consumer retains a provider, transport, guest path, or physical environment object. +Composing a protected activity request reads the foundation identity independently +of aggregate installation/image health. Explicit status still reports that health. +Selecting a physical route queries the committed subject/profile selection before +native observation; unrelated profiles are not part of a selected data transfer. +The activity owner reuses an observed attachment while committed generation and +declaration records remain unchanged. Each request reloads its admitted route and +reads a consistent committed identity snapshot. The snapshot is an identity view; +it does not infer current admission from historical operation rows. Lifecycle +mutations change its revision, invalidating reused observations. Opening a Hyper-V connection reobserves native +ownership and storage compatibility; the adapter authenticates and binds that +connection to its exact physical guest. Connection loss, changed binding, +cancellation and protected authority changes discard reusable resources. +No cached observation substitutes for current task, lease/fence or result checks. + +Repository execution enumerates the environment inventory at composition. Each +operation then requests only its selected subject/profile observation through the +existing activity observation contract. Unrelated guest readiness is not rescanned +for that operation; a changed or unavailable selected route still rejects admission. + +The execution owner uses a bounded, digest-derived run scratch name beneath the +already isolated workspace. It observes the legacy and compact locations through +the guest resource contract: existing legacy work keeps its location, new work +uses the compact location, and conflicting or symlinked layouts are rejected. +Logical run IDs, source identity and cleanup evidence retain their v1 meanings. +This leaves space for native build-tool paths without requiring every tool to +support extended Windows paths. + +The Windows service retains one serialized activity worker for its own lifetime; +it does not recycle a healthy worker or connection because a request count or idle +timer elapsed. Message sizes, in-flight work and cached identities remain bounded. +It releases the service gate between +requests. Its existing v1 pipe, response acknowledgement, byte bounds and client +cancellation remain unchanged. Read-only lifecycle inspection can proceed between +frames; privileged lifecycle mutation invalidates retained activity resources. +Read-only configuration inspection preserves them; configuration changes are +detected through the current route/declaration/record bindings before dispatch. +The service job closes worker descendants on cancellation and shutdown. Hyper-V +uses a reusable authenticated SSH or PowerShell Direct connection; the existing +installed guest agent still journals each detached operation independently. +Libvirt retains its QGA transport. Hyper-V tests do not qualify native KVM behavior. + +Current preparation gap: each registered operation and scratch-cleanup session +calls activity preparation and health again. Bootstrap `ensure` now skips provider +preparation for a ready exact generation. Repeated readiness probes remain to be +measured after transport reuse is natively qualified. Normal session close does +not stop the VM. A passing transport contract alone does not establish an +efficient warm workflow; both installed guest routes must demonstrate it. + ## Local route policy Routes are stored under the control-owned state directory at: @@ -62,6 +110,26 @@ The guest receives only this admitted snapshot through input-transfer capabiliti `git reset --hard ` plus `git clean -fd` removes prior proposal state before each operation while deliberately preserving ignored dependency/build caches within that repository workspace. The host snapshots again after synchronization and rejects host source drift. +The workspace transport groups small source parts into bounded compressed +`devbridge/source-part-pack-v1` artifacts. The delivered workspace helper checks +the pack digest, decompression bound, member names and each member's bytes before +staging them. The original v1 file-tree manifest still governs source application; +large parts retain the existing streaming path. This changes neither the public +bridge frames nor image identity. Source transfer reports bounded progress and +checks cancellation before subsequent effects. Native qualification status is +tracked separately from this transport contract. + +For a changed manifest, the delivered source helper observes which staged parts +still match their expected size and digest. The host accepts only a bounded part +selection bound to its exact manifest bytes and sends missing/corrupt parts. +Guest source application still validates every part and complete file; a stale or +forged omission cannot turn altered bytes into an accepted source tree. Unchanged +staged data therefore avoids retransmission without becoming trusted authority. + +Workspace preparation and source transfer project their phases through the existing +run liveness channel. They must not imply that the registered compiler or test has +started. Progress checkpoint failures are awaited before subsequent transfer effects. + ## Candidate return and host authority After an observed non-timeout/non-abort completion, the guest compares its working state to the saved local baseline and emits a bounded delta. Guest commits do not change the comparison basis. diff --git a/specs/DB-009-effects-recovery.md b/specs/DB-009-effects-recovery.md index fdb07a9c8..9beb23663 100644 --- a/specs/DB-009-effects-recovery.md +++ b/specs/DB-009-effects-recovery.md @@ -123,6 +123,27 @@ gaps identified above remain open until demonstrated through the production path ## Run recovery +### Local environment failure inspection + +The environment lifecycle owner retains bounded failure evidence alongside its +existing state, bound to environment identity, declaration revision, operation +identity, stage, and observed generation. Diagnostic inspection must not repeat +the operation or require guest access. Native exit, cancellation, timeout, +classification, and truncation facts are retained when supplied by the effect +owner; missing native or guest evidence is explicit. Text is sanitized before +retention and remains bounded independently of output size. + +The optional `diagnostics-v1` read operation exposes this record through the +existing lifecycle transport. Existing v1 operation payloads and result envelopes +remain unchanged; unsupported diagnostic capability is rejected explicitly. +Consumers distinguish retained historical failure from a failure associated with +the current active operation. The diagnostic record does not grant authority to +resume work, change declarations, or select a provider effect. + +The installed CLI exposes this capability as `environment diagnostics`. This +implementation supplies owner/native failure evidence; it does not claim guest +console or installer evidence that the diagnostic owner has not collected. + On startup/daemon continuation the coordinator examines durable non-terminal runs before claiming or advancing work as appropriate. Recovery behavior is stage-aware: diff --git a/specs/DB-020-vm-execution-boundary.md b/specs/DB-020-vm-execution-boundary.md index 7b30586d6..db76480e6 100644 --- a/specs/DB-020-vm-execution-boundary.md +++ b/specs/DB-020-vm-execution-boundary.md @@ -161,6 +161,33 @@ A provider/image/profile compatibility change cannot silently reuse incompatible Adding/removing a repository must not recreate a compatible profile VM. Stopping/restarting the profile VM preserves its persistent disk and intended repository workspace state. Reset/reseed of the entire profile is an explicit profile-level action; repository-workspace reset is a separate narrower operation when supported. +### Running lifetime and preparation + +A ready profile VM stays running between jobs by default. Completing, failing, or +cancelling a repository operation, closing a workspace session, or restarting the +ordinary application does not request a VM stop, suspend, reset, or fresh boot. +An explicit operator lifecycle action or an explicitly enabled profile resource +policy may change that lifetime. Idle shutdown/suspend is opt-in, not job cleanup. + +Installation verification, image admission, provider attachment preparation, and +guest bootstrap belong to their respective owners. Perform expensive preparation +at the relevant component or guest startup, after a material identity/configuration +change, or when an observed failure requires reconciliation. A job or transfer +request is not itself a reason to repeat that work. Reuse valid owner-produced +readiness for the same declaration, environment generation, boot, and capability +requirements; invalidate it when its owning facts change. Guest observations +remain untrusted and never grant host authority. + +Job admission still checks current task/repository/revision authority, the exact +selected route, required capabilities, and exclusive workspace ownership. Effects +still check cancellation, lease/fence validity, relevant generation/policy changes, +and request/result identity. Source changes and returned results still require +their own validation; host Git and publication retain their existing checks. +These bounded checks must not reenter installation, scan unrelated profiles, +reacquire accepted images, or rerun bootstrap preparation for an unchanged ready +guest. A stale or unavailable assessment is reconciled by its owner, not by +duplicating its health algorithm in each consumer. + DB-009 observe/reconcile-before-repeat semantics apply to ambiguous VM lifecycle and bridge effects. ## Base images and writable layers @@ -279,7 +306,7 @@ Resource governance includes, where supported: - profile memory/vCPU policy; - host available memory/storage reserve; - active profile/warm-pool limits; -- idle shutdown/suspend without losing persistent profile/workspace state; +- explicitly enabled idle shutdown/suspend without losing persistent profile/workspace state (default: keep running); - disk growth/retention; - operation timeout/cancel; - GPU/device exclusivity; diff --git a/src/app/environment-activity-host.js b/src/app/environment-activity-host.js index da0cdac26..0f51be117 100644 --- a/src/app/environment-activity-host.js +++ b/src/app/environment-activity-host.js @@ -63,7 +63,9 @@ export async function createProtectedEnvironmentActivity({ const requestFor = async (rawTarget) => { const target = exactEnvironment(rawTarget); - const observed = await selectedState.observeEnvironment(target); + const observed = typeof selectedState.readEnvironmentRecords === 'function' + ? { record: (await selectedState.readEnvironmentRecords({ identity: target })).records.find(record => record.identity === target) } + : await selectedState.observeEnvironment(target); if (observed?.record?.identity !== target || typeof observed?.record?.profile !== 'string') { throw new Error('protected activity target observation is invalid'); } @@ -78,13 +80,18 @@ export async function createProtectedEnvironmentActivity({ }); }; - const identity = await selectedState.inspect(); - if (typeof identity?.identity !== 'string') throw new Error('protected activity foundation identity is unavailable'); const selectedExchange = bridgeExchange ?? await createEnvironmentBridgeExchange({ stateDirectory: authority, - foundationIdentity: identity.identity, platform, invoke, + reuseConnections: true, + beforeConnect: async target => { + const current = await selectedState.observeEnvironment(target); + if (current.record?.identity !== target || current.observation?.exists !== true + || current.observation?.owned !== true || current.observation?.compatible !== true) { + throw new Error('protected activity connection requires an owned compatible current environment'); + } + }, access: async (target) => selectedPreparation.connection(await requestFor(target), target), }); @@ -101,5 +108,6 @@ export async function createProtectedEnvironmentActivity({ }, }), exchange: selectedExchange, + authorityBinding: async target => requestFor(target), }); } diff --git a/src/app/environment-activity-runtime.js b/src/app/environment-activity-runtime.js index 0e6a5df5b..7ebb812ca 100644 --- a/src/app/environment-activity-runtime.js +++ b/src/app/environment-activity-runtime.js @@ -26,19 +26,30 @@ function assertExchange(value) { return value; } -export function createEnvironmentActivityRuntime({ state, loadPolicy, preparation, exchange } = {}) { +export function createEnvironmentActivityRuntime({ state, loadPolicy, preparation, exchange, authorityBinding = async () => null } = {}) { const selectedState = assertState(state); const selectedPolicy = assertPolicyLoader(loadPolicy); const selectedPreparation = assertPreparation(preparation); const selectedExchange = assertExchange(exchange); + if (typeof authorityBinding !== 'function') throw new TypeError('environment activity authority binding is invalid'); + const attachments = new Map(); const routing = async () => createExecutionProfileRouting({ state: selectedState, policy: await selectedPolicy() }); const attachment = async (logicalTarget) => { const current = await routing(); - return Object.freeze({ - target: await current.physicalTarget(logicalTarget), - prefix: `workspaces/${current.workspaceIdentity(logicalTarget)}`, - }); + const committed = await current.attachmentBinding(logicalTarget); + const authority = committed == null ? null : await authorityBinding(committed.record.identity); + const key = committed == null ? null : JSON.stringify([committed, authority]); + const previous = attachments.get(logicalTarget); + if (key != null && previous?.key === key) return previous.value; + const target = await current.physicalTarget(logicalTarget); + if (committed != null && target !== committed.record.identity) throw new Error('environment activity attachment changed during observation'); + const value = Object.freeze({ target, prefix: `workspaces/${current.workspaceIdentity(logicalTarget)}`, binding: key }); + if (key != null) { + if (attachments.size >= 32) attachments.delete(attachments.keys().next().value); + attachments.set(logicalTarget, { key, value }); + } + return value; }; return Object.freeze({ @@ -62,8 +73,14 @@ export function createEnvironmentActivityRuntime({ state, loadPolicy, preparatio const logical = normalizeEnvironmentBridgeRequest(rawFrame); const selected = await attachment(logical.target); const attached = rebindEnvironmentBridgeRequest(logical, selected); - const result = await selectedExchange(attached, options); - return rebindEnvironmentBridgeResponse(result, { from: attached, to: logical }); + try { + const result = await selectedExchange(attached, { ...options, binding: selected.binding }); + return rebindEnvironmentBridgeResponse(result, { from: attached, to: logical }); + } catch (error) { + attachments.delete(logical.target); + throw error; + } }, + close() { attachments.clear(); selectedExchange.close?.(); }, }); } diff --git a/src/app/environment-bootstrap.js b/src/app/environment-bootstrap.js index f156d88b0..245802fe8 100644 --- a/src/app/environment-bootstrap.js +++ b/src/app/environment-bootstrap.js @@ -151,7 +151,7 @@ export async function createEnvironmentBootstrap({ }; }; - const exchange = async (target, frame) => { + const exchange = async (target, frame, { signal = null } = {}) => { const selected = await resolvedAccess(target); const program = selected.family === 'windows' ? 'node.exe' : 'node'; const helper = selected.family === 'windows' @@ -160,12 +160,16 @@ export async function createEnvironmentBootstrap({ const outcome = await bridge.execute(target, { program, arguments: [helper, '--exchange-stdin'], - directory: { class: 'scratch', path: '.' }, - environment: { DEVBRIDGE_GUEST_TARGET: target }, + directory: { class: selected.family === 'linux' ? 'cache' : 'scratch', path: '.' }, + // The bridge owns the guest user's cache location. Readiness state must + // not share the privileged network agent's seed/state directory. + environment: { DEVBRIDGE_GUEST_TARGET: target, + ...(selected.family === 'linux' ? { DEVBRIDGE_BOOTSTRAP_ROOT: 'environment-bootstrap' } : {}), + }, input: JSON.stringify(frame), timeoutMs: 30_000, maxOutputBytes: 256 * 1024, - }, { pollIntervalMs: 500 }); + }, { pollIntervalMs: 500, signal }); return parseBootstrapOutput(outcome); }; diff --git a/src/app/environment-bridge.js b/src/app/environment-bridge.js index 84ba1df29..de74b2c46 100644 --- a/src/app/environment-bridge.js +++ b/src/app/environment-bridge.js @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { invokeCommand } from '../runtime/command-invocation.js'; +import { openJsonCommandChannel } from '../runtime/command-channel.js'; import { EnvironmentBridge } from '../runtime/environment-bridge.js'; import { loadOrCreateLocalIdentity } from '../runtime/local-identity.js'; import { HyperVEnvironmentBridge } from '../runtime/providers/hyperv-environment-bridge.js'; @@ -45,6 +46,9 @@ export async function createEnvironmentBridgeExchange({ platform = process.platform, invoke = invokeCommand, access, + reuseConnections = false, + openChannel = openJsonCommandChannel, + beforeConnect, } = {}) { if (typeof stateDirectory !== 'string' || stateDirectory.length === 0) throw new TypeError('stateDirectory is required'); if (typeof access !== 'function') throw new TypeError('bridge access must be a function'); @@ -53,10 +57,12 @@ export async function createEnvironmentBridgeExchange({ : foundationIdentity(injectedFoundationIdentity); const locate = currentLocation(identity, platform); let attachment; - if (platform === 'win32') attachment = new HyperVEnvironmentBridge({ invoke, access, locate }); + if (platform === 'win32') attachment = new HyperVEnvironmentBridge({ invoke, access, locate, beforeConnect, openChannel: reuseConnections ? openChannel : null }); else if (platform === 'linux') attachment = new LibvirtEnvironmentBridge({ invoke, access, locate }); else throw new Error('no environment bridge attachment is available for this host platform'); - return attachment.exchange.bind(attachment); + const exchange = attachment.exchange.bind(attachment); + exchange.close = () => attachment.close?.(); + return exchange; } export async function createEnvironmentBridge(options = {}) { diff --git a/src/app/environment-construction-observation.js b/src/app/environment-construction-observation.js index 5feca6e3c..3d401a1dd 100644 --- a/src/app/environment-construction-observation.js +++ b/src/app/environment-construction-observation.js @@ -23,13 +23,13 @@ export function createEnvironmentConstructionObservation({ materialization, prep const preparationPort = assertPort(preparation, 'inspect', 'preparation observation'); const workspacePort = assertPort(workspaces, 'inspect', 'workspace observation'); - const observe = async (request) => { + const assess = async (request) => { const base = normalizeEnvironmentObservation(await materializationPort.observe(request)); if (base.materialization !== 'present' || base.systemStorage !== 'present' || base.attachment !== 'ready' || base.implementationGeneration == null) { - return base; + return { observation: base, reason: null }; } const selected = requestWithGeneration(request, base.implementationGeneration); @@ -39,21 +39,26 @@ export function createEnvironmentConstructionObservation({ materialization, prep const bootstrap = ['ready', 'degraded'].includes(prepared?.bootstrap) ? prepared.bootstrap : 'unknown'; const guest = prepared?.ready === true && workspace?.ready === true ? 'healthy' : 'degraded'; - return normalizeEnvironmentObservation({ + const observation = normalizeEnvironmentObservation({ ...base, enrollment, bootstrap, guest, }); + const blocker = prepared?.ready !== true ? prepared : workspace?.ready !== true ? workspace : null; + const reason = typeof blocker?.reason === 'string' + ? blocker.reason.replace(/[\u0000-\u001f\u007f]/gu, ' ').slice(0, 1024) + : null; + return { observation, reason }; }; return Object.freeze({ - observe, + observe: async (request) => (await assess(request)).observation, readiness: Object.freeze({ async verify(request) { - const observation = await observe(request); + const { observation, reason } = await assess(request); const condition = environmentObservationCondition(observation); - if (condition !== 'healthy') throw new Error(`environment construction readiness is not healthy: ${condition}`); + if (condition !== 'healthy') throw new Error(`environment construction readiness is not healthy: ${condition}${reason ? `; ${reason}` : ''}`); return Object.freeze({ ready: true, implementationGeneration: observation.implementationGeneration, diff --git a/src/app/environment-construction-preparation.js b/src/app/environment-construction-preparation.js index c00a8b2b4..0bac42170 100644 --- a/src/app/environment-construction-preparation.js +++ b/src/app/environment-construction-preparation.js @@ -59,6 +59,7 @@ export async function createLocalEnvironmentAccess({ return Object.freeze({ connection: async () => Object.freeze({ family: 'linux' }), prepare: null, + discard: async () => {}, }); } if (family === 'linux' && platform === 'win32') { @@ -71,6 +72,7 @@ export async function createLocalEnvironmentAccess({ return Object.freeze({ connection: async (target) => preparation.connection(target), prepare: (request) => preparation.ensure(request), + discard: (target) => material.discard(target), }); } if (family === 'windows' && platform === 'win32') { @@ -118,6 +120,7 @@ export function createEnvironmentConstructionPreparation({ revision: declaration.bootstrap.generation, }); if (!bootstrap || typeof bootstrap.ensure !== 'function' || typeof bootstrap.inspect !== 'function' || typeof bootstrap.connection !== 'function') throw new TypeError('environment bootstrap composition contract is incomplete'); + if (values.size >= 64) values.delete(values.keys().next().value); values.set(key, Object.freeze({ target, access, bootstrap })); } return { request, declaration, selected: values.get(key) }; diff --git a/src/app/environment-construction-runtime.js b/src/app/environment-construction-runtime.js index 8891e9e3b..a036d43b2 100644 --- a/src/app/environment-construction-runtime.js +++ b/src/app/environment-construction-runtime.js @@ -40,6 +40,9 @@ export async function createEnvironmentConstructionRuntime({ resetAuthorization = null, recreateAuthorization = null, now, +} = {}, { + preparationFactory = createEnvironmentConstructionPreparation, + bridgeFactory = createEnvironmentBridge, } = {}) { if (typeof stateDirectory !== 'string' || stateDirectory.length === 0) throw new TypeError('environment construction runtime stateDirectory is required'); if (authorityDirectory != null && (typeof authorityDirectory !== 'string' || authorityDirectory.length === 0)) { @@ -47,6 +50,7 @@ export async function createEnvironmentConstructionRuntime({ } if (typeof resolveAuthority !== 'function') throw new TypeError('environment construction runtime authority resolver is required'); if (typeof invoke !== 'function') throw new TypeError('environment construction runtime invocation contract is invalid'); + if (typeof preparationFactory !== 'function' || typeof bridgeFactory !== 'function') throw new TypeError('environment construction runtime preparation composition is invalid'); const authorityStateDirectory = authorityDirectory ?? stateDirectory; const localFoundation = foundation ?? await createEnvironmentFoundation({ stateDirectory: authorityStateDirectory, platform, invoke }); @@ -56,14 +60,14 @@ export async function createEnvironmentConstructionRuntime({ const localLifecycle = lifecycle ?? createEnvironmentLifecycle({ stateDirectory: authorityStateDirectory, ...(now ? { now } : {}) }); const policy = createEnvironmentMaterializationPolicy(); const materialization = createEnvironmentMaterialization({ state: localFoundation, subject: policy.subject, settings: policy.settings }); - const rebuildMaterialization = createEnvironmentRebuildMaterialization({ state: localFoundation, subject: policy.subject, journal: localLifecycle.journal }); + const rebuildMaterialization = createEnvironmentRebuildMaterialization({ state: localFoundation, subject: policy.subject }); const resetAvailable = typeof localFoundation.replaceEnvironment === 'function' && typeof localFoundation.retireSupersededEnvironment === 'function'; - const resetMaterialization = resetAvailable ? createEnvironmentResetMaterialization({ state: localFoundation, subject: policy.subject, journal: localLifecycle.journal }) : null; + const resetMaterialization = resetAvailable ? createEnvironmentResetMaterialization({ state: localFoundation, subject: policy.subject }) : null; const resetRetirement = resetAvailable ? createEnvironmentResetRetirement({ state: localFoundation, journal: localLifecycle.journal }) : null; const recreateAvailable = typeof localFoundation.recreateEnvironment === 'function' && typeof localFoundation.retireSupersededEnvironment === 'function'; - const recreateMaterialization = recreateAvailable ? createEnvironmentRecreateMaterialization({ state: localFoundation, subject: policy.subject, journal: localLifecycle.journal }) : null; + const recreateMaterialization = recreateAvailable ? createEnvironmentRecreateMaterialization({ state: localFoundation, subject: policy.subject }) : null; const recreateRetirement = recreateAvailable ? createEnvironmentRecreateRetirement({ state: localFoundation, journal: localLifecycle.journal }) : null; - const preparation = createEnvironmentConstructionPreparation({ + const preparation = preparationFactory({ stateDirectory, authorityDirectory: authorityStateDirectory, platform, @@ -74,11 +78,11 @@ export async function createEnvironmentConstructionRuntime({ state: localFoundation, routeState: localRouteState, resolveAuthority, - resolveChannel: async ({ declaration }) => createEnvironmentBridge({ - stateDirectory, + resolveChannel: async ({ declaration }) => bridgeFactory({ + stateDirectory: authorityStateDirectory, platform, invoke, - access: (target) => preparation.connection({ declaration }, target), + access: (target) => preparation.connection({ declaration, implementationGeneration: target }, target), }), }); const observation = createEnvironmentConstructionObservation({ materialization, preparation, workspaces }); diff --git a/src/app/environment-foundation.js b/src/app/environment-foundation.js index 4bf6bafe5..08dfd26c5 100644 --- a/src/app/environment-foundation.js +++ b/src/app/environment-foundation.js @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; +import { createLocalFileLease } from './file-lease.js'; import { BaseImageLibrary } from '../runtime/base-image-library.js'; import { invokeCommand } from '../runtime/command-invocation.js'; import { EnvironmentFoundation, UnavailableEnvironmentControl } from '../runtime/environment-foundation.js'; @@ -59,6 +60,7 @@ export async function createEnvironmentFoundation({ stateDirectory, platform = process.platform, invoke = invokeCommand, + leaseFactory = createLocalFileLease, } = {}) { if (typeof stateDirectory !== 'string' || stateDirectory.length === 0) throw new TypeError('stateDirectory is required'); const root = path.join(path.resolve(stateDirectory), 'environment-foundation'); @@ -109,6 +111,7 @@ export async function createEnvironmentFoundation({ const lifecycle = new PersistentEnvironments({ directory: path.join(persistentRoot, 'registry'), + lease: leaseFactory({ subjectPath: path.join(persistentRoot, 'registry', 'lifecycle.lease'), platform }), source, operations, }); diff --git a/src/app/environment-lifecycle.js b/src/app/environment-lifecycle.js index 6060b7749..e4cfc66df 100644 --- a/src/app/environment-lifecycle.js +++ b/src/app/environment-lifecycle.js @@ -1,6 +1,7 @@ import path from 'node:path'; import { EnvironmentDeclarationRegistry } from '../runtime/environment-declaration.js'; import { EnvironmentLifecycleJournal } from '../runtime/environment-lifecycle-journal.js'; +import { createEnvironmentLifecycleDiagnostics } from '../runtime/environment-lifecycle-diagnostics.js'; import { createEnvironmentLifecycleStateStore } from '../state/environment-lifecycle-state-store.js'; export function createEnvironmentLifecycle({ stateDirectory, now, operationId } = {}) { @@ -9,5 +10,6 @@ export function createEnvironmentLifecycle({ stateDirectory, now, operationId } return Object.freeze({ declarations: new EnvironmentDeclarationRegistry({ port: port.declarations, ...(now ? { now } : {}) }), journal: new EnvironmentLifecycleJournal({ port: port.journal, ...(now ? { now } : {}), ...(operationId ? { id: operationId } : {}) }), + diagnostics: createEnvironmentLifecycleDiagnostics({ port: port.diagnostics, ...(now ? { now } : {}) }), }); } diff --git a/src/app/environment-materialization.js b/src/app/environment-materialization.js index be1022d89..2b48b77b7 100644 --- a/src/app/environment-materialization.js +++ b/src/app/environment-materialization.js @@ -1,4 +1,5 @@ import { normalizeEnvironmentObservation, ENVIRONMENT_OBSERVATION_PROTOCOL } from '../runtime/environment-observation.js'; +import { requireEnvironmentOperationSubject } from '../runtime/environment-operation-subject.js'; function assertState(value) { const methods = ['listEnvironments', 'ensureEnvironment']; @@ -134,13 +135,13 @@ export function createEnvironmentMaterialization({ state, subject, settings } = }); } -export function createEnvironmentRebuildMaterialization({ state, subject, journal } = {}) { +export function createEnvironmentRebuildMaterialization({ state, subject } = {}) { const localState = assertRebuildState(state); const subjectResolver = assertResolver(subject, 'rebuild subject'); - if (!journal || typeof journal.current !== 'function') throw new TypeError('environment rebuild materialization journal contract is incomplete'); return Object.freeze({ async ensure(rawRequest) { const request = requireRequest(rawRequest); + const operation = requireEnvironmentOperationSubject(request, 'rebuild'); const localSubject = await subjectResolver.resolve(Object.freeze({ environmentIdentity: request.environmentIdentity, profile: request.declaration.profile, @@ -148,11 +149,7 @@ export function createEnvironmentRebuildMaterialization({ state, subject, journa const matches = (await localState.listEnvironments()).filter((entry) => entry?.record?.subject === localSubject && entry?.record?.profile === request.declaration.profile); if (matches.length !== 1) throw new Error('environment rebuild materialization is missing or ambiguous'); const selected = matches[0]; - const active = await journal.current(request.environmentIdentity); - if (!active || active.operation !== 'rebuild' || active.operationId !== request.operationId || active.declarationRevision !== request.declarationRevision) { - throw new Error('environment rebuild materialization is not bound to the active rebuild lifecycle'); - } - const previous = active.entries.find((entry) => entry.stage === 'pre-observation')?.implementationGeneration; + const previous = operation.previousImplementationGeneration; if (typeof previous !== 'string' || !/^env-[a-f0-9]{32}$/u.test(previous)) throw new Error('environment rebuild previous implementation generation is unavailable'); const result = await localState.rebuildEnvironment(implementation(selected.record), { requestId: request.operationId, diff --git a/src/app/environment-operator.js b/src/app/environment-operator.js index c29501e3d..7f61ea490 100644 --- a/src/app/environment-operator.js +++ b/src/app/environment-operator.js @@ -236,8 +236,20 @@ export function createEnvironmentOperator({ runtime } = {}) { }; const invoke = async (operation, identity, approval = null) => { - if (operation === 'reset' || operation === 'recreate') return ownerMethod(runtime, operation)(identity, { approval }); - return ownerMethod(runtime, operation)(identity); + const declaration = await declarations.get(identity); + try { + if (operation === 'reset' || operation === 'recreate') return await ownerMethod(runtime, operation)(identity, { approval }); + return await ownerMethod(runtime, operation)(identity); + } catch (error) { + // Preserve the owner's failure before the public v1 boundary projects its + // generic error. Reading diagnostics never repeats the failed operation. + try { + const current = await journal.current(identity); + await runtime.lifecycle.diagnostics?.record({ environmentIdentity: identity, declarationRevision: declaration?.revision ?? null, operation, + journal: current?.declarationRevision === declaration?.revision && current.operation === operation ? current : null, error }); + } catch { /* Failure to collect must not replace the original operation error. */ } + throw error; + } }; const list = async () => { @@ -338,6 +350,15 @@ export function createEnvironmentOperator({ runtime } = {}) { }); }, status, + async diagnostics(identity) { + if (typeof runtime.lifecycle.diagnostics?.inspect !== 'function') throw Object.assign(new Error('lifecycle diagnostics are unsupported'), { code: 'UNSUPPORTED_CAPABILITY' }); + const selected = safeId(identity, 'environment identity'); + const evidence = await runtime.lifecycle.diagnostics.inspect(selected); + const declaration = await declarations.get(selected); + const current = await journal.current(selected); + return Object.freeze({ ...evidence, current: evidence.available === true + && declaration?.revision === evidence.declarationRevision && current?.operationId === evidence.operationId && active(current) }); + }, plan, run, async resume(rawIdentity, { approval = null } = {}) { diff --git a/src/app/environment-recreate.js b/src/app/environment-recreate.js index ed245cef1..b0c92ca22 100644 --- a/src/app/environment-recreate.js +++ b/src/app/environment-recreate.js @@ -1,3 +1,4 @@ +import { requireEnvironmentOperationSubject } from '../runtime/environment-operation-subject.js'; function assertPort(value, methods, name) { if (!value || methods.some((method) => typeof value[method] !== 'function')) throw new TypeError(`environment recreate ${name} contract is incomplete`); return value; @@ -19,14 +20,14 @@ function requestIdentity(value) { return value; } -export function createEnvironmentRecreateMaterialization({ state, subject, journal } = {}) { +export function createEnvironmentRecreateMaterialization({ state, subject } = {}) { const localState = assertPort(state, ['listEnvironments', 'recreateEnvironment'], 'materialization state'); const subjectResolver = assertResolver(subject); - const localJournal = assertPort(journal, ['current'], 'journal'); return Object.freeze({ async ensure(rawRequest) { const request = requireRequest(rawRequest); + const operation = requireEnvironmentOperationSubject(request, 'recreate'); const localSubject = await subjectResolver.resolve(Object.freeze({ environmentIdentity: request.environmentIdentity, profile: request.declaration.profile, @@ -36,11 +37,7 @@ export function createEnvironmentRecreateMaterialization({ state, subject, journ if (matches.length !== 1) throw new Error('environment recreate materialization is missing or ambiguous'); const selected = matches[0]; if (selected.record?.source?.identity !== request.declaration.image.identity) throw new Error('environment recreate source no longer matches declaration authority'); - const active = await localJournal.current(request.environmentIdentity); - if (!active || active.operation !== 'recreate' || active.operationId !== request.operationId || active.declarationRevision !== request.declarationRevision) { - throw new Error('environment recreate materialization is not bound to the active recreate lifecycle'); - } - const previous = active.entries.find((entry) => entry.stage === 'pre-observation')?.implementationGeneration; + const previous = operation.previousImplementationGeneration; implementation(previous, 'environment recreate previous implementation generation'); const result = await localState.recreateEnvironment(implementation(selected.record?.identity), { requestId: request.operationId, diff --git a/src/app/environment-reset.js b/src/app/environment-reset.js index 41fd04629..e79dbd21d 100644 --- a/src/app/environment-reset.js +++ b/src/app/environment-reset.js @@ -1,3 +1,4 @@ +import { requireEnvironmentOperationSubject } from '../runtime/environment-operation-subject.js'; function assertPort(value, methods, name) { if (!value || methods.some((method) => typeof value[method] !== 'function')) throw new TypeError(`environment reset ${name} contract is incomplete`); return value; @@ -19,14 +20,14 @@ function requestIdentity(value) { return value; } -export function createEnvironmentResetMaterialization({ state, subject, journal } = {}) { +export function createEnvironmentResetMaterialization({ state, subject } = {}) { const localState = assertPort(state, ['listEnvironments', 'replaceEnvironment'], 'materialization state'); const subjectResolver = assertResolver(subject); - const localJournal = assertPort(journal, ['current'], 'journal'); return Object.freeze({ async ensure(rawRequest) { const request = requireRequest(rawRequest); + const operation = requireEnvironmentOperationSubject(request, 'reset'); const localSubject = await subjectResolver.resolve(Object.freeze({ environmentIdentity: request.environmentIdentity, profile: request.declaration.profile, @@ -36,11 +37,7 @@ export function createEnvironmentResetMaterialization({ state, subject, journal if (matches.length !== 1) throw new Error('environment reset materialization is missing or ambiguous'); const selected = matches[0]; if (selected.record?.source?.identity !== request.declaration.image.identity) throw new Error('environment reset source no longer matches declaration authority'); - const active = await localJournal.current(request.environmentIdentity); - if (!active || active.operation !== 'reset' || active.operationId !== request.operationId || active.declarationRevision !== request.declarationRevision) { - throw new Error('environment reset materialization is not bound to the active reset lifecycle'); - } - const previous = active.entries.find((entry) => entry.stage === 'pre-observation')?.implementationGeneration; + const previous = operation.previousImplementationGeneration; implementation(previous, 'environment reset previous implementation generation'); const result = await localState.replaceEnvironment(implementation(selected.record?.identity), { requestId: request.operationId, diff --git a/src/app/execution-profile-routing.js b/src/app/execution-profile-routing.js index 5615b32f8..1a537f2e4 100644 --- a/src/app/execution-profile-routing.js +++ b/src/app/execution-profile-routing.js @@ -91,7 +91,7 @@ export function createExecutionProfileRouting({ state, policy }) { const index = routeIndex(normalized); const physicalForRoute = async (route) => { - const matches = profileMatches(await state.listEnvironments(), route.profile); + const matches = profileMatches(await state.listEnvironments({ subject: executionProfileSubject(route.profile), profile: route.profile }), route.profile); if (matches.length > 1) throw new Error(`execution profile ${route.profile} has multiple persistent environments`); return matches[0] ?? null; }; @@ -106,6 +106,9 @@ export function createExecutionProfileRouting({ state, policy }) { const route = routeForTarget(target); const physical = await physicalForRoute(route); if (!physical) throw new Error(`execution profile ${route.profile} has no persistent environment`); + if (physical.observation?.exists !== true || physical.observation?.owned !== true || physical.observation?.compatible !== true) { + throw new Error(`execution profile ${route.profile} is unavailable: ${physical.observation?.reason ?? 'current environment is not compatible'}`); + } return physical.record.identity; }; @@ -139,6 +142,14 @@ export function createExecutionProfileRouting({ state, policy }) { return syntheticEntry(route, target, observed); }, physicalTarget, + async attachmentBinding(target) { + const route = routeForTarget(target); + if (typeof state.readEnvironmentRecords !== 'function') return null; + const snapshot = await state.readEnvironmentRecords({ subject: executionProfileSubject(route.profile), profile: route.profile }); + if (snapshot.records.length !== 1) throw new Error('execution profile has no unique committed environment'); + return { record: snapshot.records[0], revision: snapshot.revision, + workspace: executionWorkspaceIdentity(route.subject, route.profile) }; + }, representativeTarget, targetForSubject(subject) { return preferredSubjectRoute(index, subject).target; diff --git a/src/app/file-lease.js b/src/app/file-lease.js new file mode 100644 index 000000000..b5eda103b --- /dev/null +++ b/src/app/file-lease.js @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { createLinuxFileLease } from '../runtime/linux-file-lease.js'; +import { createWindowsFileLease } from '../runtime/windows-file-lease.js'; + +export function createLocalFileLease({ subjectPath, platform = process.platform, holderExecutable = path.join(path.dirname(process.execPath), 'devbridge-lifecycle-authority-host.exe') } = {}) { + // Read-only composition does not need a platform mutation capability. Resolve + // it only when a consumer actually requests exclusive ownership. + return Object.freeze({ + async acquire(request) { + if (platform === 'linux') return createLinuxFileLease({ subjectPath }).acquire(request); + if (platform === 'win32') return createWindowsFileLease({ subjectPath, holderExecutable }).acquire(request); + throw new Error('no local mutation lease is available on this host'); + }, + }); +} diff --git a/src/app/linux-access-preparation.js b/src/app/linux-access-preparation.js index abd960256..c42b68089 100644 --- a/src/app/linux-access-preparation.js +++ b/src/app/linux-access-preparation.js @@ -1,35 +1,49 @@ +import { setTimeout as delay } from 'node:timers/promises'; + const DESTINATION = '/var/lib/devbridge/access/seed.json'; function sameConnection(left, right) { return left?.family === 'linux' && right?.family === 'linux' && left.user === right.user && left.identityFile === right.identityFile && left.knownHostsFile === right.knownHostsFile; } -export function createLinuxAccessPreparation({ material, delivery, probe, settleMs = 90_000, pollMs = 1_000 } = {}) { +export function createLinuxAccessPreparation({ material, delivery, probe, settleMs = 180_000, pollMs = 1_000, now = Date.now, wait = (ms, signal) => delay(ms, undefined, { signal }) } = {}) { if (!material || typeof material.connection !== 'function' || typeof material.prepare !== 'function') throw new TypeError('Linux access material contract is incomplete'); if (!delivery || typeof delivery.put !== 'function') throw new TypeError('Linux access delivery contract is incomplete'); if (!probe || typeof probe.inspect !== 'function') throw new TypeError('Linux access probe contract is incomplete'); - if (!Number.isSafeInteger(settleMs) || settleMs < 0 || !Number.isSafeInteger(pollMs) || pollMs < 1) throw new TypeError('Linux access settling policy is invalid'); + if (!Number.isSafeInteger(settleMs) || settleMs < 100 || settleMs > 300_000 || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 30_000 + || typeof now !== 'function' || typeof wait !== 'function') throw new TypeError('Linux access settling policy is invalid'); const connection = (target) => material.connection(target); - const ensure = async ({ target, access }) => { + const ensure = async ({ target, access, deadline = null, signal = undefined }) => { if (!access || access.family !== 'linux') throw new TypeError('Linux access preparation requires a Linux connection'); const expected = connection(target); if (!sameConnection(expected, access)) throw new Error('Linux access preparation connection changed'); - let observed = await probe.inspect(access); + if (deadline != null && !Number.isSafeInteger(deadline)) throw new TypeError('Linux access deadline is invalid'); + const until = Math.min(now() + settleMs, deadline ?? Infinity); + let observed; + const remaining = () => { + signal?.throwIfAborted(); + const value = until - now(); + if (value < 100) throw new Error(`Linux access did not become ready before its deadline: ${observed?.reason ?? 'starting'}`); + return value; + }; + const inspect = async () => { + observed = await probe.inspect(access, { timeoutMs: Math.min(15_000, remaining()), signal }); + remaining(); + return observed; + }; + await inspect(); if (observed.ready === true) return Object.freeze({ ready: true, changed: false }); const prepared = await material.prepare(target); try { if (!sameConnection(prepared.connection, access)) throw new Error('Linux access material returned another connection identity'); - await delivery.put(target, prepared.seedFile, DESTINATION); - const deadline = Date.now() + settleMs; + await delivery.put(target, prepared.seedFile, DESTINATION, { timeoutMs: Math.min(30_000, remaining()), signal }); do { - observed = await probe.inspect(access); + await inspect(); if (observed.ready === true) return Object.freeze({ ready: true, changed: true }); - if (Date.now() >= deadline) break; - await new Promise((resolve) => setTimeout(resolve, pollMs)); + await wait(Math.min(pollMs, remaining()), signal); } while (true); - throw new Error(`Linux access did not become ready: ${observed.reason ?? 'unknown failure'}`); } finally { await prepared.cleanup(); } diff --git a/src/app/repository-execution.js b/src/app/repository-execution.js index 1f583f347..9c82ab5c1 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -25,12 +25,15 @@ import { OperationMaterializer } from './repository-execution/operation-material import { RouteAccess } from './repository-execution/route-access.js'; import { acquireSessionGuard } from './repository-execution/session-guard.js'; import { WorkspaceSession } from './repository-execution/workspace-session.js'; +import { transferRepositorySource } from './repository-execution/source-transfer.js'; +import { executionWorkspaceTarget } from './execution-profile-routing.js'; const BRIDGE_OUTPUT_LIMIT = 3 * 1024 * 1024; const TRANSFER_LIMIT = 16 * 1024 * 1024; const MANIFEST_LIMIT = 24 * 1024 * 1024; const AGENT_FILE = fileURLToPath(new URL('../guest/workspace-agent.mjs', import.meta.url)); const RESOURCE_AGENT_FILE = fileURLToPath(new URL('../guest/resource-agent.mjs', import.meta.url)); +const SOURCE_PACK_AGENT_FILE = fileURLToPath(new URL('../guest/source-pack-agent.mjs', import.meta.url)); function hashIdentity(value) { return `execution-${createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')}`; } function repositoryPathAllowed(relative) { const first = String(relative).replace(/\\/gu, '/').split('/')[0]; return first !== '.git' && first !== '.devbridge'; } @@ -52,7 +55,9 @@ function activityComponents(raw) { return Object.freeze({ state: Object.freeze({ inspect: () => raw.inspect(), - listEnvironments: () => raw.list(), + listEnvironments: async (selection = null) => selection == null + ? raw.list() + : [await raw.observe(executionWorkspaceTarget(selection.subject, selection.profile))], observeEnvironment: (target) => raw.observe(target), }), preparation: Object.freeze({ ensure: (target) => raw.prepare(target) }), @@ -124,13 +129,14 @@ export async function createRepositoryExecution({ }; const agentBytes = await readFile(AGENT_FILE); const resourceAgentBytes = await readFile(RESOURCE_AGENT_FILE); + const sourcePackAgentBytes = await readFile(SOURCE_PACK_AGENT_FILE); const stagingRoot = path.join(path.resolve(stateDirectory), 'repository-execution', 'staging'); await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); const access = new RouteAccess({ policy, identify: resolveSubject, select: environmentActivityRouteForSubject, - list: () => state.listEnvironments(), + list: (selection) => state.listEnvironments(selection), root: rootFor, canonicalize: (value) => realpath(path.resolve(value)), inspect: lstat, @@ -155,11 +161,16 @@ export async function createRepositoryExecution({ }); const agentLocation = { class: 'input', path: 'control/workspace-agent.mjs' }; const resourceAgentLocation = { class: 'input', path: 'control/resource-agent.mjs' }; + const sourcePackAgentLocation = { class: 'input', path: 'control/source-pack-agent.mjs' }; + const sourcePackLocation = { class: 'input', path: 'source/parts.gz' }; + let sourcePackAgentInstalled = false; const stateLocation = { class: 'cache', path: 'source-state.json' }; const sourceManifestLocation = { class: 'input', path: 'source/manifest.json' }; const candidateDirectory = { class: 'output', path: 'candidate' }; - const scratchRoot = `subjects/${subject}/runs/${scope.runId}`; - const scratchRunLocation = { class: 'scratch', path: scratchRoot }; + const legacyScratchRoot = `subjects/${subject}/runs/${scope.runId}`; + const compactScratchRoot = `r/${createHash('sha256').update(JSON.stringify([subject, scope.runId])).digest('hex').slice(0, 32)}`; + let selectedScratchRoot = null; + let resourceAgentInstalled = false; const bytes = new ByteChannel({ target, put: (...args) => channel.put(...args), @@ -170,6 +181,24 @@ export async function createRepositoryExecution({ limit: 'execution output transfer exceeded its limit', }, }); + const installResourceAgent = async () => { + if (resourceAgentInstalled) return; + await bytes.write(resourceAgentBytes, resourceAgentLocation); + resourceAgentInstalled = true; + }; + const scratchRoot = async () => { + if (selectedScratchRoot != null) return selectedScratchRoot; + await installResourceAgent(); + const choice = parseAgentResult(await channel.execute(target, { + program: 'node', arguments: [resourceAgentLocation, 'select-directory', + { class: 'scratch', path: legacyScratchRoot }, { class: 'scratch', path: compactScratchRoot }], + directory: { class: 'work', path: '.' }, environment: {}, input: null, + timeoutMs: 30_000, maxOutputBytes: 4096, + }, { pollIntervalMs: 500 }), 'scratch layout observation'); + if (!choice || Object.keys(choice).length !== 1 || !['legacy', 'compact'].includes(choice.selected)) throw new Error('scratch layout observation is invalid'); + selectedScratchRoot = choice.selected === 'legacy' ? legacyScratchRoot : compactScratchRoot; + return selectedScratchRoot; + }; const materializer = new OperationMaterializer({ write: (value, location) => bytes.write(value, location), protectedValues: protectedEnvironmentValues, @@ -217,7 +246,27 @@ export async function createRepositoryExecution({ snapshot, install: () => bytes.write(agentBytes, agentLocation), observe: (digest, options) => runAgent('prepare', [stateLocation, digest], { timeoutMs: 60_000, ...options }), - writePart: (part, read) => bytes.stream({ read }, { class: 'input', path: `source/${part.name}` }, { maxBytes: Math.max(1, part.size) }), + transfer: async (snapshot, options) => { + if (!sourcePackAgentInstalled) { + await bytes.write(sourcePackAgentBytes, sourcePackAgentLocation, { signal: options.signal }); + sourcePackAgentInstalled = true; + } + const needed = parseAgentResult(await channel.execute(target, { + program: 'node', arguments: [sourcePackAgentLocation, 'needed', sourceManifestLocation, createHash('sha256').update(snapshot.manifestBytes()).digest('hex')], + directory: { class: 'work', path: '.' }, environment: {}, input: null, + timeoutMs: 60_000, maxOutputBytes: BRIDGE_OUTPUT_LIMIT, + }, options), 'source part observation'); + return transferRepositorySource({ + snapshot, needed, ...options, + writePack: (value, controls) => bytes.write(value, sourcePackLocation, controls), + unpack: async (identity, controls) => parseAgentResult(await channel.execute(target, { + program: 'node', arguments: [sourcePackAgentLocation, sourcePackLocation, identity], + directory: { class: 'work', path: '.' }, environment: {}, input: null, + timeoutMs: 60_000, maxOutputBytes: 16 * 1024, + }, controls), 'source pack unpacking'), + writePart: (part, read) => bytes.stream({ read }, { class: 'input', path: `source/${part.name}` }, { maxBytes: Math.max(1, part.size) }), + }); + }, writeManifest: (value) => bytes.write(value, sourceManifestLocation), apply: (options) => runAgent('apply', [sourceManifestLocation, stateLocation], { timeoutMs: 10 * 60_000, ...options }), }, @@ -274,7 +323,8 @@ export async function createRepositoryExecution({ if (resource !== 'scratch') throw new Error('repository execution cleanup resource is unsupported'); }, remove: async (resource, { signal }) => { - await bytes.write(resourceAgentBytes, resourceAgentLocation); + const scratchRunLocation = { class: 'scratch', path: await scratchRoot() }; + await installResourceAgent(); const outcome = await channel.execute(target, { program: 'node', arguments: [resourceAgentLocation, 'remove-directory', scratchRunLocation], diff --git a/src/app/repository-execution/byte-channel.js b/src/app/repository-execution/byte-channel.js index a11ff416f..797cc4a8e 100644 --- a/src/app/repository-execution/byte-channel.js +++ b/src/app/repository-execution/byte-channel.js @@ -1,7 +1,9 @@ -function sourceFrom(value) { +function sourceFrom(value, { signal = null, onProgress = null } = {}) { const bytes = Buffer.from(value); return { async read({ offset, limit }) { + await onProgress?.({ offset, total: bytes.length }); + if (signal?.aborted) throw signal.reason ?? new Error('byte transfer was cancelled'); const end = Math.min(bytes.length, offset + limit); return { data: bytes.subarray(offset, end), eof: end === bytes.length }; }, @@ -70,9 +72,9 @@ export class ByteChannel { }; } - async write(bytes, destination) { + async write(bytes, destination, controls = {}) { const value = Buffer.from(bytes); - return this.#put(this.#target, sourceFrom(value), destination, { maxBytes: Math.max(1, value.length) }); + return this.#put(this.#target, sourceFrom(value, controls), destination, { maxBytes: Math.max(1, value.length) }); } async read(source, limit) { diff --git a/src/app/repository-execution/operation-materializer.js b/src/app/repository-execution/operation-materializer.js index 87bc1bd08..9104c149b 100644 --- a/src/app/repository-execution/operation-materializer.js +++ b/src/app/repository-execution/operation-materializer.js @@ -135,7 +135,9 @@ export class OperationMaterializer { protectedValues: this.#protectedValues, messages: this.#messages, entryLocation, - scratchRoot: this.#scratchRoot, + scratchRoot: typeof this.#scratchRoot === 'function' + ? (invocation.arguments.some(argument => argument.kind === 'scratch') ? await this.#scratchRoot() : null) + : this.#scratchRoot, }); const bytes = Buffer.from(`${JSON.stringify(materialized.descriptor)}\n`, 'utf8'); if (bytes.length > DESCRIPTOR_LIMIT) throw new Error(this.#messages.descriptorLimit); diff --git a/src/app/repository-execution/route-access.js b/src/app/repository-execution/route-access.js index bd9b94b4f..5be335328 100644 --- a/src/app/repository-execution/route-access.js +++ b/src/app/repository-execution/route-access.js @@ -27,7 +27,7 @@ export class RouteAccess { async resolve(scope) { const subject = stableSubject(await this.#identify(structuredClone(scope)), this.#messages.subjectName); const route = this.#select(this.#policy, subject); - const matches = (await this.#list()).filter((entry) => entry.record?.subject === subject && entry.record?.profile === route.profile); + const matches = (await this.#list({ subject, profile: route.profile })).filter((entry) => entry.record?.subject === subject && entry.record?.profile === route.profile); if (matches.length !== 1) throw new Error(matches.length === 0 ? this.#messages.absent : this.#messages.ambiguous); const selected = matches[0]; if (!selected.observation?.exists || !selected.observation?.owned || !selected.observation?.compatible) { diff --git a/src/app/repository-execution/source-transfer.js b/src/app/repository-execution/source-transfer.js new file mode 100644 index 000000000..e23042b85 --- /dev/null +++ b/src/app/repository-execution/source-transfer.js @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; + +const PROTOCOL = 'devbridge/source-part-pack-v1'; +const RAW_BYTES = 2 * 1024 * 1024; +const MAX_PARTS = 2048; +const PACK_BYTES = 4 * 1024 * 1024; +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +function active(signal) { + if (signal?.aborted) throw signal.reason ?? new Error('source transfer was cancelled'); +} + +// Packing is a workspace transport decision. The original tree manifest remains +// the authority for every file and part, including parts too large for a pack. +export async function transferRepositorySource({ snapshot, needed = null, writePack, unpack, writePart, signal = null, onActivity = null }) { + const allParts = snapshot.manifest.entries.filter(entry => entry.type === 'file').flatMap(entry => entry.parts); + let parts = allParts; + if (needed != null) { + const known = new Set(allParts.map(part => part.name)); + if (needed.protocol !== 'devbridge/source-parts-needed-v1' + || needed.manifestDigest !== digest(snapshot.manifestBytes()) + || !Array.isArray(needed.needed) || needed.needed.length > allParts.length + || new Set(needed.needed).size !== needed.needed.length + || needed.needed.some(name => !known.has(name))) { + throw new Error('source part selection does not match its manifest'); + } + const selected = new Set(needed.needed); + parts = allParts.filter(part => selected.has(part.name)); + } + let batch = [], size = 0, completed = 0; + const started = Date.now(); + const progress = () => onActivity?.({ kind: `source-transfer ${completed}/${parts.length} parts`, elapsedMs: Date.now() - started, at: new Date().toISOString() }); + await progress(); + const flush = async () => { + if (batch.length === 0) return; + active(signal); + const serialized = Buffer.from(JSON.stringify({ protocol: PROTOCOL, parts: batch })); + if (serialized.length > PACK_BYTES) throw new Error('source part pack exceeds its byte bound'); + const bytes = gzipSync(serialized); + const identity = digest(bytes); + await writePack(bytes, { signal, onProgress: ({ offset, total }) => onActivity?.({ + kind: `source-transfer ${completed}/${parts.length} parts; pack ${offset}/${total} bytes`, + elapsedMs: Date.now() - started, at: new Date().toISOString(), + }) }); + active(signal); + const result = await unpack(identity, { signal, onActivity }); + if (result?.ready !== true || result.digest !== identity || result.parts !== batch.length) throw new Error('source part pack receipt does not match its subject'); + completed += batch.length; + batch = []; size = 0; + await progress(); + }; + for (const part of parts) { + active(signal); + if (part.size > RAW_BYTES) { + await flush(); + await writePart(part, request => { active(signal); return snapshot.readPart(part.name, request); }); + completed += 1; await progress(); + continue; + } + if (size + part.size > RAW_BYTES || batch.length === MAX_PARTS) await flush(); + const response = await snapshot.readPart(part.name, { offset: 0, limit: part.size }); + const bytes = Buffer.from(response.data); + if (response.eof !== true || bytes.length !== part.size || digest(bytes) !== part.digest) throw new Error('source part changed before packing'); + batch.push({ name: part.name, size: part.size, digest: part.digest, data: bytes.toString('base64') }); + size += bytes.length; + } + await flush(); + active(signal); +} diff --git a/src/app/repository-execution/workspace-session.js b/src/app/repository-execution/workspace-session.js index 03f9d395e..82042acb9 100644 --- a/src/app/repository-execution/workspace-session.js +++ b/src/app/repository-execution/workspace-session.js @@ -31,27 +31,41 @@ export class WorkspaceSession { } async prepare({ signal = null, onActivity = null } = {}) { + const started = Date.now(); + let phase; + const report = async (kind) => { + ensureActive(signal); + phase = kind; + await onActivity?.({ kind, elapsedMs: Date.now() - started, at: new Date().toISOString() }); + ensureActive(signal); + }; + const preparationActivity = (event) => onActivity?.({ ...event, + kind: /^(workspace-|source-)/u.test(event.kind ?? '') ? event.kind : phase, + }); + await report('workspace-prepare'); ensureActive(signal); const ready = await this.#activity.prepare(); + await report('workspace-health'); ensureActive(signal); const health = await this.#activity.health(); if (!health.ready) throw new Error(health.reason ?? this.#messages.activityUnavailable); + await report('source-snapshot'); this.#source = await this.#sourcePort.snapshot(); ensureActive(signal); + await report('source-agent'); await this.#sourcePort.install(); - const observed = await this.#sourcePort.observe(this.#source.manifest.digest, { signal, onActivity }); + await report('source-check'); + const observed = await this.#sourcePort.observe(this.#source.manifest.digest, { signal, onActivity: preparationActivity }); if (observed.appliedDigest !== this.#source.manifest.digest) { - for (const entry of this.#source.manifest.entries) { - if (entry.type !== 'file') continue; - for (const part of entry.parts) { - ensureActive(signal); - await this.#sourcePort.writePart(part, (request) => this.#source.readPart(part.name, request)); - } - } + await report('source-manifest'); await this.#sourcePort.writeManifest(this.#source.manifestBytes()); - const applied = await this.#sourcePort.apply({ signal, onActivity }); + await report('source-transfer'); + await this.#sourcePort.transfer(this.#source, { signal, onActivity: preparationActivity }); + await report('source-apply'); + const applied = await this.#sourcePort.apply({ signal, onActivity: preparationActivity }); if (applied.digest !== this.#source.manifest.digest) throw new Error(this.#messages.sourceApplyMismatch); } + await report('source-verify'); const current = await this.#sourcePort.snapshot(); if (current.manifest.digest !== this.#source.manifest.digest) throw new Error(this.#messages.sourceChangedDuringSync); this.#evidence = this.#identify({ generation: ready.generation, version: health.version, source: this.#source.manifest.digest }); diff --git a/src/app/runtime.js b/src/app/runtime.js index 6019e004d..659f0e9f1 100644 --- a/src/app/runtime.js +++ b/src/app/runtime.js @@ -94,6 +94,7 @@ export async function createRuntime(config, { const feedbackSource = new IssueFeedbackSource({ client, queueRepository: selectedQueue, trustedActorIds: config.github.trustedActorIds }); const decisionSource = new IssueDecisionSource({ client, queueRepository: selectedQueue }); let toolInventory = null; + const faultInjector = new DeterministicFaultInjector(config.execution.faultInjection); const statusReporter = new IssueStatusReporter({ client, stateStore, @@ -102,6 +103,7 @@ export async function createRuntime(config, { maxCommentBytes: config.status.maxCommentBytes, secretValues, inventoryRefProvider: () => toolInventory?.reference() ?? null, + faultInjector, }); const chatHandoffProjector = new ChatHandoffProjector({ client, @@ -189,7 +191,6 @@ export async function createRuntime(config, { const deterministicProfileNames = Object.keys(builtIns); const tools = { ...config.tools, ...builtIns }; - const faultInjector = new DeterministicFaultInjector(config.execution.faultInjection); const runtimeExecution = await createRuntimeExecutionContext({ config, workspaceManager, diff --git a/src/app/setup-environment-activation.js b/src/app/setup-environment-activation.js index 8f3fe612b..446a761b3 100644 --- a/src/app/setup-environment-activation.js +++ b/src/app/setup-environment-activation.js @@ -1,4 +1,5 @@ import { ENVIRONMENT_OPERATOR_STATUS_PROTOCOL } from './environment-operator.js'; +import { environmentDeclarationDigest } from '../runtime/environment-declaration.js'; export const SETUP_ENVIRONMENT_ACTIVATION_PROTOCOL = 'devbridge/setup-environment-activation-v1'; @@ -62,9 +63,10 @@ function result({ ready, changed = false, state, blocker = null, identity = null }); } -export async function reconcileSetupEnvironmentActivation({ client, profile } = {}) { +export async function reconcileSetupEnvironmentActivation({ client, profile, expectedDeclaration = null } = {}) { const selectedClient = assertClient(client); const selectedProfile = safeId(profile, 'setup environment profile'); + const expectedDigest = expectedDeclaration == null ? null : environmentDeclarationDigest(expectedDeclaration); const inventory = await selectedClient.list(); if (!Array.isArray(inventory) || inventory.length > MAX_ENVIRONMENTS) { throw new Error('protected environment inventory is invalid'); @@ -75,16 +77,17 @@ export async function reconcileSetupEnvironmentActivation({ client, profile } = } const before = normalizeStatus(matches[0], { profile: selectedProfile }); + if (expectedDigest != null && before.declarationDigest !== expectedDigest) throw new Error('environment declaration differs from accepted setup authority'); const identity = before.environmentIdentity; if (terminalReady(before)) return result({ ready: true, state: 'ready', identity }); let changed = false; if (before.lifecycle.active === true) { - if (before.lifecycle.operation !== 'create' || before.lifecycle.resumable !== true) { + if (!['create', 'repair', 'rebuild', 'reset', 'recreate'].includes(before.lifecycle.operation) || before.lifecycle.resumable !== true || before.recommendedAction !== 'resume') { return result({ ready: false, state: 'blocked', - blocker: 'accepted environment has a non-create lifecycle transition requiring operator review', + blocker: 'accepted environment transition requires its lifecycle owner to resolve authority', identity, }); } @@ -105,6 +108,7 @@ export async function reconcileSetupEnvironmentActivation({ client, profile } = } const after = normalizeStatus(await selectedClient.status(identity), { profile: selectedProfile, identity }); + if (after.declarationDigest !== before.declarationDigest || after.declarationRevision !== before.declarationRevision) throw new Error('environment declaration changed during activation'); if (!terminalReady(after)) { return result({ ready: false, diff --git a/src/app/setup.js b/src/app/setup.js index 5f2474d34..47c31dd88 100644 --- a/src/app/setup.js +++ b/src/app/setup.js @@ -1437,8 +1437,13 @@ export async function runDevBridgeSetup({ const client = lifecycleClientFactory({ stateDirectory, platform, connectTimeoutMs: 3_000 }); environmentActivation = await progress.run('environment-activation', () => serialReconciler({ items: activationProfiles, + independent: true, reconcile: async (profile) => { - const observed = await environmentActivationReconciler({ client, profile }); + const expectedDeclaration = profileConfigurationRecord.configuration.declarations.find((entry) => entry.profile === profile); + if (!expectedDeclaration) throw new Error('activation profile is outside accepted configuration'); + let observed; + try { observed = await environmentActivationReconciler({ client, profile, expectedDeclaration }); } + catch (error) { observed = { ready: false, changed: error?.changed === true, blocker: String(error?.message ?? 'environment activation failed').slice(0, 512) }; } return Object.freeze({ ready: observed?.ready === true, changed: observed?.changed === true, @@ -1463,7 +1468,7 @@ export async function runDevBridgeSetup({ blocker: `Protected environment activation failed: ${error.message}`, }); } - if (environmentActivation?.ready !== true) { + if (environmentActivation?.ready !== true && !(environmentActivation?.completedCount > 0)) { return publicResult({ home: root, pathStatus, @@ -1576,6 +1581,7 @@ export async function runDevBridgeSetup({ operationalConfiguration, constructionRequested: construct, constructionAttempted, + blocker: environmentActivation?.ready === true ? null : environmentActivation?.blocker ?? 'Some accepted environment profiles remain unavailable', }); } diff --git a/src/app/windows-elevated-setup-environment-activation.js b/src/app/windows-elevated-setup-environment-activation.js index 5064d1666..5e8ada4ca 100644 --- a/src/app/windows-elevated-setup-environment-activation.js +++ b/src/app/windows-elevated-setup-environment-activation.js @@ -1,5 +1,5 @@ import process from 'node:process'; -import { createLocalEnvironmentOperator } from './environment-operator-runtime.js'; +import { createConfiguredLifecycleAuthorityClient } from '../runtime/environment-lifecycle-authority-transport.js'; import { readEnvironmentProfileConfigurationRecord } from '../setup/environment-profile-configuration-record.js'; import { createWindowsEnvironmentProfileConfiguration } from '../setup/windows-environment-profile-configuration.js'; import { createWindowsLifecycleAuthorityPlan } from '../setup/windows-lifecycle-authority.js'; @@ -28,7 +28,7 @@ export async function reconcileWindowsElevatedSetupEnvironmentActivation({ configurationFactory = createWindowsEnvironmentProfileConfiguration, hostInspector = inspectWindowsLifecycleAuthorityReadinessHost, planFactory = createWindowsLifecycleAuthorityPlan, - operatorFactory = createLocalEnvironmentOperator, + clientFactory = createConfiguredLifecycleAuthorityClient, activationReconciler = reconcileSetupEnvironmentActivation, } = {}) { if (platform !== 'win32') throw new Error('elevated setup environment activation is only valid on Windows'); @@ -37,7 +37,7 @@ export async function reconcileWindowsElevatedSetupEnvironmentActivation({ } if (typeof invoke !== 'function' || typeof recordReader !== 'function' || typeof configurationFactory !== 'function' || typeof hostInspector !== 'function' || typeof planFactory !== 'function' - || typeof operatorFactory !== 'function' || typeof activationReconciler !== 'function') { + || typeof clientFactory !== 'function' || typeof activationReconciler !== 'function') { throw new TypeError('elevated setup environment activation composition is invalid'); } @@ -69,26 +69,21 @@ export async function reconcileWindowsElevatedSetupEnvironmentActivation({ return result({ ready: false, blocker: 'accepted environment profile configuration is unavailable' }); } - const client = await operatorFactory({ + const client = await clientFactory({ stateDirectory, - authorityDirectory: plan.authorityDirectory, platform: 'win32', - invoke, }); let changed = configured.changed === true; let environmentCount = 0; + let blocker = null; for (const declaration of declarations) { - const activation = await activationReconciler({ client, profile: declaration.profile }); + const activation = await activationReconciler({ client, profile: declaration.profile, expectedDeclaration: declaration }); changed ||= activation?.changed === true; if (activation?.ready !== true) { - return result({ - ready: false, - changed, - blocker: activation?.blocker ?? 'accepted environment did not verify ready after protected activation', - environmentCount, - }); + blocker ??= activation?.blocker ?? 'accepted environment did not verify ready after protected activation'; + continue; } environmentCount += 1; } - return result({ ready: true, changed, environmentCount }); + return result({ ready: blocker == null, changed, blocker, environmentCount }); } diff --git a/src/app/windows-environment-access.js b/src/app/windows-environment-access.js index e99c5b246..7ba389e0e 100644 --- a/src/app/windows-environment-access.js +++ b/src/app/windows-environment-access.js @@ -30,7 +30,7 @@ export async function createWindowsEnvironmentAccess({ const identity = await identityLoader({ directory: foundationRoot }); const material = materialFactory({ directory: path.join(root, 'material'), invoke, user: USER, platform }); const seed = seedFactory({ directory: path.join(root, 'transient'), user: USER }); - const delivery = deliveryFactory({ identity, invoke }); + const delivery = deliveryFactory({ identity, invoke, family: 'windows' }); const probe = probeFactory({ identity, invoke }); const preparation = preparationFactory({ material, seed, delivery, probe }); if (!preparation || typeof preparation.connection !== 'function' || typeof preparation.ensure !== 'function') { @@ -39,5 +39,6 @@ export async function createWindowsEnvironmentAccess({ return Object.freeze({ connection: (target) => preparation.connection(target), prepare: (request) => preparation.ensure(request), + discard: (target) => material.discard(target), }); } diff --git a/src/bootstrap/repository-preflight.mjs b/src/bootstrap/repository-preflight.mjs index dc8bee44e..62b86564f 100644 --- a/src/bootstrap/repository-preflight.mjs +++ b/src/bootstrap/repository-preflight.mjs @@ -155,6 +155,8 @@ const SYNTAX_FILES = [ 'src/values/boot-protection.js', 'src/app/environment-materialization-policy.js', 'src/app/environment-activity-host.js', + 'src/runtime/command-channel.js', + 'src/runtime/providers/guest-bridge-command-session.js', 'src/app/linux-environment-activity-state.js', 'src/runtime/environment-activity-policy-state.js', 'src/app/environment-lifecycle-authority-host.js', @@ -483,6 +485,7 @@ const TARGETED_TESTS = [ 'test/windows-production-image-setup.test.js', 'test/environment-construction-ports.test.js', 'test/environment-activity-host.test.js', + 'test/command-channel.test.js', 'test/environment-operator.test.js', 'test/environment-recovery.test.js', 'test/environment-profile-configuration.test.js', @@ -698,13 +701,14 @@ function protocolNumber(value, name) { export function parseRepositoryPreflightArguments(args = []) { if (!Array.isArray(args)) throw new TypeError('repository preflight arguments must be an array'); - const allowed = [BOUND_TARGETED_TEST_CONCURRENCY_ARGUMENT, '--ci-qualification']; + const allowed = [BOUND_TARGETED_TEST_CONCURRENCY_ARGUMENT, '--ci-qualification', '--static-only']; if (new Set(args).size !== args.length || args.some((arg) => !allowed.includes(arg))) { throw new Error(`repository preflight accepts only ${allowed.join(' and ')}, each at most once`); } return Object.freeze({ boundTargetedTestConcurrency: args.includes(BOUND_TARGETED_TEST_CONCURRENCY_ARGUMENT), ciQualification: args.includes('--ci-qualification'), + staticOnly: args.includes('--static-only'), }); } @@ -713,7 +717,7 @@ function normalizeRepositoryPreflightOptions(options) { throw new TypeError('repository preflight options must be an object'); } const keys = Object.keys(options); - if (keys.some((key) => !['boundTargetedTestConcurrency', 'ciQualification'].includes(key))) { + if (keys.some((key) => !['boundTargetedTestConcurrency', 'ciQualification', 'staticOnly'].includes(key))) { throw new TypeError('repository preflight options contain an unsupported field'); } if (options.boundTargetedTestConcurrency != null && typeof options.boundTargetedTestConcurrency !== 'boolean') { @@ -722,9 +726,11 @@ function normalizeRepositoryPreflightOptions(options) { if (options.ciQualification != null && typeof options.ciQualification !== 'boolean') { throw new TypeError('ciQualification must be boolean'); } + if (options.staticOnly != null && typeof options.staticOnly !== 'boolean') throw new TypeError('staticOnly must be boolean'); return Object.freeze({ boundTargetedTestConcurrency: options.boundTargetedTestConcurrency === true, ciQualification: options.ciQualification === true, + staticOnly: options.staticOnly === true, }); } @@ -770,6 +776,7 @@ export function runRepositoryPreflight(root = process.cwd(), runner = spawnSync, const missing = TARGETED_TESTS.filter((relative) => !targeted.includes(relative)); throw new Error(`preflight targeted tests are missing: ${missing.join(', ')}`); } + if (scheduling.staticOnly) return { standaloneArtifacts: 3, syntaxFiles: SYNTAX_FILES.length, jsonFiles: JSON_FILES.length, targetedTests: 0, staticOnly: true, compatibility }; const testArguments = [ '--test', ...(scheduling.boundTargetedTestConcurrency ? [`--test-concurrency=${TARGETED_TEST_CONCURRENCY_LIMIT}`] : []), diff --git a/src/cli.js b/src/cli.js index 6f720015a..14deb0b57 100755 --- a/src/cli.js +++ b/src/cli.js @@ -28,7 +28,7 @@ function usage() { console.error('Usage: devbridge setup [--profiles ] [--construct] [--windows-storage ] [--windows-distribution ] [--windows-activation ] [--track-ref ] [--retire-conflict ] [--home ] [--repository owner/name|all]...'); console.error(' devbridge setup [--windows-media ] [--approve-windows-media --windows-image-index --windows-media-class ]'); console.error(' devbridge --config [options]'); - console.error(' devbridge environment --config [--identity id|--profile name] [--operation op] [--confirm subject]'); + console.error(' devbridge environment --config [--identity id|--profile name] [--operation op] [--confirm subject]'); console.error(' devbridge construction-retention [inspect] [--home ]'); console.error(' devbridge construction-retention retire --subject --confirm [--home ]'); } @@ -73,6 +73,7 @@ async function runEnvironmentCommand(config, args) { if (action === 'setup-reentry') return operator.setupReentry(identity); if (!identity) throw new PolicyError(`environment ${action} requires --identity or --profile`); if (action === 'show') return operator.status(identity); + if (action === 'diagnostics') return operator.diagnostics(identity); if (action === 'resume') return operator.resume(identity, { approval: optionValue(args, '--confirm') }); if (action === 'plan') { const operation = optionValue(args, '--operation'); diff --git a/src/entry/windows-lifecycle-authority-worker.mjs b/src/entry/windows-lifecycle-authority-worker.mjs index 1dea0da73..6a6dba2b4 100644 --- a/src/entry/windows-lifecycle-authority-worker.mjs +++ b/src/entry/windows-lifecycle-authority-worker.mjs @@ -49,18 +49,21 @@ export function parseWindowsLifecycleAuthorityWorkerArguments(argv) { for (let index = 0; index < argv.length; index += 2) { const flag = argv[index]; const value = argv[index + 1]; - if (!ARGUMENTS.has(flag) || typeof value !== 'string' || values.has(flag)) { + if ((!ARGUMENTS.has(flag) && flag !== '--activity-stream') || typeof value !== 'string' || values.has(flag)) { throw new TypeError('Windows lifecycle authority worker arguments are invalid'); } values.set(flag, value); } - if (values.size !== ARGUMENTS.size) throw new TypeError('Windows lifecycle authority worker arguments are incomplete'); + if ([...ARGUMENTS].some(flag => !values.has(flag))) throw new TypeError('Windows lifecycle authority worker arguments are incomplete'); const access = values.get('--access'); if (!ACCESS.has(access)) throw new TypeError('Windows lifecycle authority worker access class is invalid'); + const stream = values.get('--activity-stream'); + if (stream != null && (stream !== 'v1' || access !== 'activity')) throw new TypeError('Windows activity stream mode is invalid'); return Object.freeze({ access, stateDirectory: absoluteWindowsPath(values.get('--state-directory'), 'Windows lifecycle authority worker stateDirectory'), authorityDirectory: absoluteWindowsPath(values.get('--authority-directory'), 'Windows lifecycle authority worker authorityDirectory'), + ...(stream == null ? {} : { activityStream: true }), }); } @@ -124,6 +127,37 @@ async function readSingleRequest(input, maxWireBytes) { return JSON.parse(text.slice(0, newline)); } +export async function runWindowsActivityAuthorityStream({ input, output, activityFactory, + idleMs = 0, maxRequests = 0 } = {}) { + let buffer = Buffer.alloc(0), timer = null, activity = null, count = 0; + const arm = () => { clearTimeout(timer); if (idleMs > 0) timer = setTimeout(() => input.destroy(new Error('activity worker idle lifetime ended')), idleMs); }; + arm(); + try { + for await (const chunk of input) { + buffer = Buffer.concat([buffer, Buffer.from(chunk)]); + if (buffer.length > ACTIVITY_MAX_REQUEST_WIRE_BYTES) throw new Error('activity worker request exceeded its bound'); + const newline = buffer.indexOf(10); + if (newline < 0) continue; + if (newline !== buffer.length - 1) throw new Error('activity worker request framing is invalid'); + clearTimeout(timer); + const request = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, newline))); + buffer = Buffer.alloc(0); + let response; + try { + activity ??= await activityFactory(); + response = await handleWindowsLifecycleAuthorityWorkerRequest({ access: 'activity', activity, request }); + } catch (error) { response = workerInitializationFailure(request, error, 'activity'); } + const wire = `${JSON.stringify(response)}\n`; + if (Buffer.byteLength(wire) > ACTIVITY_MAX_RESULT_WIRE_BYTES) throw new Error('activity worker response exceeded its bound'); + output.write(wire); + count += 1; + if (maxRequests > 0 && count >= maxRequests) return; + arm(); + } + if (buffer.length) throw new Error('activity worker request was interrupted'); + } finally { clearTimeout(timer); activity?.close?.(); } +} + export async function runWindowsLifecycleAuthorityWorker({ argv = process.argv.slice(2), input = process.stdin, @@ -134,6 +168,12 @@ export async function runWindowsLifecycleAuthorityWorker({ acceptanceHandler = null, } = {}) { const options = parseWindowsLifecycleAuthorityWorkerArguments(argv); + if (options.activityStream) { + const selectedActivityFactory = activityFactory ?? (await import('../app/environment-activity-host.js')).createProtectedEnvironmentActivity; + return runWindowsActivityAuthorityStream({ input, output, activityFactory: () => selectedActivityFactory({ + stateDirectory: options.stateDirectory, authorityDirectory: options.authorityDirectory, platform: 'win32', + }) }); + } const requestLimit = options.access === 'activity' ? ACTIVITY_MAX_REQUEST_WIRE_BYTES : options.access === 'configuration' diff --git a/src/github/issue-status-reporter.js b/src/github/issue-status-reporter.js index 3fb963cd7..790581d17 100644 --- a/src/github/issue-status-reporter.js +++ b/src/github/issue-status-reporter.js @@ -53,13 +53,15 @@ export class IssueStatusReporter { #secrets; #inventoryRefProvider; #now; + #faults; constructor({ client, stateStore, queueRepository, progressIntervalMs = 300_000, - maxCommentBytes = 48_000, secretValues = [], inventoryRefProvider = null, now = () => Date.now() }) { + maxCommentBytes = 48_000, secretValues = [], inventoryRefProvider = null, now = () => Date.now(), faultInjector = null }) { if (!client || typeof client.request !== 'function' || !stateStore || typeof stateStore.get !== 'function' || typeof stateStore.set !== 'function') throw new TypeError('status reporter requires its client and state store'); if (typeof queueRepository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(queueRepository)) throw new ProtocolError('status queue repository is invalid'); if (!Number.isSafeInteger(maxCommentBytes) || maxCommentBytes < 4096) throw new ProtocolError('status comment budget is invalid'); if (!Number.isSafeInteger(progressIntervalMs) || progressIntervalMs < 1) throw new ProtocolError('status interval is invalid'); + if (faultInjector != null && typeof faultInjector.throwIfTriggered !== 'function') throw new TypeError('status fault injection capability is invalid'); this.#client = client; this.#stateStore = stateStore; this.#queueRepository = queueRepository; @@ -69,6 +71,7 @@ export class IssueStatusReporter { this.#secrets = secretValues; this.#inventoryRefProvider = typeof inventoryRefProvider === 'function' ? inventoryRefProvider : null; this.#now = now; + this.#faults = faultInjector; } #key(subject) { return `status.${this.#queueRepository}#${subject.issueNumber}.${subject.runId}`; } @@ -168,6 +171,7 @@ export class IssueStatusReporter { if (!await taskLeaseAllowsEffect()) return { published: false, commentId: record.commentId ?? null, reason: 'lease-lost' }; if (Number.isFinite(record.delivery?.retryAt) && this.#now() < record.delivery.retryAt) return this.#defer(key, record, 'server-pacing'); try { + this.#faults?.throwIfTriggered('status.before-delivery', { operation: record.pending.terminal ? 'terminal' : 'progress' }); if (!record.commentId) { let observedActorId = null; if (!record.creation) { @@ -213,6 +217,7 @@ export class IssueStatusReporter { creation.attemptedAt = this.#now(); await this.#stateStore.set(key, record); const response = await this.#client.request('POST', `${this.#repositoryPath}/issues/${record.subject.issueNumber}/comments`, { body: { body: creation.body }, critical: record.pending.terminal }); + this.#faults?.throwIfTriggered('status.after-effect', { operation: creation.projection.terminal ? 'terminal' : 'progress' }); const confirmed = await this.#confirm(key, record, creation.projection, response.data?.id); if (!record.pending || confirmed.leaseLost) return confirmed; } @@ -220,6 +225,7 @@ export class IssueStatusReporter { if (!await taskLeaseAllowsEffect()) return this.#defer(key, record, 'lease-lost'); const projection = record.pending; const response = await this.#client.request('PATCH', `${this.#repositoryPath}/issues/comments/${record.commentId}`, { body: { body: projection.body }, critical: projection.terminal }); + this.#faults?.throwIfTriggered('status.after-effect', { operation: projection.terminal ? 'terminal' : 'progress' }); if (response.data?.id !== record.commentId) throw new ProtocolError('GitHub status update returned a different comment ID'); return await this.#confirm(key, record, projection, record.commentId); } catch (error) { diff --git a/src/guest/resource-agent.mjs b/src/guest/resource-agent.mjs index 5d2dbd323..c6dddda32 100644 --- a/src/guest/resource-agent.mjs +++ b/src/guest/resource-agent.mjs @@ -15,13 +15,28 @@ async function removeDirectory(target) { return { state: 'verified-absent', removed: true }; } +async function selectDirectory(legacy, compact) { + const present = []; + for (const target of [legacy, compact]) { + if (!(await exists(target))) { present.push(false); continue; } + const info = await lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('owned resource is not a real directory'); + present.push(true); + } + if (present.every(Boolean)) throw new Error('both resource layouts are present'); + return { selected: present[0] ? 'legacy' : 'compact' }; +} + const [, , action, target, ...rest] = process.argv; -if (rest.length !== 0 || action !== 'remove-directory' || typeof target !== 'string' || target.length === 0) { +const valid = typeof target === 'string' && target.length > 0 && ( + (action === 'remove-directory' && rest.length === 0) + || (action === 'select-directory' && rest.length === 1 && typeof rest[0] === 'string' && rest[0].length > 0)); +if (!valid) { process.stderr.write('resource-agent received an invalid request\n'); process.exitCode = 2; } else { try { - process.stdout.write(`${JSON.stringify(await removeDirectory(target))}\n`); + process.stdout.write(`${JSON.stringify(action === 'select-directory' ? await selectDirectory(target, rest[0]) : await removeDirectory(target))}\n`); } catch (error) { process.stderr.write(`${error?.name ?? 'Error'}: ${String(error?.message ?? error).replace(/[\r\n]+/gu, ' ').slice(0, 1024)}\n`); process.exitCode = 1; diff --git a/src/guest/source-pack-agent.mjs b/src/guest/source-pack-agent.mjs new file mode 100644 index 000000000..75ca8c657 --- /dev/null +++ b/src/guest/source-pack-agent.mjs @@ -0,0 +1,109 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; + +const PART_NAME = /^part-[0-9]{1,6}-[0-9]{1,6}$/u; +const sha256 = value => createHash('sha256').update(value).digest('hex'); +function exactDigest(value) { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) throw new Error('source pack digest is invalid'); + return value; +} +function exactObject(value, keys) { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw new Error('source pack object is invalid'); + return value; +} + +async function unpackSource(file, expectedDigest) { + const expected = exactDigest(expectedDigest); + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024) throw new Error('source pack is not a bounded regular file'); + const compressed = await readFile(file); + if (sha256(compressed) !== expected) throw new Error('source pack digest mismatch'); + const value = exactObject(JSON.parse(gunzipSync(compressed, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')), ['protocol', 'parts']); + if (value.protocol !== 'devbridge/source-part-pack-v1' || !Array.isArray(value.parts) || value.parts.length < 1 || value.parts.length > 2048) throw new Error('source pack protocol or cardinality is invalid'); + const seen = new Set(); + let total = 0; + const parts = value.parts.map(raw => { + const part = exactObject(raw, ['name', 'size', 'digest', 'data']); + if (typeof part.name !== 'string' || !PART_NAME.test(part.name) || seen.has(part.name)) throw new Error('packed source part name is invalid or duplicated'); + seen.add(part.name); + if (!Number.isSafeInteger(part.size) || part.size < 0 || part.size > 2 * 1024 * 1024 || typeof part.data !== 'string') throw new Error('packed source part bounds are invalid'); + const bytes = Buffer.from(part.data, 'base64'); + total += bytes.length; + if (bytes.toString('base64') !== part.data || bytes.length !== part.size || total > 2 * 1024 * 1024 || sha256(bytes) !== exactDigest(part.digest)) throw new Error('packed source part content is invalid'); + return { name: part.name, bytes }; + }); + const parent = path.dirname(file); + const parentInfo = await lstat(parent); + if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) throw new Error('source pack directory is invalid'); + const root = await realpath(parent); + for (const part of parts) { + const destination = path.join(root, part.name); + try { const current = await lstat(destination); if (!current.isFile() || current.isSymbolicLink()) throw new Error('packed source destination is unsafe'); } + catch (error) { if (error?.code !== 'ENOENT') throw error; } + const temporary = path.join(root, `.${part.name}-${randomUUID()}.tmp`); + try { + await writeFile(temporary, part.bytes, { mode: 0o600, flag: 'wx' }); + await rename(temporary, destination); + } finally { await rm(temporary, { force: true }); } + } + await rm(file); + process.stdout.write(`${JSON.stringify({ ready: true, digest: expected, parts: parts.length })}\n`); +} + +async function neededParts(file, expectedDigest) { + const expected = exactDigest(expectedDigest); + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink() || info.size > 24 * 1024 * 1024) throw new Error('source manifest is not a bounded regular file'); + const bytes = await readFile(file); + if (bytes.length > 24 * 1024 * 1024 || sha256(bytes) !== expected) throw new Error('source manifest bytes changed'); + const manifest = JSON.parse(bytes.toString('utf8')); + if (manifest.protocol !== 'devbridge/file-tree-v1' || !Array.isArray(manifest.entries) || manifest.entries.length > 100_000) throw new Error('source manifest is invalid'); + const parent = path.dirname(file); + const parentInfo = await lstat(parent); + if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) throw new Error('source part directory is invalid'); + const root = await realpath(parent); + const seen = new Set(), needed = []; + for (const entry of manifest.entries) { + if (entry.type !== 'file') continue; + if (!Array.isArray(entry.parts)) throw new Error('source manifest parts are invalid'); + for (const part of entry.parts) { + if (!PART_NAME.test(part.name) || seen.has(part.name) || !Number.isSafeInteger(part.size) || part.size < 0 || part.size > 32 * 1024 * 1024) throw new Error('source manifest part is invalid'); + exactDigest(part.digest); + seen.add(part.name); + let matches = false; + try { + const selected = path.join(root, part.name); + const current = await lstat(selected); + if (current.isFile() && !current.isSymbolicLink() && current.size === part.size) { + const handle = await open(selected, 'r'); + try { + const content = Buffer.alloc(part.size); + let offset = 0; + while (offset < content.length) { + const result = await handle.read(content, offset, content.length - offset, offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + matches = offset === part.size && (await handle.stat()).size === part.size && sha256(content) === part.digest; + } finally { await handle.close(); } + } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (!matches) needed.push(part.name); + } + } + process.stdout.write(`${JSON.stringify({ protocol: 'devbridge/source-parts-needed-v1', manifestDigest: expected, needed })}\n`); +} + +try { + const args = process.argv.slice(2); + if (args.length === 3 && args[0] === 'needed') await neededParts(args[1], args[2]); + else { + if (args.length !== 2) throw new Error('source unpacking requires pack path and digest'); + await unpackSource(...args); + } +} catch (error) { + process.stderr.write(`${error.name}: ${error.message}\n`); + process.exitCode = 1; +} diff --git a/src/run/controller-plan-executor.js b/src/run/controller-plan-executor.js index 4fc6e63bc..c4681e944 100644 --- a/src/run/controller-plan-executor.js +++ b/src/run/controller-plan-executor.js @@ -6,6 +6,7 @@ import { isWithin } from '../security/workspace-policy.js'; import { ManagedScratchTransaction } from '../runtime/managed-scratch.js'; import { guardActiveTaskLease } from './lease-execution-context.js'; import { captureFailureDiagnostics } from './failure-diagnostics.js'; +import { controllerPlanDigest } from './controller-plan.js'; const ASSERTION_MARKER_DIAGNOSTIC_CHARACTERS = 160; @@ -344,8 +345,10 @@ export class ControllerPlanExecutor { } async execute({ plan, state, workspace, persist, onLiveness = null }) { + const digest = controllerPlanDigest(plan); state.controllerPlan ??= { protocol: plan.protocol, + planDigest: digest, phase: 'materializing', files: [], operations: [], @@ -356,6 +359,12 @@ export class ControllerPlanExecutor { }; state.controllerPlan.scratchLedger ??= []; const planState = state.controllerPlan; + const previousDigest = planState.planDigest ?? state.prior?.receipt?.controllerPlanSha256; + if ((previousDigest != null && previousDigest !== digest) + || (planState.operations.some(record => record.state === 'observed') && previousDigest !== digest)) { + throw new PolicyError('retained controller work is not bound to the accepted plan'); + } + planState.planDigest = digest; const results = new Map(); let diagnosticOperation = null; let diagnosticResult = null; @@ -429,6 +438,20 @@ export class ControllerPlanExecutor { record = { id: operation.id, operation: operation.operation, state: 'planned', attempts: 0 }; planState.operations.push(record); } + if (record.state === 'observed') { + const retained = record.result; + if (record.operation !== operation.operation || retained?.id !== operation.id || retained?.operation !== operation.operation + || !Number.isSafeInteger(record.attempts) || record.attempts < 1 + || (retained.exitCode !== null && !Number.isInteger(retained.exitCode)) + || ['timedOut', 'aborted', 'outputTruncated'].some(key => typeof retained[key] !== 'boolean') + || typeof retained.stdout !== 'string' || typeof retained.stderr !== 'string') { + throw new PolicyError('retained controller operation evidence is invalid'); + } + diagnosticResult = retained; + results.set(operation.id, retained); + if (retained.timedOut) throw new PolicyError(`deterministic operation ${operation.id} timed out`); + continue; + } record.state = 'attempted'; record.attempts = (record.attempts ?? 0) + 1; record.attemptedAt = new Date().toISOString(); diff --git a/src/run/liveness-projecting-plan-executor.js b/src/run/liveness-projecting-plan-executor.js index 7c9962031..e559dbf14 100644 --- a/src/run/liveness-projecting-plan-executor.js +++ b/src/run/liveness-projecting-plan-executor.js @@ -30,6 +30,9 @@ function capsule(state) { function summary(liveness) { const label = `${liveness.operationId} (${liveness.operation})`; + if (/^(workspace-|source-)/u.test(liveness.activity ?? '')) { + return `Preparing the workspace for ${label}: ${liveness.activity}. The registered operation has not started.`; + } if (liveness.processAlive === false) return `Deterministic operation ${label} finished; assertions and verification are pending.`; const elapsed = liveness.elapsedMs == null ? 'unknown' : `${Math.ceil(liveness.elapsedMs / 1000)}s`; return `Deterministic operation ${label} is active; elapsed ${elapsed}.`; diff --git a/src/runtime/command-channel.js b/src/runtime/command-channel.js new file mode 100644 index 000000000..059b11896 --- /dev/null +++ b/src/runtime/command-channel.js @@ -0,0 +1,105 @@ +import { spawn } from 'node:child_process'; + +function limit(value, name, ceiling) { + if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) throw new TypeError(`${name} is invalid`); + return value; +} + +// One locally selected process, one request at a time. Reconnection and effect +// reconciliation belong to the caller; this transport never replays a request. +export function openJsonCommandChannel({ executable, arguments: args = [], inputLimit = 64 * 1024, + outputLimit = 8 * 1024 * 1024, timeoutMs = 120_000, idleMs = 60_000, signal = null, +} = {}, { spawnProcess = spawn } = {}) { + if (typeof executable !== 'string' || !executable || executable.includes('\0') + || !Array.isArray(args) || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new TypeError('command channel invocation is invalid'); + limit(inputLimit, 'command channel input bound', 32 * 1024 * 1024); + limit(outputLimit, 'command channel output bound', 32 * 1024 * 1024); + limit(timeoutMs, 'command channel timeout', 300_000); + if (idleMs !== 0) limit(idleMs, 'command channel idle timeout', 300_000); + if (signal?.aborted) throw signal.reason ?? new Error('command channel was cancelled'); + const child = spawnProcess(executable, args, { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let pending = null, closed = null, idle = null, stderr = Buffer.alloc(0); + const evidence = { exitCode: null, signal: null, timedOut: false, aborted: false, outputTruncated: false }; + const close = (code = 'CLOSED', message = 'command channel closed') => { + if (closed) return; + closed = Object.assign(new Error(message), { code, native: { ...evidence, stderr: stderr.toString('utf8') } }); + clearTimeout(idle); + signal?.removeEventListener('abort', onOwnerAbort); + if (pending) { + const current = pending; pending = null; + clearTimeout(current.timer); + current.signal?.removeEventListener('abort', current.onAbort); + current.reject(closed); + } + child.stdin.destroy(); + if (child.exitCode == null && child.signalCode == null) child.kill(); + }; + const onOwnerAbort = () => { evidence.aborted = true; close('ABORTED', 'command channel was cancelled'); }; + const armIdle = () => { + clearTimeout(idle); + if (idleMs === 0) return; + idle = setTimeout(() => close('IDLE', 'command channel idle lifetime ended'), idleMs); + idle.unref?.(); + }; + signal?.addEventListener('abort', onOwnerAbort, { once: true }); + child.once('error', error => close('SPAWN_FAILED', `command channel could not start: ${error.message}`)); + child.stdin.on('error', () => close('DISCONNECTED', 'command channel input disconnected')); + child.stderr.on('data', chunk => { stderr = Buffer.from(Buffer.concat([stderr, Buffer.from(chunk)]).subarray(-8192)); }); + child.stderr.on('error', () => close('DISCONNECTED', 'command channel error stream disconnected')); + child.stdout.on('error', () => close('DISCONNECTED', 'command channel output disconnected')); + child.once('close', (code, childSignal) => { + evidence.exitCode = code; evidence.signal = childSignal; + close('DISCONNECTED', 'command channel process ended'); + }); + child.stdout.on('data', chunk => { + if (closed) return; + if (!pending) return close('PROTOCOL', 'command channel received an unsolicited response'); + const current = pending; + current.bytes += chunk.length; + if (current.bytes > outputLimit) { + evidence.outputTruncated = true; + return close('OUTPUT_LIMIT', 'command channel response exceeded its bound'); + } + current.chunks.push(Buffer.from(chunk)); + if (!chunk.includes(10)) return; + const bytes = Buffer.concat(current.chunks); + let value; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const end = text.indexOf('\n'); + if (end < 0 || text.slice(end + 1).trim()) throw new Error('extra response'); + value = JSON.parse(text.slice(0, end)); + } catch { return close('PROTOCOL', 'command channel response framing is invalid'); } + pending = null; + clearTimeout(current.timer); + current.signal?.removeEventListener('abort', current.onAbort); + armIdle(); + current.resolve(value); + }); + armIdle(); + return Object.freeze({ + get closed() { return closed != null; }, + exchange(value, { signal: operationSignal = null } = {}) { + if (closed) return Promise.reject(closed); + if (pending) return Promise.reject(new Error('command channel already has an active request')); + if (operationSignal?.aborted) return Promise.reject(operationSignal.reason ?? new Error('command channel request was cancelled')); + let wire; + try { + const serialized = JSON.stringify(value); + if (serialized == null) throw new TypeError('command channel request is not JSON'); + wire = Buffer.from(`${serialized}\n`); + if (wire.length > inputLimit) throw new Error('command channel request exceeded its bound'); + } catch (error) { return Promise.reject(error); } + clearTimeout(idle); + return new Promise((resolve, reject) => { + const onAbort = () => { evidence.aborted = true; close('ABORTED', 'command channel request was cancelled'); }; + const timer = setTimeout(() => { evidence.timedOut = true; close('TIMEOUT', 'command channel response timed out'); }, timeoutMs); + timer.unref?.(); + pending = { resolve, reject, chunks: [], bytes: 0, timer, signal: operationSignal, onAbort }; + operationSignal?.addEventListener('abort', onAbort, { once: true }); + child.stdin.write(wire, error => { if (error) close('DISCONNECTED', 'command channel request could not be delivered'); }); + }); + }, + close: () => close(), + }); +} diff --git a/src/runtime/environment-bootstrap.js b/src/runtime/environment-bootstrap.js index d63df5d8a..a08bbb272 100644 --- a/src/runtime/environment-bootstrap.js +++ b/src/runtime/environment-bootstrap.js @@ -244,7 +244,7 @@ export class EnvironmentBootstrap { return expectedState(basis, plan); } - async #send(target, action, expected, { request = null } = {}) { + async #send(target, action, expected, { request = null, probe = false } = {}) { const identity = requestId(request); const frame = { protocol: ENVIRONMENT_BOOTSTRAP_PROTOCOL, @@ -260,7 +260,15 @@ export class EnvironmentBootstrap { networkRequired: expected.plan.networkRequired, }, }; - const response = await this.#exchange(target, structuredClone(frame)); + let response; + try { + response = await this.#exchange(target, structuredClone(frame), probe ? { signal: AbortSignal.timeout(30_000) } : {}); + } catch (error) { + // Only an unavailable read may enter preparation. A received response + // still has to pass identity/schema validation outside this catch. + if (!probe || error instanceof TypeError) throw error; + return null; + } return normalizeObservation(response, frame); } @@ -274,7 +282,13 @@ export class EnvironmentBootstrap { async ensure(rawTarget) { const target = targetId(rawTarget); const expected = await this.#expected(target); + const existing = await this.#send(target, 'inspect', expected, { probe: true }); + if (existing != null) { + const current = statusFrom(existing, expected); + if (current.ready) return current; + } await this.#prepare(target, structuredClone(expected.basis)); + if ((await this.#expected(target)).generation !== expected.generation) throw new Error('bootstrap basis or policy changed during preparation'); let observation = await this.#send(target, 'inspect', expected); let status = statusFrom(observation, expected); if (status.ready) return status; diff --git a/src/runtime/environment-construction.js b/src/runtime/environment-construction.js index 0e24601aa..b779f3db8 100644 --- a/src/runtime/environment-construction.js +++ b/src/runtime/environment-construction.js @@ -1,5 +1,6 @@ import { logicalEnvironmentIdentity, normalizeEnvironmentDeclaration } from './environment-declaration.js'; import { environmentObservationCondition, normalizeEnvironmentObservation } from './environment-observation.js'; +import { requireEnvironmentOperationSubject } from './environment-operation-subject.js'; export const ENVIRONMENT_CONSTRUCTION_PROTOCOL = 'devbridge/environment-construction-v1'; export const ENVIRONMENT_CONSTRUCTION_STAGES = Object.freeze([ @@ -68,6 +69,7 @@ function requestFor(input, declaration) { operationId: input.operationId, declarationRevision: input.declarationRevision, declaration, + ...(input.operationSubject == null ? {} : { operationSubject: input.operationSubject }), }); } @@ -129,12 +131,13 @@ export class EnvironmentConstructionPipeline { async run(rawInput) { const input = requireObject(rawInput, 'environment construction request'); - onlyKeys(input, new Set(['environmentIdentity', 'operationId', 'declarationRevision', 'declaration']), 'environment construction request'); + onlyKeys(input, new Set(['environmentIdentity', 'operationId', 'declarationRevision', 'declaration', 'operationSubject']), 'environment construction request'); const declaration = normalizeEnvironmentDeclaration(input.declaration); const normalized = { environmentIdentity: safeId(input.environmentIdentity, 'environment construction environmentIdentity'), operationId: safeId(input.operationId, 'environment construction operationId'), declarationRevision: positive(input.declarationRevision, 'environment construction declarationRevision'), + ...(input.operationSubject == null ? {} : { operationSubject: requireEnvironmentOperationSubject(input) }), }; if (logicalEnvironmentIdentity(declaration.profile) !== normalized.environmentIdentity) throw new Error('environment construction declaration belongs to another logical environment'); const request = requestFor(normalized, declaration); diff --git a/src/runtime/environment-create.js b/src/runtime/environment-create.js index 9abb3f70a..11c68cfa8 100644 --- a/src/runtime/environment-create.js +++ b/src/runtime/environment-create.js @@ -1,3 +1,4 @@ +import { normalizeEnvironmentOperationSubject } from './environment-operation-subject.js'; import { environmentObservationCondition, normalizeEnvironmentObservation } from './environment-observation.js'; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:+-]{0,159}$/u; @@ -85,7 +86,7 @@ export class EnvironmentCreate { } if (lastStage(record) === 'fenced-attempt') { - const result = await this.#construction.run({ environmentIdentity: identity, operationId: record.operationId, declarationRevision: declaration.revision, declaration: declaration.declaration }); + const result = await this.#construction.run({ environmentIdentity: identity, operationId: record.operationId, declarationRevision: declaration.revision, declaration: declaration.declaration, operationSubject: normalizeEnvironmentOperationSubject({ environmentIdentity: identity, operationId: record.operationId, operation: 'create', declarationRevision: declaration.revision, previousImplementationGeneration: null, imageIdentity: declaration.declaration.image.identity, imageGeneration: declaration.declaration.image.generation }) }); const after = await this.#observe(declaration); if (after.implementationGeneration !== result.implementationGeneration) throw new Error('environment create post-observation generation changed'); record = await this.#journal.advance(identity, record.operationId, { stage: 'post-observation', outcome: 'observed', implementationGeneration: result.implementationGeneration, observation: after }); diff --git a/src/runtime/environment-foundation.js b/src/runtime/environment-foundation.js index df3147e41..a9c03270e 100644 --- a/src/runtime/environment-foundation.js +++ b/src/runtime/environment-foundation.js @@ -228,7 +228,8 @@ export class EnvironmentFoundation { } async ensureEnvironment(input) { return this.#lifecycle.ensure(input); } - async listEnvironments() { return this.#lifecycle.list(); } + async listEnvironments(selection = {}) { return this.#lifecycle.list(selection); } + async readEnvironmentRecords(selection = {}) { return this.#lifecycle.records(selection); } async observeEnvironment(identity) { return this.#lifecycle.observe(requireEnvironmentIdentity(identity)); } async startEnvironment(identity) { return this.#lifecycle.start(requireEnvironmentIdentity(identity)); } async stopEnvironment(identity, options = {}) { return this.#lifecycle.stop(requireEnvironmentIdentity(identity), options); } diff --git a/src/runtime/environment-lifecycle-authority.js b/src/runtime/environment-lifecycle-authority.js index 3483a5661..e778fe489 100644 --- a/src/runtime/environment-lifecycle-authority.js +++ b/src/runtime/environment-lifecycle-authority.js @@ -5,7 +5,7 @@ const RESULT_PROTOCOL = 'devbridge/environment-lifecycle-authority-result-v1'; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:+-]{0,159}$/u; const MAX_SUBJECT_BYTES = 512; const MAX_ENVELOPE_BYTES = 16 * 1024; -const READ_AUTHORITY_OPERATIONS = new Set(['inspect', 'list', 'status', 'plan', 'setup-reentry']); +const READ_AUTHORITY_OPERATIONS = new Set(['inspect', 'list', 'status', 'plan', 'setup-reentry', 'diagnostics-v1']); const MUTATION_AUTHORITY_OPERATIONS = new Set(['run', 'resume']); const AUTHORITY_OPERATIONS = new Set([...READ_AUTHORITY_OPERATIONS, ...MUTATION_AUTHORITY_OPERATIONS]); const LIFECYCLE_OPERATIONS = new Set(['create', 'repair', 'rebuild', 'reset', 'recreate']); @@ -57,7 +57,7 @@ function normalizePayload(operation, raw) { onlyKeys(value, new Set(), 'lifecycle authority payload'); return {}; } - if (operation === 'status') { + if (operation === 'status' || operation === 'diagnostics-v1') { onlyKeys(value, new Set(['identity']), 'lifecycle authority payload'); return { identity: requireSafeId(value.identity, 'environment identity') }; } @@ -167,6 +167,10 @@ async function invokeOperator(operator, request) { case 'inspect': return operator.inspect(); case 'list': return operator.list(); case 'status': return operator.status(p.identity); + case 'diagnostics-v1': { + if (typeof operator.diagnostics !== 'function') throw Object.assign(new Error('lifecycle diagnostics are unsupported'), { code: 'UNSUPPORTED_CAPABILITY' }); + return operator.diagnostics(p.identity); + } case 'plan': return operator.plan(p.operation, p.identity); case 'setup-reentry': return operator.setupReentry(p.identity); case 'run': return operator.run(p.operation, p.identity, { approval: p.approval }); @@ -204,12 +208,14 @@ function createAuthorityHandler({ operator, allowedOperations }) { const response = Object.freeze({ protocol: RESULT_PROTOCOL, requestId: request.requestId, ok: true, value: result }); if (encodedBytes(response) > MAX_ENVELOPE_BYTES) throw new TypeError('lifecycle authority result is too large'); return response; - } catch { + } catch (error) { return Object.freeze({ protocol: RESULT_PROTOCOL, requestId: request.requestId, ok: false, - error: Object.freeze({ code: 'OPERATION_FAILED', message: 'environment lifecycle authority operation failed' }), + error: Object.freeze(error?.code === 'UNSUPPORTED_CAPABILITY' + ? { code: 'UNSUPPORTED_CAPABILITY', message: 'environment lifecycle capability is unsupported' } + : { code: 'OPERATION_FAILED', message: 'environment lifecycle authority operation failed' }), }); } }; @@ -250,7 +256,7 @@ export class LifecycleAuthorityClient { const result = normalizeLifecycleAuthorityResult(raw, request.requestId); if (!result.ok) { const error = new Error(result.error.message); - error.code = result.error.code; + error.code = operation === 'diagnostics-v1' && result.error.code === 'INVALID_REQUEST' ? 'UNSUPPORTED_CAPABILITY' : result.error.code; throw error; } return structuredClone(result.value); @@ -259,6 +265,7 @@ export class LifecycleAuthorityClient { inspect() { return this.#request('inspect'); } list() { return this.#request('list'); } status(identity) { return this.#request('status', { identity }); } + diagnostics(identity) { return this.#request('diagnostics-v1', { identity }); } plan(operation, identity) { return this.#request('plan', { operation, identity }); } setupReentry(identity = null) { return this.#request('setup-reentry', { identity }); } run(operation, identity, { approval = null } = {}) { return this.#request('run', { operation, identity, approval }); } diff --git a/src/runtime/environment-lifecycle-diagnostics.js b/src/runtime/environment-lifecycle-diagnostics.js new file mode 100644 index 000000000..3cfafc195 --- /dev/null +++ b/src/runtime/environment-lifecycle-diagnostics.js @@ -0,0 +1,49 @@ +import { sanitizeDiagnosticText } from '../security/diagnostic-redaction.js'; + +export const ENVIRONMENT_LIFECYCLE_DIAGNOSTICS_PROTOCOL = 'devbridge/environment-lifecycle-diagnostics-v1'; +const ID = /^[A-Za-z0-9][A-Za-z0-9_.:+-]{0,159}$/u; +function identity(value) { + if (typeof value !== 'string' || !ID.test(value)) throw new TypeError('diagnostic environment identity is invalid'); + return value; +} +function text(value, limit = 2048) { + let selected = sanitizeDiagnosticText(String(value ?? '')) + .replace(/\b(?:powershell(?:\.exe)?|virsh|Remove-VM|Remove-VMSwitch|rm\s+-rf)\b/giu, '[NATIVE TOOL]') + .slice(0, limit); + while (Buffer.byteLength(selected, 'utf8') > limit) selected = selected.slice(0, -1); + return selected; +} + +export function createEnvironmentLifecycleDiagnostics({ port, now = () => new Date().toISOString() }) { + if (!port || typeof port.load !== 'function' || typeof port.save !== 'function') throw new TypeError('lifecycle diagnostic persistence is required'); + return Object.freeze({ + async record({ environmentIdentity, declarationRevision, operation, journal, error }) { + const evidence = error?.evidence ?? null; + const native = evidence == null ? null : { + exitCode: Number.isInteger(evidence.exitCode) ? evidence.exitCode : null, + timedOut: evidence.timedOut === true, aborted: evidence.aborted === true, + outputTruncated: evidence.outputTruncated === true || Buffer.byteLength(String(evidence.stdout ?? '')) > 2048 || Buffer.byteLength(String(evidence.stderr ?? '')) > 2048, + stdout: text(evidence.stdout), stderr: text(evidence.stderr), + category: text(evidence.category, 128), nativeCode: text(evidence.nativeCode, 128), + }; + const record = { + protocol: ENVIRONMENT_LIFECYCLE_DIAGNOSTICS_PROTOCOL, available: true, + environmentIdentity: identity(environmentIdentity), declarationRevision, operation, + operationId: journal?.operationId ?? null, + stage: journal?.entries?.at(-1)?.stage ?? 'admission', + implementationGeneration: journal?.entries?.at(-1)?.implementationGeneration ?? null, + observedAt: now(), + failure: { name: text(error?.name, 128), code: text(error?.code, 128), message: text(error?.message), native, nativeEvidenceAvailable: native != null }, + guestEvidence: 'unavailable', + }; + await port.save(record.environmentIdentity, record); + return record; + }, + async inspect(environmentIdentity) { + const selected = identity(environmentIdentity); + const record = await port.load(selected); + if (record != null && (record.protocol !== ENVIRONMENT_LIFECYCLE_DIAGNOSTICS_PROTOCOL || record.environmentIdentity !== selected)) throw new Error('lifecycle diagnostic subject changed'); + return structuredClone(record ?? { protocol: ENVIRONMENT_LIFECYCLE_DIAGNOSTICS_PROTOCOL, environmentIdentity: selected, available: false, failure: null, guestEvidence: 'unavailable' }); + }, + }); +} diff --git a/src/runtime/environment-operation-subject.js b/src/runtime/environment-operation-subject.js new file mode 100644 index 000000000..75d6e6480 --- /dev/null +++ b/src/runtime/environment-operation-subject.js @@ -0,0 +1,25 @@ +const ID = /^[A-Za-z0-9][A-Za-z0-9_.:+-]{0,159}$/u; + +export function normalizeEnvironmentOperationSubject(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError('environment operation subject is required'); + const keys = ['environmentIdentity', 'operationId', 'operation', 'declarationRevision', 'previousImplementationGeneration', 'imageIdentity', 'imageGeneration']; + if (Object.keys(raw).some((key) => !keys.includes(key))) throw new TypeError('environment operation subject contains an unknown field'); + for (const key of ['environmentIdentity', 'operationId', 'imageIdentity', 'imageGeneration']) { + if (typeof raw[key] !== 'string' || !ID.test(raw[key])) throw new TypeError(`environment operation subject ${key} is invalid`); + } + if (!['create', 'rebuild', 'reset', 'recreate'].includes(raw.operation)) throw new TypeError('environment operation subject operation is invalid'); + if (!Number.isSafeInteger(raw.declarationRevision) || raw.declarationRevision < 1) throw new TypeError('environment operation subject declaration revision is invalid'); + if (raw.previousImplementationGeneration != null && (typeof raw.previousImplementationGeneration !== 'string' || !ID.test(raw.previousImplementationGeneration))) throw new TypeError('environment operation subject previous generation is invalid'); + if (raw.operation !== 'create' && raw.previousImplementationGeneration == null) throw new TypeError('environment operation subject previous generation is required'); + return Object.freeze(Object.fromEntries(keys.map((key) => [key, raw[key] ?? null]))); +} + +export function requireEnvironmentOperationSubject(request, operation = null) { + const subject = normalizeEnvironmentOperationSubject(request.operationSubject); + if (subject.environmentIdentity !== request.environmentIdentity || subject.operationId !== request.operationId + || subject.declarationRevision !== request.declarationRevision || subject.imageIdentity !== request.declaration?.image?.identity + || subject.imageGeneration !== request.declaration?.image?.generation || (operation != null && subject.operation !== operation)) { + throw new Error('environment operation subject does not match request authority'); + } + return subject; +} diff --git a/src/runtime/environment-rebuild.js b/src/runtime/environment-rebuild.js index 830d133ed..204ce575d 100644 --- a/src/runtime/environment-rebuild.js +++ b/src/runtime/environment-rebuild.js @@ -1,5 +1,6 @@ import { diagnoseEnvironment } from './environment-diagnosis.js'; import { normalizeEnvironmentObservation } from './environment-observation.js'; +import { normalizeEnvironmentOperationSubject } from './environment-operation-subject.js'; export const ENVIRONMENT_REBUILD_IMPACT_PROTOCOL = 'devbridge/environment-rebuild-impact-v1'; @@ -156,6 +157,7 @@ export class EnvironmentRebuild { operationId: record.operationId, declarationRevision: declaration.revision, declaration: declaration.declaration, + operationSubject: normalizeEnvironmentOperationSubject({ environmentIdentity: identity, operationId: record.operationId, operation: 'rebuild', declarationRevision: declaration.revision, previousImplementationGeneration: previousGeneration, imageIdentity: declaration.declaration.image.identity, imageGeneration: declaration.declaration.image.generation }), }); if (result.implementationGeneration === previousGeneration) throw new Error('environment rebuild did not create a new implementation generation'); const after = await this.#observe(declaration); diff --git a/src/runtime/environment-recreate.js b/src/runtime/environment-recreate.js index d7c9e9a6d..86078817c 100644 --- a/src/runtime/environment-recreate.js +++ b/src/runtime/environment-recreate.js @@ -1,3 +1,4 @@ +import { normalizeEnvironmentOperationSubject } from './environment-operation-subject.js'; import { createHash } from 'node:crypto'; import { environmentObservationCondition, normalizeEnvironmentObservation } from './environment-observation.js'; @@ -252,6 +253,7 @@ export class EnvironmentRecreate { operationId: record.operationId, declarationRevision: declaration.revision, declaration: declaration.declaration, + operationSubject: normalizeEnvironmentOperationSubject({ environmentIdentity: identity, operationId: record.operationId, operation: 'recreate', declarationRevision: declaration.revision, previousImplementationGeneration: previousGeneration, imageIdentity: declaration.declaration.image.identity, imageGeneration: declaration.declaration.image.generation }), }); if (result.implementationGeneration === previousGeneration) throw new Error('environment recreate did not create a new implementation generation'); const after = await this.#observe(declaration); diff --git a/src/runtime/environment-reset.js b/src/runtime/environment-reset.js index 938575fa0..44136e745 100644 --- a/src/runtime/environment-reset.js +++ b/src/runtime/environment-reset.js @@ -1,3 +1,4 @@ +import { normalizeEnvironmentOperationSubject } from './environment-operation-subject.js'; import { createHash } from 'node:crypto'; import { diagnoseEnvironment } from './environment-diagnosis.js'; import { normalizeEnvironmentObservation } from './environment-observation.js'; @@ -228,6 +229,7 @@ export class EnvironmentReset { operationId: record.operationId, declarationRevision: declaration.revision, declaration: declaration.declaration, + operationSubject: normalizeEnvironmentOperationSubject({ environmentIdentity: identity, operationId: record.operationId, operation: 'reset', declarationRevision: declaration.revision, previousImplementationGeneration: previousGeneration, imageIdentity: declaration.declaration.image.identity, imageGeneration: declaration.declaration.image.generation }), }); if (result.implementationGeneration === previousGeneration) throw new Error('environment reset did not create a new implementation generation'); const after = await this.#observe(declaration); diff --git a/src/runtime/fault-injector.js b/src/runtime/fault-injector.js index c1b756816..8529be2fd 100644 --- a/src/runtime/fault-injector.js +++ b/src/runtime/fault-injector.js @@ -7,6 +7,8 @@ const POINTS = new Set([ 'process.after-exit', 'cleanup.before-remove', 'scratch.cleanup.before-remove', + 'status.before-delivery', + 'status.after-effect', ]); const ACTIONS = new Set(['error', 'crash', 'interrupt', 'timeout', 'truncate-output']); diff --git a/src/runtime/lease-bound-invocation.js b/src/runtime/lease-bound-invocation.js new file mode 100644 index 000000000..29ee1d1e8 --- /dev/null +++ b/src/runtime/lease-bound-invocation.js @@ -0,0 +1,11 @@ +export function bindInvocationToLease(invoke, context) { + if (typeof invoke !== 'function' || typeof context?.assertHeld !== 'function') throw new TypeError('native mutation lease context is invalid'); + return async (request) => { + context.assertHeld(); + const signal = context.signal == null ? request.signal : request.signal == null + ? context.signal : AbortSignal.any([context.signal, request.signal]); + const result = await invoke({ ...request, ...(signal == null ? {} : { signal }) }); + context.assertHeld(); + return result; + }; +} diff --git a/src/runtime/legacy-file-guard.js b/src/runtime/legacy-file-guard.js new file mode 100644 index 000000000..b60c634b6 --- /dev/null +++ b/src/runtime/legacy-file-guard.js @@ -0,0 +1,36 @@ +import { lstat, readFile, rename } from 'node:fs/promises'; + +async function readGuard(file) { + let info; + try { info = await lstat(file); } + catch (error) { if (error?.code === 'ENOENT') return null; throw error; } + if (!info.isFile() || info.isSymbolicLink() || info.size > 128) throw new Error('legacy mutation guard is not an owned token file'); + const token = await readFile(file, 'utf8'); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\n$/u.test(token)) throw new Error('legacy mutation guard token is invalid'); + return { info, token }; +} + +// The installation owner supplies quiescence; neither token age nor process IDs +// are evidence that the previous authority has stopped. +export async function migrateLegacyFileGuard({ guardFile, lease, assertQuiescent }) { + if (typeof guardFile !== 'string' || !lease?.acquire || typeof assertQuiescent !== 'function') throw new TypeError('legacy guard migration contract is incomplete'); + if (await readGuard(guardFile) == null) return Object.freeze({ changed: false }); + await assertQuiescent(); + const held = await lease.acquire({ mode: 'exclusive' }); + if (!held) throw new Error('legacy guard migration requires exclusive ownership'); + try { + held.assertHeld(); + await assertQuiescent(); + const previous = await readGuard(guardFile); + if (previous == null) return Object.freeze({ changed: false }); + const retained = `${guardFile}.retired-${previous.token.trim()}`; + const existing = await readGuard(retained); + if (existing != null) throw new Error('legacy guard retirement already exists while its original is still present'); + await assertQuiescent(); + const current = await readGuard(guardFile); + if (current == null || current.token !== previous.token || current.info.ino !== previous.info.ino || current.info.dev !== previous.info.dev) throw new Error('legacy mutation guard changed during migration'); + held.assertHeld(); + await rename(guardFile, retained); + return Object.freeze({ changed: true }); + } finally { await held.release(); } +} diff --git a/src/runtime/linux-file-lease.js b/src/runtime/linux-file-lease.js index bb4466fa7..da0448d30 100644 --- a/src/runtime/linux-file-lease.js +++ b/src/runtime/linux-file-lease.js @@ -1,20 +1,12 @@ -import { spawn } from 'node:child_process'; import process from 'node:process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { activityLeaseHolderReadyLine } from './activity-lease-protocol.js'; +import { createProcessFileLease } from './process-file-lease.js'; const EXECUTABLE = '/usr/bin/flock'; const HOLDER = fileURLToPath(new URL('../entry/activity-lease-holder.mjs', import.meta.url)); const CONFLICT_EXIT_CODE = 75; -const MAX_OUTPUT_BYTES = 4 * 1024; -const DEFAULT_TIMINGS = Object.freeze({ sharedAcquireMs: 2_000, exclusiveAcquireMs: 32_000, releaseMs: 2_000, killMs: 1_000 }); - -function exactObject(value, allowed, name) { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} is invalid`); - for (const key of Object.keys(value)) if (!allowed.has(key)) throw new TypeError(`${name} contains an unknown field`); - return value; -} function subjectPath(value) { if (typeof value !== 'string' || value.length === 0 || /[\0\r\n]/u.test(value) @@ -24,42 +16,6 @@ function subjectPath(value) { return value; } -function timing(value, name) { - if (!Number.isSafeInteger(value) || value < 10 || value > 300_000) throw new TypeError(`Linux file lease ${name} is invalid`); - return value; -} - -function timings(value) { - const selected = exactObject(value ?? DEFAULT_TIMINGS, new Set(Object.keys(DEFAULT_TIMINGS)), 'Linux file lease timings'); - return Object.freeze(Object.fromEntries(Object.keys(DEFAULT_TIMINGS).map((name) => [name, timing(selected[name], name)]))); -} - -function signal(value) { - if (value == null) return null; - if (typeof value !== 'object' || typeof value.aborted !== 'boolean' - || typeof value.addEventListener !== 'function' || typeof value.removeEventListener !== 'function') { - throw new TypeError('Linux file lease cancellation signal is invalid'); - } - return value; -} - -function request(value) { - const selected = exactObject(value, new Set(['mode', 'signal']), 'Linux file lease request'); - if (!['shared', 'exclusive'].includes(selected.mode)) throw new TypeError('Linux file lease mode is invalid'); - return Object.freeze({ mode: selected.mode, signal: signal(selected.signal) }); -} - -function childContract(value) { - if (!value || typeof value !== 'object' - || typeof value.once !== 'function' || typeof value.kill !== 'function' - || !value.stdin || typeof value.stdin.end !== 'function' - || !value.stdout || typeof value.stdout.on !== 'function' - || !value.stderr || typeof value.stderr.on !== 'function') { - throw new Error('Linux file lease process contract is invalid'); - } - return value; -} - function argumentsFor(mode, target) { return Object.freeze([ '--no-fork', @@ -70,149 +26,12 @@ function argumentsFor(mode, target) { ]); } -function spawnOptions() { - return Object.freeze({ - stdio: Object.freeze(['pipe', 'pipe', 'pipe']), - shell: false, - windowsHide: true, - env: Object.freeze({ LANG: 'C', LC_ALL: 'C' }), - }); -} - -function acquireProcess({ mode, target, cancellation, start, policy }) { - if (cancellation?.aborted) return Promise.resolve(null); - return new Promise((resolve, reject) => { - let child; - try { child = childContract(start(EXECUTABLE, argumentsFor(mode, target), spawnOptions())); } - catch { reject(new Error('Linux file lease process could not start')); return; } - - const expected = Buffer.from(activityLeaseHolderReadyLine(), 'utf8'); - let output = Buffer.alloc(0); - let outputBytes = 0; - let ready = false; - let acquiredSettled = false; - let closed = false; - let closeResult = null; - let releaseRequested = false; - let releaseComplete = false; - let fault = null; - let terminating = false; - let hardTimer = null; - let terminalTimer = null; - let closeSettled = false; - let closeResolve; - const closePromise = new Promise((done) => { closeResolve = done; }); - - const finishClose = (value) => { - if (closeSettled) return; - closeSettled = true; - closeResolve(value); - }; - - const removeCancellation = () => cancellation?.removeEventListener('abort', abort); - const settle = (value, error = null) => { - if (acquiredSettled) return; - acquiredSettled = true; - clearTimeout(acquireTimer); - removeCancellation(); - if (error) reject(error); - else resolve(value); - }; - const terminate = () => { - if (closed || terminating) return; - terminating = true; - try { child.stdin.end(); } catch { /* Continue to signal termination. */ } - try { child.kill('SIGTERM'); } catch { /* Escalation remains bounded. */ } - hardTimer = setTimeout(() => { - if (!closed) { - try { child.kill('SIGKILL'); } catch { /* Close observation remains authoritative. */ } - terminalTimer = setTimeout(() => { - if (closed) return; - finishClose(null); - if (!ready) { - if (cancellation?.aborted) settle(null); - else settle(null, fault ?? new Error('Linux file lease process termination was not observable')); - } - }, policy.killMs); - } - }, policy.killMs); - }; - const fail = (message) => { - if (fault == null) fault = new Error(message); - terminate(); - }; - const append = (chunk, source) => { - const next = Buffer.from(chunk); - outputBytes += next.length; - if (outputBytes > MAX_OUTPUT_BYTES) { fail('Linux file lease process output exceeded its bound'); return; } - if (source === 'stderr' || ready) { fail('Linux file lease process emitted unexpected output'); return; } - output = Buffer.concat([output, next]); - const newline = output.indexOf(0x0a); - if (newline < 0) return; - if (newline !== output.length - 1 || !output.equals(expected)) { - fail('Linux file lease process readiness evidence is invalid'); - return; - } - ready = true; - settle(Object.freeze({ - async release() { - if (releaseComplete) return; - if (closed && !releaseRequested) throw new Error('Linux file lease holder ended unexpectedly'); - releaseRequested = true; - if (!closed) { - try { child.stdin.end(); } - catch { fail('Linux file lease holder input could not close'); } - } - const releaseTimer = setTimeout(() => fail('Linux file lease holder did not release in time'), policy.releaseMs); - await closePromise; - clearTimeout(releaseTimer); - if (fault != null) throw fault; - if (!closeResult || closeResult.code !== 0 || closeResult.signal != null) { - throw new Error('Linux file lease holder did not exit cleanly'); - } - releaseComplete = true; - }, - })); - }; - const abort = () => { fail('Linux file lease acquisition was cancelled'); }; - const acquireTimer = setTimeout( - () => fail('Linux file lease acquisition did not complete in time'), - mode === 'shared' ? policy.sharedAcquireMs : policy.exclusiveAcquireMs, - ); - - child.stdin.on?.('error', () => {}); - child.stdout.on('data', (chunk) => append(chunk, 'stdout')); - child.stderr.on('data', (chunk) => append(chunk, 'stderr')); - child.once('error', () => fail('Linux file lease process failed')); - child.once('close', (code, exitSignal) => { - closed = true; - closeResult = Object.freeze({ code, signal: exitSignal ?? null }); - clearTimeout(hardTimer); - clearTimeout(terminalTimer); - finishClose(closeResult); - if (!ready) { - if (code === CONFLICT_EXIT_CODE || cancellation?.aborted) settle(null); - else settle(null, fault ?? new Error('Linux file lease process ended before readiness')); - } else if (!releaseRequested && fault == null) { - fault = new Error('Linux file lease holder ended unexpectedly'); - } - }); - if (cancellation != null) cancellation.addEventListener('abort', abort, { once: true }); - }); -} - -export function createLinuxFileLease(raw = {}, { - spawnProcess = spawn, - timingPolicy = DEFAULT_TIMINGS, -} = {}) { - const selected = exactObject(raw, new Set(['subjectPath']), 'Linux file lease configuration'); - const target = subjectPath(selected.subjectPath); - if (typeof spawnProcess !== 'function') throw new TypeError('Linux file lease process port is invalid'); - const policy = timings(timingPolicy); - return Object.freeze({ - async acquire(rawRequest) { - const input = request(rawRequest); - return acquireProcess({ mode: input.mode, target, cancellation: input.signal, start: spawnProcess, policy }); - }, - }); +export function createLinuxFileLease(raw = {}, options = {}) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError('Linux file lease configuration is invalid'); + if (Object.keys(raw).some((key) => key !== 'subjectPath')) throw new TypeError('Linux file lease configuration contains an unknown field'); + const target = subjectPath(raw.subjectPath); + return createProcessFileLease({ + readyLine: activityLeaseHolderReadyLine(), + commandFor: (mode) => ({ executable: EXECUTABLE, arguments: argumentsFor(mode, target) }), + }, options); } diff --git a/src/runtime/persistent-environments.js b/src/runtime/persistent-environments.js index 96e863eba..8e5b707cd 100644 --- a/src/runtime/persistent-environments.js +++ b/src/runtime/persistent-environments.js @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; +import { normalizeBootProtection } from '../values/boot-protection.js'; import { EnvironmentEffectChannel } from './persistent-environments/effect-channel.js'; import { EnvironmentGenerationChange } from './persistent-environments/generation-change.js'; import { EnvironmentLedger } from './persistent-environments/ledger.js'; @@ -45,7 +46,7 @@ function normalizeSubject(value) { function normalizeSettings(raw = {}) { const value = requireObject(raw, 'environment settings'); - onlyKeys(value, new Set(['memoryBytes', 'processorCount', 'firmware']), 'environment settings'); + onlyKeys(value, new Set(['memoryBytes', 'processorCount', 'firmware', 'bootProtection']), 'environment settings'); const memoryBytes = value.memoryBytes ?? DEFAULT_MEMORY_BYTES; const processorCount = value.processorCount ?? 2; const firmware = value.firmware ?? 'efi'; @@ -56,7 +57,9 @@ function normalizeSettings(raw = {}) { throw new TypeError('environment settings.processorCount is outside the supported safety range'); } if (!FIRMWARE.has(firmware)) throw new TypeError('environment settings.firmware is invalid'); - return { memoryBytes, processorCount, firmware }; + const bootProtection = normalizeBootProtection(value.bootProtection, { optional: true, name: 'environment settings.bootProtection' }); + if (bootProtection && firmware !== 'efi') throw new TypeError('environment protected boot requires EFI firmware'); + return { memoryBytes, processorCount, firmware, ...(bootProtection ? { bootProtection } : {}) }; } function normalizeRequest(raw) { @@ -71,7 +74,8 @@ function normalizeRequest(raw) { } function sameSettings(left, right) { - return left?.memoryBytes === right?.memoryBytes && left?.processorCount === right?.processorCount && left?.firmware === right?.firmware; + return left?.memoryBytes === right?.memoryBytes && left?.processorCount === right?.processorCount && left?.firmware === right?.firmware + && JSON.stringify(left?.bootProtection ?? null) === JSON.stringify(right?.bootProtection ?? null); } function slotIdentity(binding, subject, profile) { @@ -120,9 +124,9 @@ export class PersistentEnvironments { #generation; #retirement; - constructor({ directory, source, operations }) { - this.#ledger = new EnvironmentLedger({ directory, protocol: PROTOCOL }); - this.#effects = new EnvironmentEffectChannel({ source, actions: operations }); + constructor({ directory, source, operations, lease }) { + this.#ledger = new EnvironmentLedger({ directory, protocol: PROTOCOL, lease }); + this.#effects = new EnvironmentEffectChannel({ source, actions: operations, assertMutation: () => this.#ledger.assertHeld(), mutationContext: () => this.#ledger.mutationContext() }); const ports = { effects: this.#effects, commit: (state) => this.#ledger.commit(state), @@ -142,17 +146,43 @@ export class PersistentEnvironments { return this.#ledger.run(async () => this.#provisioning.ensure(await this.#ledger.read(), normalizeRequest(raw))); } - async list() { - return this.#ledger.run(async () => { + async list(rawSelection = {}) { + const value = requireObject(rawSelection, 'environment selection'); + onlyKeys(value, new Set(['subject', 'profile']), 'environment selection'); + const selection = { + subject: value.subject == null ? null : normalizeSubject(value.subject), + profile: value.profile == null ? null : requireId(value.profile, 'environment selection profile'), + }; + return this.#ledger.snapshot(async (state) => { const binding = await this.#effects.binding(); - return this.#lifecycle.list(await this.#ledger.read(), binding); + return this.#lifecycle.list(state, binding, selection); }); } async observe(identity) { - return this.#ledger.run(async () => { + return this.#ledger.snapshot(async (state) => { const binding = await this.#effects.binding(); - return this.#lifecycle.observe(await this.#ledger.read(), binding, identity); + return this.#lifecycle.observe(state, binding, identity); + }); + } + + // Committed identity is not native readiness. Consumers may use this bounded + // view to invalidate an already observed attachment, never to invent one. + async records(rawSelection = {}) { + const value = requireObject(rawSelection, 'environment selection'); + onlyKeys(value, new Set(['subject', 'profile', 'identity']), 'environment selection'); + const subject = value.subject == null ? null : normalizeSubject(value.subject); + const profile = value.profile == null ? null : requireId(value.profile, 'environment selection profile'); + const identity = value.identity == null ? null : requireEnvironmentId(value.identity); + return this.#ledger.snapshot(async (state) => { + const binding = await this.#effects.binding(); + const entries = Object.values(state.entries).filter(entry => + (subject == null || entry.subject === subject) && (profile == null || entry.profile === profile) + && (identity == null || entry.current.identity === identity)); + for (const entry of entries) { + if (entry.binding !== binding) throw new Error('environment attachment identity changed'); + } + return Object.freeze({ revision: state.revision, records: entries.map(publicRecord) }); }); } @@ -251,8 +281,7 @@ export class PersistentEnvironments { } async protectedSourceIdentities() { - return this.#ledger.run(async () => { - const state = await this.#ledger.read(); + return this.#ledger.snapshot(async (state) => { const identities = new Set(); for (const entry of Object.values(state.entries)) identities.add(entry.current.source.identity); for (const operation of Object.values(state.operations)) { diff --git a/src/runtime/persistent-environments/effect-channel.js b/src/runtime/persistent-environments/effect-channel.js index 6264d21c8..5db165406 100644 --- a/src/runtime/persistent-environments/effect-channel.js +++ b/src/runtime/persistent-environments/effect-channel.js @@ -70,10 +70,16 @@ function assertActions(value) { export class EnvironmentEffectChannel { #source; #actions; + #assertMutation; + #mutationContext; - constructor({ source, actions }) { + constructor({ source, actions, assertMutation = () => {}, mutationContext = () => null }) { this.#source = assertSource(source); this.#actions = assertActions(actions); + if (typeof assertMutation !== 'function') throw new TypeError('environment mutation assertion contract is invalid'); + this.#assertMutation = assertMutation; + if (typeof mutationContext !== 'function') throw new TypeError('environment mutation context contract is invalid'); + this.#mutationContext = mutationContext; } async binding() { @@ -102,19 +108,22 @@ export class EnvironmentEffectChannel { } async provision({ identity, source, settings }) { + this.#assertMutation(); return normalizeObservation(await this.#actions.provision({ identity, source: { identity: source.identity, revision: source.revision, digest: source.digest, handle: source.handle }, settings, - }), identity); + }, this.#mutationContext()), identity); } async start(identity) { - return normalizeObservation(await this.#actions.start(identity), identity); + this.#assertMutation(); + return normalizeObservation(await this.#actions.start(identity, this.#mutationContext()), identity); } async stop(identity, options) { - return normalizeObservation(await this.#actions.stop(identity, options), identity); + this.#assertMutation(); + return normalizeObservation(await this.#actions.stop(identity, options, this.#mutationContext()), identity); } canQuiesce() { @@ -122,12 +131,14 @@ export class EnvironmentEffectChannel { } async quiesce(identity) { + this.#assertMutation(); if (!this.canQuiesce()) throw new Error('environment cannot be quiesced'); - return normalizeObservation(await this.#actions.quiesce(identity), identity); + return normalizeObservation(await this.#actions.quiesce(identity, this.#mutationContext()), identity); } async drop(identity) { - return this.#actions.drop(identity); + this.#assertMutation(); + return this.#actions.drop(identity, this.#mutationContext()); } requireSource(observation, sourceIdentity) { diff --git a/src/runtime/persistent-environments/ledger.js b/src/runtime/persistent-environments/ledger.js index 147370282..e4bcc0e71 100644 --- a/src/runtime/persistent-environments/ledger.js +++ b/src/runtime/persistent-environments/ledger.js @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; function requireProtocol(value) { @@ -16,14 +16,18 @@ export class EnvironmentLedger { #stateFile; #guardFile; #protocol; + #lease; + #held = null; #tail = Promise.resolve(); - constructor({ directory, protocol }) { + constructor({ directory, protocol, lease }) { if (typeof directory !== 'string' || directory.length === 0) throw new TypeError('ledger directory is required'); this.#directory = path.resolve(directory); this.#stateFile = path.join(this.#directory, 'catalog.json'); this.#guardFile = path.join(this.#directory, 'lifecycle.lock'); this.#protocol = requireProtocol(protocol); + if (!lease || typeof lease.acquire !== 'function') throw new TypeError('ledger mutation lease is required'); + this.#lease = lease; } async #ensureDirectory() { @@ -34,37 +38,59 @@ export class EnvironmentLedger { async #acquire() { await this.#ensureDirectory(); - const token = randomUUID(); - let handle; - try { - handle = await open(this.#guardFile, 'wx', 0o600); - } catch (error) { - if (error?.code === 'EEXIST') { - throw new Error('environment lifecycle mutation is already active; remove lifecycle.lock only after confirming no operation is running'); - } - throw error; + const held = await this.#lease.acquire({ mode: 'exclusive' }); + if (!held) throw new Error('environment lifecycle mutation is already active'); + if (typeof held.assertHeld !== 'function' || typeof held.release !== 'function') { + await held.release?.(); + throw new TypeError('ledger mutation lease contract is incomplete'); } try { - await handle.writeFile(`${token}\n`, 'utf8'); - await handle.sync(); + let legacy = false; + try { await lstat(this.#guardFile); legacy = true; } + catch (error) { if (error?.code !== 'ENOENT') throw error; } + if (legacy) throw new Error('legacy lifecycle guard requires migration by the quiescent authority owner'); + held.assertHeld(); + this.#held = held; + return async () => { + this.#held = null; + await held.release(); + }; } catch (error) { - await handle.close().catch(() => {}); - await rm(this.#guardFile, { force: true }).catch(() => {}); + await held.release(); throw error; } - await handle.close(); - return async () => { - const observed = (await readFile(this.#guardFile, 'utf8')).trim(); - if (observed !== token) throw new Error('environment lifecycle guard ownership changed'); - await rm(this.#guardFile); - }; + } + + assertHeld() { + if (this.#held == null) throw new Error('ledger mutation requires an active lease'); + this.#held.assertHeld(); + } + + mutationContext() { + this.assertHeld(); + const held = this.#held; + return Object.freeze({ signal: held.signal, assertHeld: () => held.assertHeld() }); + } + + async snapshot(work) { + if (typeof work !== 'function') throw new TypeError('ledger snapshot work must be a function'); + for (let attempt = 0; attempt < 3; attempt += 1) { + const state = await this.read(); + const result = await work(state); + if ((await this.read()).revision === state.revision) return result; + } + throw new Error('environment state changed during observation'); } run(work) { if (typeof work !== 'function') throw new TypeError('ledger work must be a function'); const guarded = async () => { const release = await this.#acquire(); - try { return await work(); } + try { + const result = await work(); + this.assertHeld(); + return result; + } finally { await release(); } }; const next = this.#tail.then(guarded, guarded); @@ -87,9 +113,11 @@ export class EnvironmentLedger { } async commit(state) { + this.assertHeld(); state.revision = Number(state.revision ?? 0) + 1; const temporary = path.join(this.#directory, `.catalog-${randomUUID()}.tmp`); await writeFile(temporary, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + this.assertHeld(); await rename(temporary, this.#stateFile); } } diff --git a/src/runtime/persistent-environments/ordinary-lifecycle.js b/src/runtime/persistent-environments/ordinary-lifecycle.js index e05ad5e35..6427f7e17 100644 --- a/src/runtime/persistent-environments/ordinary-lifecycle.js +++ b/src/runtime/persistent-environments/ordinary-lifecycle.js @@ -48,9 +48,10 @@ export class EnvironmentOrdinaryLifecycle { return { record: this.#present(entry), observation }; } - async list(state, binding) { + async list(state, binding, { subject = null, profile = null } = {}) { const values = []; for (const entry of Object.values(state.entries)) { + if ((subject != null && entry.subject !== subject) || (profile != null && entry.profile !== profile)) continue; if (entry.binding !== binding) { const value = unavailable(entry); value.record = this.#present(entry); diff --git a/src/runtime/process-file-lease.js b/src/runtime/process-file-lease.js new file mode 100644 index 000000000..82eb8afa5 --- /dev/null +++ b/src/runtime/process-file-lease.js @@ -0,0 +1,197 @@ +import { spawn } from 'node:child_process'; + +const CONFLICT_EXIT_CODE = 75; +const MAX_OUTPUT_BYTES = 4 * 1024; +const DEFAULT_TIMINGS = Object.freeze({ sharedAcquireMs: 2_000, exclusiveAcquireMs: 32_000, releaseMs: 2_000, killMs: 1_000 }); + +function exactObject(value, allowed, name) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} is invalid`); + for (const key of Object.keys(value)) if (!allowed.has(key)) throw new TypeError(`${name} contains an unknown field`); + return value; +} + +function timing(value, name) { + if (!Number.isSafeInteger(value) || value < 10 || value > 300_000) throw new TypeError(`file lease ${name} is invalid`); + return value; +} + +function timings(value) { + const selected = exactObject(value ?? DEFAULT_TIMINGS, new Set(Object.keys(DEFAULT_TIMINGS)), 'file lease timings'); + return Object.freeze(Object.fromEntries(Object.keys(DEFAULT_TIMINGS).map((name) => [name, timing(selected[name], name)]))); +} + +function signal(value) { + if (value == null) return null; + if (typeof value !== 'object' || typeof value.aborted !== 'boolean' + || typeof value.addEventListener !== 'function' || typeof value.removeEventListener !== 'function') { + throw new TypeError('file lease cancellation signal is invalid'); + } + return value; +} + +function request(value) { + const selected = exactObject(value, new Set(['mode', 'signal']), 'file lease request'); + if (!['shared', 'exclusive'].includes(selected.mode)) throw new TypeError('file lease mode is invalid'); + return Object.freeze({ mode: selected.mode, signal: signal(selected.signal) }); +} + +function childContract(value) { + if (!value || typeof value !== 'object' + || typeof value.once !== 'function' || typeof value.kill !== 'function' + || !value.stdin || typeof value.stdin.end !== 'function' + || !value.stdout || typeof value.stdout.on !== 'function' + || !value.stderr || typeof value.stderr.on !== 'function') { + throw new Error('file lease process contract is invalid'); + } + return value; +} + +function spawnOptions() { + return Object.freeze({ + stdio: Object.freeze(['pipe', 'pipe', 'pipe']), + shell: false, + windowsHide: true, + env: Object.freeze({ LANG: 'C', LC_ALL: 'C' }), + }); +} + +function acquireProcess({ mode, command, readyLine, cancellation, start, policy }) { + if (cancellation?.aborted) return Promise.resolve(null); + return new Promise((resolve, reject) => { + let child; + try { child = childContract(start(command.executable, command.arguments, spawnOptions())); } + catch { reject(new Error('file lease process could not start')); return; } + + const expected = Buffer.from(readyLine, 'utf8'); + let output = Buffer.alloc(0); + let outputBytes = 0; + let ready = false; + let acquiredSettled = false; + let closed = false; + let closeResult = null; + let releaseRequested = false; + let releaseComplete = false; + let fault = null; + const loss = new AbortController(); + let terminating = false; + let hardTimer = null; + let terminalTimer = null; + let closeSettled = false; + let closeResolve; + const closePromise = new Promise((done) => { closeResolve = done; }); + + const finishClose = (value) => { + if (closeSettled) return; + closeSettled = true; + closeResolve(value); + }; + + const removeCancellation = () => cancellation?.removeEventListener('abort', abort); + const settle = (value, error = null) => { + if (acquiredSettled) return; + acquiredSettled = true; + clearTimeout(acquireTimer); + removeCancellation(); + if (error) reject(error); + else resolve(value); + }; + const terminate = () => { + if (closed || terminating) return; + terminating = true; + try { child.stdin.end(); } catch { /* Continue to signal termination. */ } + try { child.kill('SIGTERM'); } catch { /* Escalation remains bounded. */ } + hardTimer = setTimeout(() => { + if (!closed) { + try { child.kill('SIGKILL'); } catch { /* Close observation remains authoritative. */ } + terminalTimer = setTimeout(() => { + if (closed) return; + finishClose(null); + if (!ready) { + if (cancellation?.aborted) settle(null); + else settle(null, fault ?? new Error('file lease process termination was not observable')); + } + }, policy.killMs); + } + }, policy.killMs); + }; + const fail = (message) => { + if (fault == null) fault = new Error(message); + loss.abort(fault); + terminate(); + }; + const append = (chunk, source) => { + const next = Buffer.from(chunk); + outputBytes += next.length; + if (outputBytes > MAX_OUTPUT_BYTES) { fail('file lease process output exceeded its bound'); return; } + if (source === 'stderr' || ready) { fail('file lease process emitted unexpected output'); return; } + output = Buffer.concat([output, next]); + const newline = output.indexOf(0x0a); + if (newline < 0) return; + if (newline !== output.length - 1 || !output.equals(expected)) { + fail('file lease process readiness evidence is invalid'); + return; + } + ready = true; + settle(Object.freeze({ + signal: loss.signal, + assertHeld() { + if (closed || releaseRequested || fault != null) throw fault ?? new Error('file lease is no longer held'); + }, + async release() { + if (releaseComplete) return; + if (closed && !releaseRequested) throw new Error('file lease holder ended unexpectedly'); + releaseRequested = true; + if (!closed) { + try { child.stdin.end(); } + catch { fail('file lease holder input could not close'); } + } + const releaseTimer = setTimeout(() => fail('file lease holder did not release in time'), policy.releaseMs); + await closePromise; + clearTimeout(releaseTimer); + if (fault != null) throw fault; + if (!closeResult || closeResult.code !== 0 || closeResult.signal != null) { + throw new Error('file lease holder did not exit cleanly'); + } + releaseComplete = true; + }, + })); + }; + const abort = () => { fail('file lease acquisition was cancelled'); }; + const acquireTimer = setTimeout( + () => fail('file lease acquisition did not complete in time'), + mode === 'shared' ? policy.sharedAcquireMs : policy.exclusiveAcquireMs, + ); + + child.stdin.on?.('error', () => {}); + child.stdout.on('data', (chunk) => append(chunk, 'stdout')); + child.stderr.on('data', (chunk) => append(chunk, 'stderr')); + child.once('error', () => fail('file lease process failed')); + child.once('close', (code, exitSignal) => { + closed = true; + closeResult = Object.freeze({ code, signal: exitSignal ?? null }); + clearTimeout(hardTimer); + clearTimeout(terminalTimer); + finishClose(closeResult); + if (!ready) { + if (code === CONFLICT_EXIT_CODE || cancellation?.aborted) settle(null); + else settle(null, fault ?? new Error(`file lease process ended before readiness (exit ${code}, signal ${exitSignal ?? 'none'})`)); + } else if (!releaseRequested && fault == null) { + fault = new Error('file lease holder ended unexpectedly'); + loss.abort(fault); + } + }); + if (cancellation != null) cancellation.addEventListener('abort', abort, { once: true }); + }); +} + +export function createProcessFileLease({ commandFor, readyLine }, { spawnProcess = spawn, timingPolicy = DEFAULT_TIMINGS } = {}) { + if (typeof commandFor !== 'function' || typeof readyLine !== 'string' || !readyLine.endsWith('\n') || readyLine.length > 1024 || typeof spawnProcess !== 'function') throw new TypeError('file lease process contract is invalid'); + const policy = timings(timingPolicy); + return Object.freeze({ + async acquire(rawRequest) { + const input = request(rawRequest); + const command = commandFor(input.mode); + return acquireProcess({ mode: input.mode, command, readyLine, cancellation: input.signal, start: spawnProcess, policy }); + }, + }); +} diff --git a/src/runtime/providers/guest-bridge-command-session.js b/src/runtime/providers/guest-bridge-command-session.js new file mode 100644 index 000000000..9407341f6 --- /dev/null +++ b/src/runtime/providers/guest-bridge-command-session.js @@ -0,0 +1,94 @@ +// Fixed adapter code, run inside the selected guest over its authenticated +// transport. It invokes only the installed bridge entrypoint; frames are stdin +// data. Each bridge request retains the guest agent's durable effect identity. +function linuxSession() { + const { spawnSync } = require('node:child_process'); + let buffer = Buffer.alloc(0), target = null; + process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length > 64 * 1024) process.exit(1); + const end = buffer.indexOf(10); + if (end < 0) return; + if (end !== buffer.length - 1) process.exit(1); + const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, end))); + buffer = Buffer.alloc(0); + if (target == null) { + if (!/^env-[a-f0-9]{32}$/.test(value.target) || Object.keys(value).length !== 1) process.exit(1); + target = value.target; + process.stdout.write('{"ready":true}\n'); + return; + } + if (value.target !== target) process.exit(1); + const result = spawnSync('node', ['/usr/local/libexec/devbridge/bridge-agent.mjs', '--exchange-stdin'], { + input: JSON.stringify(value), encoding: 'utf8', shell: false, + timeout: 120000, maxBuffer: 7 * 1024 * 1024, + env: { ...process.env, DEVBRIDGE_GUEST_TARGET: target }, + }); + if (result.error || result.status !== 0 || result.signal || result.stderr) process.exit(1); + process.stdout.write(JSON.stringify(JSON.parse(result.stdout)) + '\n'); + }); + process.stdin.on('end', () => process.exit(buffer.length ? 1 : 0)); +} + +export const LINUX_BRIDGE_SESSION_COMMAND = `node -e 'eval(Buffer.from("${Buffer.from(`(${linuxSession.toString()})()`).toString('base64')}","base64").toString("utf8"))'`; + +export const DIRECT_GUEST_SCRIPT = String.raw` + param($encoded, $target) + $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = 'node.exe' + $start.Arguments = 'C:\ProgramData\DevBridge\bridge-agent.mjs --exchange-stdin' + $start.UseShellExecute = $false + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false) + $start.StandardErrorEncoding = [Text.UTF8Encoding]::new($false) + $start.CreateNoWindow = $true + $start.EnvironmentVariables['DEVBRIDGE_GUEST_TARGET'] = $target + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + if (-not $process.Start()) { throw 'bridge helper did not start' } + $process.StandardInput.Write($json) + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(120000)) { $process.Kill(); throw 'bridge helper timed out' } + if ($process.ExitCode -ne 0) { throw 'bridge helper exited unsuccessfully' } + if ($stderr.Result.Length -ne 0 -or $stdout.Result.Length -gt 7340032) { throw 'bridge helper output is invalid' } + $stdout.Result +`; + +export const DIRECT_SESSION_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::InputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$data = [Console]::ReadLine() | ConvertFrom-Json +Import-Module Hyper-V -ErrorAction Stop +$item = Get-VM -Name ([string]$data.reference) -ErrorAction Stop +if ([string]$item.Notes -ne [string]$data.proof) { throw 'environment ownership proof does not match' } +if ([string]$item.State -ne 'Running') { throw 'environment is not running' } +$secure = [Security.SecureString]::new() +$session = $null +try { + foreach ($character in ([string]$data.password).ToCharArray()) { $secure.AppendChar($character) } + $secure.MakeReadOnly() + $credential = [Management.Automation.PSCredential]::new([string]$data.username, $secure) + $target = [string]$data.target + $session = New-PSSession -VMId $item.Id -Credential $credential -ErrorAction Stop + $data = $null + [Console]::WriteLine('{"ready":true}') + while ($null -ne ($line = [Console]::ReadLine())) { + if ([Text.Encoding]::UTF8.GetByteCount($line) -gt 65536) { throw 'bridge frame is too large' } + $frame = $line | ConvertFrom-Json + if ([string]$frame.target -ne $target) { throw 'bridge target changed' } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($line)) + $output = Invoke-Command -Session $session -ArgumentList $encoded, $target -ScriptBlock {${DIRECT_GUEST_SCRIPT}} -ErrorAction Stop + [Console]::WriteLine(([string]$output).Trim()) + } +} finally { + if ($null -ne $session) { Remove-PSSession -Session $session -ErrorAction SilentlyContinue } + $secure.Dispose() +} +`; diff --git a/src/runtime/providers/hyperv-environment-bootstrap.js b/src/runtime/providers/hyperv-environment-bootstrap.js index c52bdd4c1..3c1db959e 100644 --- a/src/runtime/providers/hyperv-environment-bootstrap.js +++ b/src/runtime/providers/hyperv-environment-bootstrap.js @@ -1,7 +1,8 @@ import { createHash, randomUUID } from 'node:crypto'; -import dns from 'node:dns'; import { lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { copyHyperVGuestFile } from './hyperv-file-copy.js'; +import { guestDnsServers, observeWindowsGuestDns } from './windows-guest-dns.js'; const PROTOCOL = 'devbridge/hyperv-environment-bootstrap-state-v1'; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/u; @@ -113,19 +114,6 @@ $contact = [string]$copy.PrimaryOperationalStatus -eq 'Ok' @{ ready = $true; state = ([string]$item.State).ToLowerInvariant(); cycleRequired = ($running -and -not $contact) } | ConvertTo-Json -Compress `; -const COPY_SCRIPT = String.raw` -$ErrorActionPreference = 'Stop' -$data = [Console]::In.ReadToEnd() | ConvertFrom-Json -Import-Module Hyper-V -ErrorAction Stop -$item = Get-VM -Name ([string]$data.reference) -ErrorAction Stop -if ([string]$item.Notes -ne [string]$data.proof) { throw 'environment ownership proof does not match' } -if ([string]$item.State -ne 'Running') { throw 'environment is not running' } -$service = Get-VMIntegrationService -VMName ([string]$data.reference) -ErrorAction Stop | Where-Object { $_.Name -eq 'Guest Service Interface' } | Select-Object -First 1 -if ($null -eq $service -or -not $service.Enabled) { throw 'guest file service is not enabled' } -Copy-VMFile -VMName ([string]$data.reference) -SourcePath ([string]$data.source) -DestinationPath ([string]$data.destination) -FileSource Host -CreateFullPath -Force -ErrorAction Stop -@{ copied = $true } | ConvertTo-Json -Compress -`; - export class HyperVEnvironmentBootstrap { #directory; #stateFile; @@ -134,14 +122,17 @@ export class HyperVEnvironmentBootstrap { #locate; #connection; #dnsServers; + #now; + #wait; #tail = Promise.resolve(); - constructor({ directory, invoke, locate, connection, dnsServers = () => dns.getServers() }) { + constructor({ directory, invoke, locate, connection, dnsServers = () => observeWindowsGuestDns({ invoke }), now = Date.now, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) { if (typeof directory !== 'string' || directory.length === 0) throw new TypeError('bootstrap directory is required'); if (typeof invoke !== 'function') throw new TypeError('bootstrap invoke must be a function'); if (typeof locate !== 'function') throw new TypeError('bootstrap locate must be a function'); if (typeof connection !== 'function') throw new TypeError('bootstrap connection must be a function'); if (typeof dnsServers !== 'function') throw new TypeError('bootstrap dnsServers must be a function'); + if (typeof now !== 'function' || typeof wait !== 'function') throw new TypeError('bootstrap timing contract is invalid'); this.#directory = path.resolve(directory); this.#stateFile = path.join(this.#directory, 'state.json'); this.#guardFile = path.join(this.#directory, 'allocation.lock'); @@ -149,6 +140,8 @@ export class HyperVEnvironmentBootstrap { this.#locate = locate; this.#connection = connection; this.#dnsServers = dnsServers; + this.#now = now; + this.#wait = wait; } async #acquire() { @@ -231,9 +224,9 @@ export class HyperVEnvironmentBootstrap { try { return JSON.parse(result.stdout); } catch { throw new Error('bootstrap management operation returned invalid structured output'); } } - #servers() { - const servers = [...new Set(this.#dnsServers().filter((entry) => IPV4.test(entry)))].slice(0, 4); - if (servers.length === 0) servers.push('1.1.1.1'); + async #servers() { + const servers = guestDnsServers(await this.#dnsServers()); + if (servers.length === 0) throw new Error('bootstrap requires guest-reachable DNS from local network policy'); return servers; } @@ -274,7 +267,7 @@ export class HyperVEnvironmentBootstrap { const target = targetId(rawTarget); const network = normalizeNetwork(rawNetwork); const address = await this.#allocation(target, { network }, 'reserved'); - return Object.freeze({ address, prefixLength: 24, gateway: network.gateway, dns: Object.freeze(this.#servers()) }); + return Object.freeze({ address, prefixLength: 24, gateway: network.gateway, dns: await this.#servers() }); } async releaseAddress(rawTarget) { @@ -313,7 +306,7 @@ export class HyperVEnvironmentBootstrap { address, prefixLength: 24, gateway: location.network.gateway, - dns: this.#servers(), + dns: await this.#servers(), revision: 1, }; await this.#ensure(); @@ -323,19 +316,21 @@ export class HyperVEnvironmentBootstrap { const destination = location.family === 'windows' ? 'C:\\ProgramData\\DevBridge\\bootstrap\\network-seed.json' : '/var/lib/devbridge/bootstrap/network-seed.json'; - const deadline = Date.now() + 90_000; + const deadline = this.#now() + 90_000; let last = null; do { try { - const result = await this.#powerShell(COPY_SCRIPT, { reference: location.reference, proof: location.proof, source: temporary, destination }, 20_000); - if (result.copied === true) return { ready: true, address }; - last = new Error('guest seed copy did not report completion'); + const remaining = deadline - this.#now(); + if (remaining < 100) break; + await copyHyperVGuestFile({ invoke: this.#invoke, location, family: location.family, source: temporary, destination, timeoutMs: Math.min(20_000, remaining) }); + return { ready: true, address }; } catch (error) { + if (error.retryable !== true) throw error; last = error; } - await new Promise((resolve) => setTimeout(resolve, 1_000)); - } while (Date.now() < deadline); - throw new Error(`guest seed copy did not become ready: ${last?.message ?? 'unknown failure'}`); + await this.#wait(Math.min(1_000, Math.max(0, deadline - this.#now()))); + } while (this.#now() < deadline); + throw last ?? new Error('guest seed copy did not become ready'); } finally { await rm(temporary, { force: true }); } diff --git a/src/runtime/providers/hyperv-environment-bridge.js b/src/runtime/providers/hyperv-environment-bridge.js index 1a93d5aa0..b284fadd7 100644 --- a/src/runtime/providers/hyperv-environment-bridge.js +++ b/src/runtime/providers/hyperv-environment-bridge.js @@ -1,4 +1,5 @@ import { lstat, realpath } from 'node:fs/promises'; +import { DIRECT_GUEST_SCRIPT, DIRECT_SESSION_SCRIPT, LINUX_BRIDGE_SESSION_COMMAND } from './guest-bridge-command-session.js'; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/u; const USER = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/u; @@ -101,27 +102,7 @@ try { $credential = [Management.Automation.PSCredential]::new([string]$data.username, $secure) $session = New-PSSession -VMName ([string]$data.reference) -Credential $credential -ErrorAction Stop $output = Invoke-Command -Session $session -ArgumentList ([string]$data.frame), ([string]$data.target) -ScriptBlock { - param($encoded, $target) - $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded)) - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = 'node.exe' - $start.Arguments = 'C:\ProgramData\DevBridge\bridge-agent.mjs --exchange-stdin' - $start.UseShellExecute = $false - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.CreateNoWindow = $true - $start.EnvironmentVariables['DEVBRIDGE_GUEST_TARGET'] = $target - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - if (-not $process.Start()) { throw 'bridge helper did not start' } - $process.StandardInput.Write($json) - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEnd() - $stderr = $process.StandardError.ReadToEnd() - $process.WaitForExit() - if ($process.ExitCode -ne 0) { throw ('bridge helper failed: ' + $stderr) } - $stdout + ${DIRECT_GUEST_SCRIPT} } -ErrorAction Stop [string]$output } finally { @@ -134,14 +115,22 @@ export class HyperVEnvironmentBridge { #invoke; #access; #locate; + #openChannel; + #channel = null; + #channelKey = null; + #beforeConnect; - constructor({ invoke, access, locate }) { + constructor({ invoke, access, locate, openChannel = null, beforeConnect = async () => {} }) { if (typeof invoke !== 'function') throw new TypeError('bridge invoke must be a function'); if (typeof access !== 'function') throw new TypeError('bridge access must be a function'); if (typeof locate !== 'function') throw new TypeError('bridge locate must be a function'); this.#invoke = invoke; this.#access = access; this.#locate = locate; + if (openChannel != null && typeof openChannel !== 'function') throw new TypeError('bridge command channel factory is invalid'); + this.#openChannel = openChannel; + if (typeof beforeConnect !== 'function') throw new TypeError('bridge connection observation contract is invalid'); + this.#beforeConnect = beforeConnect; } async #powerShell(script, payload, { signal = null, timeoutMs = 90_000 } = {}) { @@ -168,11 +157,60 @@ export class HyperVEnvironmentBridge { return location; } - async exchange(frame, { signal = null } = {}) { + close() { + this.#channel?.close(); + this.#channel = null; + this.#channelKey = null; + } + + async #connectedExchange(frame, selected, { signal, binding }) { + const location = await this.#location(frame.target); + const key = JSON.stringify([frame.target, location, selected, binding]); + if (this.#channel?.closed || this.#channelKey !== key) this.close(); + try { + if (this.#channel == null) { + await this.#beforeConnect(frame.target, { signal }); + let executable, args, initialization; + if (selected.family === 'windows') { + executable = POWERSHELL; + args = [...POWERSHELL_ARGS, encodeScript(DIRECT_SESSION_SCRIPT)]; + initialization = { ...location, username: selected.username, password: selected.password, target: frame.target }; + } else { + await this.#verify(frame.target, { signal }); + const [identityFile, knownHostsFile] = await Promise.all([ + regularFile(selected.identityFile, 'bridge access.identityFile'), + regularFile(selected.knownHostsFile, 'bridge access.knownHostsFile'), + ]); + executable = 'ssh.exe'; + args = [ + '-F', 'NUL', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', + '-o', `UserKnownHostsFile=${knownHostsFile}`, '-o', 'GlobalKnownHostsFile=NUL', + '-o', 'UpdateHostKeys=no', '-o', 'IdentitiesOnly=yes', '-o', 'ForwardAgent=no', + '-o', 'ForwardX11=no', '-o', 'ClearAllForwardings=yes', '-o', 'PermitLocalCommand=no', + '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', + '-i', identityFile, `${selected.user}@${selected.address}`, LINUX_BRIDGE_SESSION_COMMAND, + ]; + initialization = { target: frame.target }; + } + this.#channel = this.#openChannel({ executable, arguments: args, inputLimit: 64 * 1024, + outputLimit: MAX_RESPONSE_BYTES, timeoutMs: 120_000, idleMs: 0 }); + const ready = await this.#channel.exchange(initialization, { signal }); + if (ready?.ready !== true || Object.keys(ready).length !== 1) throw new Error('bridge connection did not prove readiness'); + this.#channelKey = key; + } + return await this.#channel.exchange(frame, { signal }); + } catch (error) { + this.close(); + throw error; + } + } + + async exchange(frame, { signal = null, binding = null } = {}) { const target = normalizeTarget(frame?.target); const serialized = JSON.stringify(frame); if (Buffer.byteLength(serialized, 'utf8') > MAX_FRAME_BYTES) throw new Error('bridge frame exceeds this attachment limit'); const selected = normalizeAccess(await this.#access(target)); + if (this.#openChannel != null) return this.#connectedExchange(frame, selected, { signal, binding }); if (selected.family === 'windows') { const location = await this.#location(target); diff --git a/src/runtime/providers/hyperv-file-copy.js b/src/runtime/providers/hyperv-file-copy.js new file mode 100644 index 000000000..348597be9 --- /dev/null +++ b/src/runtime/providers/hyperv-file-copy.js @@ -0,0 +1,113 @@ +import { copyFile, lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; +import path from 'node:path'; + +const REFERENCE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/u; +const SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$data = [Console]::In.ReadToEnd() | ConvertFrom-Json +$code = 'management-failed' +$attempted = $false +try { + Import-Module Hyper-V -ErrorAction Stop + $item = Get-VM -Name ([string]$data.reference) -ErrorAction Stop + $code = 'ownership-mismatch' + if ([string]$item.Notes -ne [string]$data.proof) { throw 'environment ownership proof does not match' } + $code = 'guest-not-running' + if ([string]$item.State -ne 'Running') { throw 'environment is not running' } + $code = 'service-unavailable' + $service = Get-VMIntegrationService -VMName ([string]$data.reference) -ErrorAction Stop | Where-Object { $_.Name -eq 'Guest Service Interface' } | Select-Object -First 1 + if ($null -eq $service -or -not $service.Enabled) { throw 'guest file service is not enabled' } + $code = 'service-not-ready' + if ([string]$service.PrimaryOperationalStatus -ne 'Ok') { throw 'guest file service has no contact' } + $code = 'copy-failed' + $attempted = $true + Copy-VMFile -VMName ([string]$data.reference) -SourcePath ([string]$data.source) -DestinationPath ([string]$data.destination) -FileSource Host -CreateFullPath -Force -ErrorAction Stop + @{ delivered = $true } | ConvertTo-Json -Compress +} catch { + $category = [string]$_.CategoryInfo.Category + if ($category -eq 'InvalidArgument' -or $category -eq 'InvalidData') { $code = 'invalid-argument' } + @{ delivered = $false; failure = @{ code = $code; attempted = $attempted; category = $category; nativeCode = ('0x{0:X8}' -f $_.Exception.HResult); message = [string]$_.Exception.Message } } | ConvertTo-Json -Depth 4 -Compress +} +`; + +function bounded(value, name, limit = 4096) { + if (typeof value !== 'string' || !value || /[\0\r\n]/u.test(value) || Buffer.byteLength(value, 'utf8') > limit) throw new TypeError(`${name} is invalid`); + return value; +} + +function destinationPath(destination, family) { + const selected = bounded(destination, 'guest file destination'); + const syntax = family === 'linux' ? path.posix : path.win32; + if (!syntax.isAbsolute(selected) || syntax.normalize(selected) !== selected || /[\\/]$/u.test(selected) + || (family === 'linux' && selected.includes('\\')) + || (family === 'windows' && !/^[A-Za-z]:\\/u.test(selected))) { + throw new TypeError('guest file destination must be a normalized absolute filename'); + } + const basename = syntax.basename(selected); + // Linux destinations must also be representable as a Windows source basename. + if (/[<>:"\\|?*]/u.test(basename) || /[. ]$/u.test(basename) + || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(basename)) throw new TypeError('guest file basename is not representable by Hyper-V'); + return { selected, basename, directory: syntax.dirname(selected) }; +} + +function evidence(result, failure) { + const text = (value) => String(value ?? '').slice(0, 2048); + const stdout = text(result?.stdout); + const stderr = text(result?.stderr); + return Object.freeze({ + exitCode: Number.isInteger(result?.exitCode) ? result.exitCode : null, + timedOut: result?.timedOut === true, + aborted: result?.aborted === true, + outputTruncated: result?.outputTruncated === true || String(result?.stdout ?? '').length > stdout.length || String(result?.stderr ?? '').length > stderr.length, + stdout, stderr, + nativeCode: failure?.nativeCode == null ? null : text(failure.nativeCode), + category: failure?.category == null ? null : text(failure.category), + }); +} + +export class HyperVFileCopyError extends Error { + constructor(result, failure = null) { + const code = typeof failure?.code === 'string' ? failure.code.slice(0, 128) : 'copy-outcome-unknown'; + const detail = String(failure?.message || result?.stderr || 'native copy did not return confirmed completion').trim().slice(0, 2048); + super(`guest file delivery ${code}: ${detail}`); + this.name = 'HyperVFileCopyError'; + this.code = code; + this.retryable = failure?.attempted === false && ['guest-not-running', 'service-not-ready'].includes(code); + this.effect = failure?.attempted === false ? 'not-attempted' : 'uncertain'; + this.evidence = evidence(result, failure); + } +} + +export async function copyHyperVGuestFile({ invoke, location, family, source, destination, timeoutMs = 30_000, signal } = {}) { + if (typeof invoke !== 'function' || !['linux', 'windows'].includes(family)) throw new TypeError('Hyper-V copy contract is invalid'); + if (!REFERENCE.test(location?.reference ?? '')) throw new TypeError('Hyper-V copy reference is invalid'); + const proof = bounded(location?.proof, 'Hyper-V copy ownership proof', 2048); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 300_000) throw new TypeError('Hyper-V copy timeout is invalid'); + const guest = destinationPath(destination, family); + const lexical = bounded(source, 'guest file delivery source'); + const info = await lstat(lexical); + if (!info.isFile() || info.isSymbolicLink()) throw new Error('guest file delivery source must be a real regular file'); + const localSource = await realpath(lexical); + let staging = null; + try { + let nativeSource = localSource; + if (family === 'linux') { + staging = await mkdtemp(path.join(path.dirname(localSource), '.guest-delivery-')); + nativeSource = path.join(staging, guest.basename); + await copyFile(localSource, nativeSource); + } + const result = await invoke({ + executable: 'powershell.exe', + arguments: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', Buffer.from(SCRIPT, 'utf16le').toString('base64')], + input: JSON.stringify({ reference: location.reference, proof, source: nativeSource, destination: family === 'linux' ? `${guest.directory.replace(/\/$/u, '')}/` : guest.selected }), + timeoutMs, maxOutputBytes: 256 * 1024, ...(signal == null ? {} : { signal }), + }); + if (!result || result.exitCode !== 0 || result.timedOut || result.aborted || result.outputTruncated) throw new HyperVFileCopyError(result); + let parsed; + try { parsed = JSON.parse(result.stdout); } catch { throw new HyperVFileCopyError(result); } + if (parsed?.delivered !== true) throw new HyperVFileCopyError(result, parsed?.failure); + return Object.freeze({ delivered: true }); + } finally { + if (staging != null) await rm(staging, { recursive: true, force: true }); + } +} diff --git a/src/runtime/providers/hyperv-guest-file-delivery.js b/src/runtime/providers/hyperv-guest-file-delivery.js index bef1bca92..a9029009d 100644 --- a/src/runtime/providers/hyperv-guest-file-delivery.js +++ b/src/runtime/providers/hyperv-guest-file-delivery.js @@ -1,55 +1,24 @@ -import { lstat, realpath } from 'node:fs/promises'; import { createHyperVEnvironmentLocation } from './hyperv-environment-location.js'; +import { copyHyperVGuestFile } from './hyperv-file-copy.js'; -const POWERSHELL = 'powershell.exe'; -const POWERSHELL_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand']; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/u; -function encodeScript(value) { return Buffer.from(value, 'utf16le').toString('base64'); } function bounded(value, name, maxBytes = 4096) { if (typeof value !== 'string' || value.length === 0 || value.includes('\0') || Buffer.byteLength(value, 'utf8') > maxBytes) throw new TypeError(`${name} is invalid`); return value; } -const COPY_SCRIPT = String.raw` -$ErrorActionPreference = 'Stop' -$data = [Console]::In.ReadToEnd() | ConvertFrom-Json -Import-Module Hyper-V -ErrorAction Stop -$item = Get-VM -Name ([string]$data.reference) -ErrorAction Stop -if ([string]$item.Notes -ne [string]$data.proof) { throw 'environment ownership proof does not match' } -if ([string]$item.State -ne 'Running') { throw 'environment is not running' } -$service = Get-VMIntegrationService -VMName ([string]$data.reference) -ErrorAction Stop | Where-Object { $_.Name -eq 'Guest Service Interface' } | Select-Object -First 1 -if ($null -eq $service -or -not $service.Enabled) { throw 'guest file service is not enabled' } -Copy-VMFile -VMName ([string]$data.reference) -SourcePath ([string]$data.source) -DestinationPath ([string]$data.destination) -FileSource Host -CreateFullPath -Force -ErrorAction Stop -@{ delivered = $true } | ConvertTo-Json -Compress -`; - export class HyperVGuestFileDelivery { #invoke; #location; - constructor({ identity, invoke } = {}) { - if (typeof invoke !== 'function') throw new TypeError('guest file delivery invocation contract is invalid'); + #family; + constructor({ identity, invoke, family = 'linux' } = {}) { + if (typeof invoke !== 'function' || !['linux', 'windows'].includes(family)) throw new TypeError('guest file delivery invocation contract is invalid'); this.#invoke = invoke; this.#location = createHyperVEnvironmentLocation(identity); + this.#family = family; } - async put(rawTarget, source, destination) { + async put(rawTarget, source, destination, { timeoutMs, signal } = {}) { const target = bounded(rawTarget, 'guest file delivery target', 512); if (!TARGET.test(target)) throw new TypeError('guest file delivery target is invalid'); - const lexical = bounded(source, 'guest file delivery source'); - const info = await lstat(lexical); - if (!info.isFile() || info.isSymbolicLink()) throw new Error('guest file delivery source must be a real regular file'); - const localSource = await realpath(lexical); - const guestDestination = bounded(destination, 'guest file delivery destination'); - const location = this.#location.environment(target); - const result = await this.#invoke({ - executable: POWERSHELL, - arguments: [...POWERSHELL_ARGS, encodeScript(COPY_SCRIPT)], - input: JSON.stringify({ ...location, source: localSource, destination: guestDestination }), - timeoutMs: 30_000, - maxOutputBytes: 256 * 1024, - }); - if (!result || result.exitCode !== 0 || result.timedOut || result.aborted || result.outputTruncated) throw new Error(String(result?.stderr || result?.stdout || 'guest file delivery failed').trim().slice(0, 2048)); - let parsed; - try { parsed = JSON.parse(result.stdout); } catch { throw new Error('guest file delivery returned invalid structured output'); } - if (parsed?.delivered !== true) throw new Error('guest file delivery did not report completion'); - return Object.freeze({ delivered: true }); + return copyHyperVGuestFile({ invoke: this.#invoke, location: this.#location.environment(target), family: this.#family, source, destination, timeoutMs, signal }); } } diff --git a/src/runtime/providers/hyperv-image-construction/console-evidence.js b/src/runtime/providers/hyperv-image-construction/console-evidence.js index 3b085a2fa..0fb685519 100644 --- a/src/runtime/providers/hyperv-image-construction/console-evidence.js +++ b/src/runtime/providers/hyperv-image-construction/console-evidence.js @@ -1,14 +1,11 @@ import { createHash, randomUUID } from 'node:crypto'; import { rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { consoleDimensions } from './console-format.js'; -const WIDTH = 320; -const HEIGHT = 240; -const RAW_BYTES = WIDTH * HEIGHT * 2; - -function normalizeBytes(raw) { - if (raw.length === RAW_BYTES) return raw; - if (raw.length === RAW_BYTES + 4) { +function normalizeBytes(raw, expectedBytes) { + if (raw.length === expectedBytes) return raw; + if (raw.length === expectedBytes + 4) { if (raw.readUInt32BE(0) !== raw.length) throw new Error('construction console evidence length prefix is invalid'); return raw.subarray(4); } @@ -30,24 +27,25 @@ export class HyperVConsoleEvidence { const reason = String(result?.reason ?? 'Hyper-V thumbnail evidence is unavailable').slice(0, 512); return Object.freeze({ available: false, reason }); } - if (result.width !== WIDTH || result.height !== HEIGHT || typeof result.imageData !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/u.test(result.imageData)) { + const { width, height, rawBytes } = consoleDimensions(result.width, result.height); + if (typeof result.imageData !== 'string' || result.imageData.length > Math.ceil((rawBytes + 4) / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(result.imageData)) { throw new Error('construction console evidence contract is invalid'); } const raw = Buffer.from(result.imageData, 'base64'); if (raw.toString('base64') !== result.imageData) throw new Error('construction console evidence encoding is invalid'); - const pixels = normalizeBytes(raw); - const rowBytes = WIDTH * 3; - const bmp = Buffer.alloc(54 + rowBytes * HEIGHT); + const pixels = normalizeBytes(raw, rawBytes); + const rowBytes = width * 3; + const bmp = Buffer.alloc(54 + rowBytes * height); bmp.write('BM', 0, 'ascii'); bmp.writeUInt32LE(bmp.length, 2); bmp.writeUInt32LE(54, 10); bmp.writeUInt32LE(40, 14); - bmp.writeInt32LE(WIDTH, 18); - bmp.writeInt32LE(-HEIGHT, 22); + bmp.writeInt32LE(width, 18); + bmp.writeInt32LE(-height, 22); bmp.writeUInt16LE(1, 26); bmp.writeUInt16LE(24, 28); - bmp.writeUInt32LE(rowBytes * HEIGHT, 34); - for (let pixel = 0; pixel < WIDTH * HEIGHT; pixel += 1) { + bmp.writeUInt32LE(rowBytes * height, 34); + for (let pixel = 0; pixel < width * height; pixel += 1) { const packed = pixels.readUInt16LE(pixel * 2); const target = 54 + pixel * 3; bmp[target] = Math.round((packed & 0x1f) * 255 / 31); @@ -65,6 +63,6 @@ export class HyperVConsoleEvidence { } const captured = this.#now(); if (!(captured instanceof Date) || !Number.isFinite(captured.getTime())) throw new Error('construction clock returned an invalid time'); - return Object.freeze({ available: true, capturedAt: captured.toISOString(), location, bytes: bmp.length, sha256, width: WIDTH, height: HEIGHT }); + return Object.freeze({ available: true, capturedAt: captured.toISOString(), location, bytes: bmp.length, sha256, width, height }); } } diff --git a/src/runtime/providers/hyperv-image-construction/console-format.js b/src/runtime/providers/hyperv-image-construction/console-format.js new file mode 100644 index 000000000..70f03ce5c --- /dev/null +++ b/src/runtime/providers/hyperv-image-construction/console-format.js @@ -0,0 +1,8 @@ +const SIZES = new Set(['320x240', '640x480', '1024x768']); + +export function consoleDimensions(width = 320, height = 240) { + if (!Number.isInteger(width) || !Number.isInteger(height) || !SIZES.has(`${width}x${height}`)) { + throw new TypeError('Hyper-V console dimensions are unsupported'); + } + return Object.freeze({ width, height, rawBytes: width * height * 2 }); +} diff --git a/src/runtime/providers/hyperv-image-construction/management-channel.js b/src/runtime/providers/hyperv-image-construction/management-channel.js index c65be3804..a2d920d6f 100644 --- a/src/runtime/providers/hyperv-image-construction/management-channel.js +++ b/src/runtime/providers/hyperv-image-construction/management-channel.js @@ -1,3 +1,5 @@ +import { consoleDimensions } from './console-format.js'; + const POWERSHELL = 'powershell.exe'; const POWERSHELL_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand']; @@ -235,15 +237,15 @@ $service = Get-CimInstance -Namespace 'root/virtualization/v2' -ClassName Msvm_V if ($null -eq $service) { throw 'construction console management service is absent' } $result = Invoke-CimMethod -InputObject $service -MethodName GetVirtualSystemThumbnailImage -Arguments @{ TargetSystem = $settings - WidthPixels = [uint16]320 - HeightPixels = [uint16]240 + WidthPixels = [uint16]$data.width + HeightPixels = [uint16]$data.height } -ErrorAction Stop if ([uint32]$result.ReturnValue -ne 0) { @{ available = $false; reason = "Hyper-V thumbnail returned $([uint32]$result.ReturnValue)" } | ConvertTo-Json -Compress exit 0 } $bytes = [byte[]]$result.ImageData -@{ available = $true; width = 320; height = 240; imageData = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Compress +@{ available = $true; width = [int]$data.width; height = [int]$data.height; imageData = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Compress `; const START_INSTALL_SCRIPT = String.raw` @@ -391,20 +393,23 @@ export class HyperVConstructionChannel { this.#invoke = invoke; } - async #run(script, payload, timeoutMs = 90_000) { + async #run(script, payload, timeoutMs = 90_000, maxOutputBytes = 1024 * 1024) { return parseJson(await this.#invoke({ executable: POWERSHELL, arguments: [...POWERSHELL_ARGS, encodeScript(script)], input: JSON.stringify(payload), timeoutMs, - maxOutputBytes: 1024 * 1024, + maxOutputBytes, })); } prepare(payload) { return this.#run(PREPARE_SCRIPT, payload, 120_000); } prepareQualification(payload) { return this.#run(PREPARE_QUALIFICATION_SCRIPT, payload, 120_000); } observe(payload) { return this.#run(OBSERVE_SCRIPT, payload, 30_000); } - console(payload) { return this.#run(INSTALL_CONSOLE_SCRIPT, payload, 30_000); } + console(payload) { + const { width, height, rawBytes } = consoleDimensions(payload.width, payload.height); + return this.#run(INSTALL_CONSOLE_SCRIPT, { ...payload, width, height }, 30_000, Math.ceil((rawBytes + 4) / 3) * 4 + 4096); + } startInstall(payload) { return this.#run(START_INSTALL_SCRIPT, payload, 60_000); } address(payload) { return this.#run(GUEST_ADDRESS_SCRIPT, payload, 30_000); } bootInstalled(payload) { return this.#run(BOOT_INSTALLED_SCRIPT, payload, 60_000); } diff --git a/src/runtime/providers/hyperv-persistent-environment.js b/src/runtime/providers/hyperv-persistent-environment.js index fe6554edb..fb5f46e66 100644 --- a/src/runtime/providers/hyperv-persistent-environment.js +++ b/src/runtime/providers/hyperv-persistent-environment.js @@ -1,3 +1,4 @@ +import { bindInvocationToLease } from '../lease-bound-invocation.js'; import { lstat, realpath } from 'node:fs/promises'; import path from 'node:path'; import { @@ -49,7 +50,12 @@ export class HyperVPersistentEnvironment { return new PersistentEnvironmentCore(this.#options).inspect(); } - async #core() { + async #core(context = null) { + if (context != null) { + context.assertHeld(); + const sourceRoot = await canonicalRoot(this.#options.sourceRoot); + return new PersistentEnvironmentCore({ ...this.#options, sourceRoot, invoke: bindInvocationToLease(this.#options.invoke, context) }); + } if (!this.#delegate) { const sourceRoot = await canonicalRoot(this.#options.sourceRoot); this.#delegate = new PersistentEnvironmentCore({ ...this.#options, sourceRoot }); @@ -57,16 +63,16 @@ export class HyperVPersistentEnvironment { return this.#delegate; } - async provision(input) { + async provision(input, context = null) { preflightExecutionProfileMemory(input?.settings); await preflightExecutionProfileStoragePaths({ directory: this.#options.directory, sourceLocation: input?.source?.handle?.location, }); - return observed(await (await this.#core()).provision(input)); + return observed(await (await this.#core(context)).provision(input)); } async observe(identity) { return observed(await (await this.#core()).observe(identity)); } - async start(identity) { return observed(await (await this.#core()).start(identity)); } - async stop(identity, options) { return observed(await (await this.#core()).stop(identity, options)); } - async drop(identity) { return (await this.#core()).drop(identity); } + async start(identity, context = null) { return observed(await (await this.#core(context)).start(identity)); } + async stop(identity, options, context = null) { return observed(await (await this.#core(context)).stop(identity, options)); } + async drop(identity, context = null) { return (await this.#core(context)).drop(identity); } } \ No newline at end of file diff --git a/src/runtime/providers/libvirt-persistent-environment.js b/src/runtime/providers/libvirt-persistent-environment.js index 8baaff42f..cfd1ed5fe 100644 --- a/src/runtime/providers/libvirt-persistent-environment.js +++ b/src/runtime/providers/libvirt-persistent-environment.js @@ -1,3 +1,4 @@ +import { bindInvocationToLease } from '../lease-bound-invocation.js'; import { lstat, realpath } from 'node:fs/promises'; import path from 'node:path'; import { @@ -50,7 +51,12 @@ export class LibvirtPersistentEnvironment { return new PersistentEnvironmentCore(this.#options).inspect(); } - async #core() { + async #core(context = null) { + if (context != null) { + context.assertHeld(); + const sourceRoot = await canonicalRoot(this.#options.sourceRoot); + return new PersistentEnvironmentCore({ ...this.#options, sourceRoot, invoke: bindInvocationToLease(this.#options.invoke, context) }); + } if (!this.#delegate) { const sourceRoot = await canonicalRoot(this.#options.sourceRoot); this.#delegate = new PersistentEnvironmentCore({ ...this.#options, sourceRoot }); @@ -58,16 +64,16 @@ export class LibvirtPersistentEnvironment { return this.#delegate; } - async provision(input) { + async provision(input, context = null) { preflightExecutionProfileMemory(input?.settings); await preflightExecutionProfileStoragePaths({ directory: this.#options.directory, sourceLocation: input?.source?.handle?.location, }); - return observed(await (await this.#core()).provision(input)); + return observed(await (await this.#core(context)).provision(input)); } async observe(identity) { return observed(await (await this.#core()).observe(identity)); } - async start(identity) { return observed(await (await this.#core()).start(identity)); } - async stop(identity, options) { return observed(await (await this.#core()).stop(identity, options)); } - async drop(identity) { return (await this.#core()).drop(identity); } + async start(identity, context = null) { return observed(await (await this.#core(context)).start(identity)); } + async stop(identity, options, context = null) { return observed(await (await this.#core(context)).stop(identity, options)); } + async drop(identity, context = null) { return (await this.#core(context)).drop(identity); } } \ No newline at end of file diff --git a/src/runtime/providers/windows-guest-dns.js b/src/runtime/providers/windows-guest-dns.js new file mode 100644 index 000000000..c6338a99e --- /dev/null +++ b/src/runtime/providers/windows-guest-dns.js @@ -0,0 +1,47 @@ +import { isIPv4 } from 'node:net'; + +const SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$routes = @(Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -PolicyStore ActiveStore -ErrorAction Stop | + Where-Object { [string]$_.State -eq 'Alive' } | + Sort-Object @{ Expression = { [long]$_.RouteMetric + [long]$_.InterfaceMetric } }, InterfaceIndex | + Select-Object -First 32) +$observations = @($routes | ForEach-Object { + $route = $_ + $servers = @(Get-DnsClientServerAddress -InterfaceIndex $route.InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop | + ForEach-Object { $_.ServerAddresses } | Select-Object -First 16) + @{ interfaceIndex = [int]$route.InterfaceIndex; servers = $servers } +}) +@{ routes = $observations } | ConvertTo-Json -Depth 5 -Compress +`; + +export function guestDnsServers(values) { + if (!Array.isArray(values) || values.length > 64) throw new TypeError('guest DNS server observation is invalid'); + const servers = values.filter((value) => { + if (typeof value !== 'string' || !isIPv4(value)) return false; + const [first, second] = value.split('.').map(Number); + return first !== 0 && first !== 127 && first < 224 && !(first === 169 && second === 254); + }); + return Object.freeze([...new Set(servers)].slice(0, 4)); +} + +// A host-local resolver (including Node's 127.0.0.1 fallback) is not a guest +// resolver. Inherit configured DNS from the active Windows route, without +// inventing a public DNS policy or changing host networking. +export async function observeWindowsGuestDns({ invoke }) { + const result = await invoke({ executable: 'powershell.exe', + arguments: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(SCRIPT, 'utf16le').toString('base64')], + input: null, timeoutMs: 15_000, maxOutputBytes: 64 * 1024 }); + if (!result || result.exitCode !== 0 || result.timedOut || result.aborted || result.outputTruncated) { + throw new Error(`Windows guest DNS observation failed: ${String(result?.stderr || 'native observation unavailable').trim().slice(0, 1024)}`); + } + const observation = JSON.parse(result.stdout); + if (!Array.isArray(observation?.routes) || observation.routes.length > 32) throw new Error('Windows guest DNS route observation is invalid'); + for (const route of observation.routes) { + if (!Number.isSafeInteger(route?.interfaceIndex) || route.interfaceIndex < 1) throw new Error('Windows guest DNS interface observation is invalid'); + const servers = guestDnsServers(route.servers); + if (servers.length) return servers; + } + throw new Error('Windows has no guest-reachable DNS configured on an active default route'); +} diff --git a/src/runtime/providers/windows-protected-access-material.js b/src/runtime/providers/windows-protected-access-material.js index 75025080c..3a2ef0dea 100644 --- a/src/runtime/providers/windows-protected-access-material.js +++ b/src/runtime/providers/windows-protected-access-material.js @@ -101,6 +101,7 @@ export class WindowsProtectedAccessMaterial { #platform; #entropy; #user; + #resolved = new Map(); constructor({ directory, invoke, user, platform = process.platform, entropy = () => randomBytes(32) } = {}) { if (typeof directory !== 'string' || directory.length === 0 || directory.includes('\0') || !path.isAbsolute(directory)) throw new TypeError('protected access directory is invalid'); @@ -184,17 +185,27 @@ export class WindowsProtectedAccessMaterial { const selectedIdentity = identity(rawIdentity); await this.#ensureRoot(); const record = await this.#load(selectedIdentity); - if (!record) throw new Error('protected access material is unavailable'); + if (!record) { this.#resolved.delete(selectedIdentity); throw new Error('protected access material is unavailable'); } + // The protected record remains the authority on every lookup. Only its + // unchanged decrypted value is reused inside this account's process. + const key = JSON.stringify(record); + const cached = this.#resolved.get(selectedIdentity); + if (cached?.key === key) return cached.value; + this.#resolved.delete(selectedIdentity); const encoded = await this.#invokeProtection(UNPROTECT_SCRIPT, { identity: selectedIdentity, protected: record.protectedSecret }, 'value'); let secret; try { secret = Buffer.from(encoded, 'base64').toString('utf8'); } catch { throw new Error('protected access operation returned invalid output'); } normalizeSecret(secret); if (digest(secret) !== record.secretDigest) throw new Error('protected access material integrity changed'); - return Object.freeze({ user: record.user, secret }); + const value = Object.freeze({ user: record.user, secret }); + if (this.#resolved.size >= 64) this.#resolved.delete(this.#resolved.keys().next().value); + this.#resolved.set(selectedIdentity, { key, value }); + return value; } async discard(rawIdentity) { const selectedIdentity = identity(rawIdentity); + this.#resolved.delete(selectedIdentity); await this.#ensureRoot(); const location = this.#location(selectedIdentity); let info; diff --git a/src/runtime/ssh-access-probe.js b/src/runtime/ssh-access-probe.js index 4bd45fb78..5d0a79938 100644 --- a/src/runtime/ssh-access-probe.js +++ b/src/runtime/ssh-access-probe.js @@ -12,7 +12,8 @@ export class SshAccessProbe { if (typeof selected !== 'string' || selected.length === 0) throw new TypeError('SSH access probe executable is invalid'); this.#invoke = invoke; this.#executable = selected; } - async inspect(access) { + async inspect(access, { timeoutMs = 15_000, signal } = {}) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 15_000) throw new TypeError('SSH access probe timeout is invalid'); if (!access || access.family !== 'linux' || typeof access.user !== 'string' || !USER.test(access.user) || typeof access.address !== 'string' || !ADDRESS.test(access.address)) return Object.freeze({ ready: false, reason: 'SSH access endpoint is invalid' }); let identityFile; let knownHostsFile; try { [identityFile, knownHostsFile] = await Promise.all([regular(access.identityFile, 'SSH access identityFile'), regular(access.knownHostsFile, 'SSH access knownHostsFile')]); } @@ -26,10 +27,11 @@ export class SshAccessProbe { '-o', 'UpdateHostKeys=no', '-o', 'IdentitiesOnly=yes', '-o', 'ForwardAgent=no', '-o', 'ForwardX11=no', '-o', 'ClearAllForwardings=yes', '-o', 'PermitLocalCommand=no', '-o', 'PasswordAuthentication=no', '-o', 'KbdInteractiveAuthentication=no', - '-o', 'ConnectTimeout=5', '-i', identityFile, `${access.user}@${access.address}`, 'true', + '-o', `ConnectTimeout=${Math.min(5, Math.max(1, Math.floor(timeoutMs / 1000)))}`, '-i', identityFile, `${access.user}@${access.address}`, 'true', ], input: null, - timeoutMs: 15_000, + timeoutMs, + ...(signal == null ? {} : { signal }), maxOutputBytes: 64 * 1024, }); if (!result || result.exitCode !== 0 || result.timedOut || result.aborted || result.outputTruncated) return Object.freeze({ ready: false, reason: String(result?.stderr || result?.stdout || 'SSH access probe failed').trim().slice(-1024) }); diff --git a/src/runtime/windows-file-lease.js b/src/runtime/windows-file-lease.js new file mode 100644 index 000000000..0e3a192e1 --- /dev/null +++ b/src/runtime/windows-file-lease.js @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { createProcessFileLease } from './process-file-lease.js'; + +export function createWindowsFileLease({ subjectPath, holderExecutable } = {}, options = {}) { + for (const value of [subjectPath, holderExecutable]) { + if (typeof value !== 'string' || !/^[A-Za-z]:\\/u.test(value) || /[\0\r\n]/u.test(value) || path.win32.normalize(value) !== value) { + throw new TypeError('Windows file lease requires normalized absolute local paths'); + } + } + return createProcessFileLease({ + readyLine: 'devbridge/file-lease-held-v1\n', + commandFor(mode) { + if (mode !== 'exclusive') throw new TypeError('Windows mutation lease requires exclusive mode'); + return { executable: holderExecutable, arguments: ['--hold-file-lease', subjectPath] }; + }, + }, options); +} diff --git a/src/setup/guest-bootstrap-qualification.js b/src/setup/guest-bootstrap-qualification.js new file mode 100644 index 000000000..a2ac18756 --- /dev/null +++ b/src/setup/guest-bootstrap-qualification.js @@ -0,0 +1,138 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createEnvironmentFoundation } from '../app/environment-foundation.js'; +import { createEnvironmentBootstrap } from '../app/environment-bootstrap.js'; +import { createEnvironmentBridge } from '../app/environment-bridge.js'; +import { createLocalEnvironmentAccess } from '../app/environment-construction-preparation.js'; +import { createEnvironmentMaterializationPolicy } from '../app/environment-materialization-policy.js'; +import { environmentDeclarationDigest } from '../runtime/environment-declaration.js'; +import { invokeCommand } from '../runtime/command-invocation.js'; +import { loadOrCreateLocalIdentity } from '../runtime/local-identity.js'; + +const PROTOCOL = 'devbridge/guest-bootstrap-qualification-v1'; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; +const READ_SEED = String.raw`import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +const file = process.platform === 'win32' ? 'C:\\ProgramData\\DevBridge\\bootstrap\\network-seed.json' : '/var/lib/devbridge/bootstrap/network-seed.json'; +const bytes = await readFile(file); +const seed = JSON.parse(bytes); +process.stdout.write(JSON.stringify({ digest: createHash('sha256').update(bytes).digest('hex'), target: seed.target, family: process.platform === 'win32' ? 'windows' : 'linux' }));`; + +function boundedFailure(error) { + return { message: String(error?.message ?? error).slice(0, 2048), code: error?.code ?? null, effect: error?.effect ?? null, + native: error?.evidence == null ? null : { ...error.evidence, stdout: String(error.evidence.stdout ?? '').slice(0, 2048), stderr: String(error.evidence.stderr ?? '').slice(0, 2048) } }; +} + +// This complements the disk-only protected-authority fixture. It materializes a +// distinct owned consumer of an accepted image and uses production first access. +export async function qualifyGuestBootstrap({ + stateDirectory, authorityDirectory, evidenceDirectory, declaration, runtimeIdentity, qualificationId = randomUUID(), platform = process.platform, + invoke = invokeCommand, +}, { + foundationFactory = createEnvironmentFoundation, accessFactory = createLocalEnvironmentAccess, + bootstrapFactory = createEnvironmentBootstrap, bridgeFactory = createEnvironmentBridge, + identityLoader = loadOrCreateLocalIdentity, +} = {}) { + if (!UUID.test(qualificationId)) throw new TypeError('guest qualification identity is invalid'); + if (!runtimeIdentity || Object.keys(runtimeIdentity).sort().join(',') !== 'nodeDigest,packageDigest' || Object.values(runtimeIdentity).some((value) => !/^[0-9a-f]{64}$/u.test(value))) throw new TypeError('guest qualification runtime identity is invalid'); + for (const value of [stateDirectory, authorityDirectory, evidenceDirectory]) if (typeof value !== 'string' || !path.isAbsolute(value)) throw new TypeError('guest qualification directories must be absolute'); + if (platform !== 'win32') throw new Error('this native first-access qualification currently covers Hyper-V only'); + const declarationDigest = environmentDeclarationDigest(declaration); + const family = declaration.guest.family === 'ubuntu' ? 'linux' : declaration.guest.family === 'windows-11' ? 'windows' : null; + if (family == null) throw new Error('guest qualification family is unsupported'); + const root = path.join(evidenceDirectory, qualificationId); + await mkdir(root, { recursive: true, mode: 0o700 }); + if (!(await lstat(root)).isDirectory() || (await lstat(root)).isSymbolicLink()) throw new Error('guest qualification directory must be real'); + const file = path.join(root, 'qualification.json'); + const subject = `bootstrap-qualification-${qualificationId}`; + let record; + try { + const info = await lstat(file); + if (!info.isFile() || info.isSymbolicLink() || info.size > 32 * 1024) throw new Error('guest qualification evidence is invalid'); + record = JSON.parse(await readFile(file, 'utf8')); + if (record.protocol !== PROTOCOL || record.qualificationId !== qualificationId || record.subject !== subject || record.declarationDigest !== declarationDigest + || record.runtimeIdentity?.packageDigest !== runtimeIdentity.packageDigest || record.runtimeIdentity?.nodeDigest !== runtimeIdentity.nodeDigest) throw new Error('guest qualification intent changed'); + } catch (error) { if (error?.code !== 'ENOENT') throw error; } + const save = async () => { + const temporary = path.join(root, `.qualification-${randomUUID()}.tmp`); + await writeFile(temporary, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 }); + await rename(temporary, file); + }; + let seedDigest = record?.seedDigest ?? null; + const observedInvoke = async (request) => { + // Do not record command input, access seeds, credentials, or general output. + let payload; + try { payload = JSON.parse(request.input); } catch {} + const destination = typeof payload?.source === 'string' && typeof payload?.destination === 'string' + ? family === 'linux' ? path.posix.join(payload.destination, path.basename(payload.source)) : payload.destination : null; + if (destination === (family === 'linux' ? '/var/lib/devbridge/bootstrap/network-seed.json' : 'C:\\ProgramData\\DevBridge\\bootstrap\\network-seed.json')) { + const bytes = await readFile(payload.source); + const seed = JSON.parse(bytes); + if (seed.target !== record?.target || seed.protocol !== 'devbridge/network-seed-v1') throw new Error('qualification network seed subject changed'); + seedDigest = createHash('sha256').update(bytes).digest('hex'); + record.seedDigest = seedDigest; + await save(); + } + return invoke(request); + }; + const foundation = await foundationFactory({ stateDirectory: authorityDirectory, platform, invoke: observedInvoke }); + if (!record) { + const existing = (await foundation.listEnvironments()).filter((entry) => entry.record.subject === subject); + if (existing.length) throw new Error('fresh guest qualification subject already exists'); + record = { protocol: PROTOCOL, qualificationId, subject, declarationDigest, profile: declaration.profile, family, + runtimeIdentity: { ...runtimeIdentity }, image: { ...declaration.image }, target: null, stage: 'intent', freshSubjectObserved: true, seedDigest: null, passed: false, cleanup: 'pending', failure: null }; + await writeFile(file, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 }); + } + if (record.cleanup === 'complete') return Object.freeze(record); + try { + const image = await foundation.verifyImage(declaration.image.identity); + const accepted = await foundation.observeImage(declaration.image.identity); + if (image?.identity !== declaration.image.identity || image.verified !== true || image.usable !== true + || accepted?.identity !== declaration.image.identity || accepted.usable !== true || accepted.entry?.generation !== declaration.image.generation) throw new Error('qualification requires the exact verified accepted image'); + record.imageDigest = accepted.entry.digest; + const access = await accessFactory({ stateDirectory, authorityDirectory, platform, invoke: observedInvoke, guest: declaration.guest }); + const bootstrap = await bootstrapFactory({ stateDirectory, authorityDirectory, platform, invoke: observedInvoke, + access: (target) => access.connection(target), prepareAccess: access.prepare, + requirements: declaration.bootstrap.requirements, revision: declaration.bootstrap.generation }); + if (!record.passed) { + record.stage = 'materialization'; await save(); + const settings = await createEnvironmentMaterializationPolicy().settings.resolve(declaration); + const current = await foundation.ensureEnvironment({ subject, profile: declaration.profile, sourceIdentity: declaration.image.identity, settings }); + if (current?.record?.subject !== subject || current.record.source?.identity !== declaration.image.identity || (record.target != null && current.record.identity !== record.target)) throw new Error('guest qualification materialization identity changed'); + record.target = current.record.identity; record.stage = 'first-access'; await save(); + const prepared = await bootstrap.ensure(record.target); + if (prepared?.ready !== true) throw new Error('fresh guest bootstrap did not become ready'); + const foundationIdentity = await identityLoader({ directory: path.join(authorityDirectory, 'environment-foundation') }); + const bridge = await bridgeFactory({ stateDirectory, foundationIdentity, platform, invoke: observedInvoke, access: (target) => bootstrap.connection(target) }); + const health = await bridge.health(record.target); + if (health.ready !== true) throw new Error('fresh guest bridge is not ready'); + const outcome = await bridge.execute(record.target, { program: family === 'windows' ? 'node.exe' : 'node', + arguments: ['--input-type=module', '-e', READ_SEED], directory: { class: 'scratch', path: '.' }, environment: {}, + input: null, timeoutMs: 30_000, maxOutputBytes: 4096 }, { pollIntervalMs: 500 }); + const result = outcome?.result; + if (outcome?.completion !== 'observed' || result?.exitCode !== 0 || result.timedOut || result.aborted || result.outputTruncated || result.stderr) throw new Error(`guest seed readback failed: ${String(result?.stderr ?? '').slice(0, 1024)}`); + const readback = JSON.parse(result.stdout); + if (!seedDigest || readback.digest !== seedDigest || readback.target !== record.target || readback.family !== family) throw new Error('fresh guest seed path or contents do not match delivery'); + record.passed = true; record.stage = 'verified'; record.bridgeVersion = health.version; record.failure = null; await save(); + } + const retained = (await foundation.listEnvironments()).filter((entry) => entry.record.subject === subject); + if (retained.length > 1 || (retained.length === 0 && record.cleanup !== 'removing')) throw new Error('guest qualification cleanup subject is missing or ambiguous'); + if (retained.length === 1) { + const current = retained[0]; + if (current.record.identity !== record.target || current.record.profile !== record.profile || current.record.source.identity !== record.image.identity) throw new Error('guest qualification cleanup subject changed'); + record.cleanup = 'removing'; await save(); + if (current.observation.exists) await foundation.stopEnvironment(record.target, { force: false, timeoutMs: 60_000 }); + await foundation.removeEnvironment(record.target); + } + await access.discard(record.target); + await bootstrap.reconcile(); + record.cleanup = 'complete'; await save(); + } catch (error) { + record.failure = boundedFailure(error); await save(); + // Preserve a failed fixture and its evidence for owned recovery. The caller + // must keep the old authority quiescent while running this candidate. + throw Object.assign(new Error(`guest bootstrap qualification failed at ${record.stage}: ${record.failure.message}`, { cause: error }), { qualificationId, evidenceFile: file }); + } + return Object.freeze(record); +} diff --git a/src/setup/linux-lifecycle-authority-refresh-composition.js b/src/setup/linux-lifecycle-authority-refresh-composition.js index 5f0201d49..a55e407ff 100644 --- a/src/setup/linux-lifecycle-authority-refresh-composition.js +++ b/src/setup/linux-lifecycle-authority-refresh-composition.js @@ -6,6 +6,8 @@ import { createConfiguredLifecycleAuthorityClient } from '../runtime/environment import { createConfiguredEnvironmentActivityClient } from '../runtime/environment-activity-authority-transport.js'; import { createConfiguredEnvironmentConfigurationClient } from '../runtime/environment-configuration-authority-transport.js'; import { invokeCommand } from '../runtime/command-invocation.js'; +import { migrateLegacyFileGuard } from '../runtime/legacy-file-guard.js'; +import { createLinuxFileLease } from '../runtime/linux-file-lease.js'; import { applyLinuxDirectoryDefinition } from './linux-directory-definition-applicator.js'; import { bindLinuxLifecycleAuthorityIdentity } from './linux-lifecycle-authority-identity-binding.js'; import { reconcileLinuxLifecycleAuthorityEndpointTopology } from './linux-lifecycle-authority-endpoint-topology.js'; @@ -548,6 +550,15 @@ export async function createLinuxLifecycleAuthorityRefreshComposition({ if (subject == null) throw new Error('Linux lifecycle authority accepted definition subject is unavailable'); accepted.push(subject.plan.service.unit); } + const guardDirectory = path.join(selected.authorityDirectory, 'environment-foundation', 'persistent', 'registry'); + await migrateLegacyFileGuard({ + guardFile: path.join(guardDirectory, 'lifecycle.lock'), + lease: { acquire: (request) => createLinuxFileLease({ subjectPath: path.join(guardDirectory, 'lifecycle.lease') }).acquire(request) }, + async assertQuiescent() { + const observed = await activity.inspect({ generations: [...new Set([generation, ...value.acceptedGenerations])] }); + if (observed.running || observed.processGeneration != null) throw new Error('legacy lifecycle guard migration requires the exact quiescent service'); + }, + }); const result = await ensureDefinition({ name: selected.service.name, path: selected.service.unitPath, diff --git a/src/setup/serial-reconciliation.js b/src/setup/serial-reconciliation.js index 5eb0155d1..c4899b1b9 100644 --- a/src/setup/serial-reconciliation.js +++ b/src/setup/serial-reconciliation.js @@ -33,16 +33,24 @@ function result({ ready, changed, state, item = null, completedCount, totalCount return Object.freeze({ ready, changed, state, item, completedCount, totalCount, blocker }); } -export async function reconcileSerialSelection({ items: rawItems, reconcile } = {}) { +export async function reconcileSerialSelection({ items: rawItems, reconcile, independent = false } = {}) { const selected = items(rawItems); if (typeof reconcile !== 'function') throw new TypeError('serial reconciliation contract is incomplete'); + if (typeof independent !== 'boolean') throw new TypeError('serial reconciliation independence must be boolean'); let completedCount = 0; + let changed = false; + let lastChanged = null; + let blocked = null; for (const item of selected) { const observed = observation(await reconcile(item)); + changed ||= observed.changed; + if (observed.changed) lastChanged = item; if (!observed.ready) { + blocked ??= { item, blocker: observed.blocker }; + if (independent) continue; return result({ ready: false, - changed: observed.changed, + changed, state: 'blocked', item, completedCount, @@ -51,17 +59,6 @@ export async function reconcileSerialSelection({ items: rawItems, reconcile } = }); } completedCount += 1; - if (observed.changed) { - const ready = completedCount === selected.length; - return result({ - ready, - changed: true, - state: ready ? 'ready' : 'pending', - item, - completedCount, - totalCount: selected.length, - }); - } } - return result({ ready: true, changed: false, state: 'ready', completedCount, totalCount: selected.length }); + return result({ ready: blocked == null, changed, state: blocked == null ? 'ready' : 'blocked', item: blocked?.item ?? lastChanged, completedCount, totalCount: selected.length, blocker: blocked?.blocker ?? null }); } diff --git a/src/setup/windows-lifecycle-authority-host.cs b/src/setup/windows-lifecycle-authority-host.cs index 2ea69b826..c553a8474 100644 --- a/src/setup/windows-lifecycle-authority-host.cs +++ b/src/setup/windows-lifecycle-authority-host.cs @@ -241,6 +241,8 @@ internal sealed class LifecycleAuthorityService : ServiceBase private Thread configurationThread; private Process activeWorker; private WorkerJob activeWorkerJob; + private Process activityWorker; + private WorkerJob activityWorkerJob; private sealed class WorkerResponse { @@ -314,6 +316,7 @@ private void StopAuthority() } lock (workerLock) { + StopActivityWorker(); if (activeWorkerJob != null) { activeWorkerJob.Dispose(); @@ -391,10 +394,15 @@ private void Serve(string name, string access) { pipe.Write(response.Bytes, 0, response.Bytes.Length); pipe.Flush(); - if (!ReadResponseAcknowledgement(pipe, response.ClientMonitor, response.ClientProbe)) continue; + if (!ReadResponseAcknowledgement(pipe, response.ClientMonitor, response.ClientProbe)) + { + if (String.Equals(access, "activity", StringComparison.Ordinal)) StopActivityWorker(); + continue; + } } catch (IOException) { + if (String.Equals(access, "activity", StringComparison.Ordinal)) StopActivityWorker(); continue; } finally @@ -610,10 +618,9 @@ private static WorkerResponse ReadActivityWorkerResponse(Process worker, NamedPi if (count <= 0) break; stdout.Write(buffer, 0, count); if (stdout.Length > maxResponseBytes) return null; + if (Array.IndexOf(buffer, (byte)10, 0, count) >= 0) break; } - int exitRemaining = ActivityWorkerTimeoutMs - (int)elapsed.ElapsedMilliseconds; - if (exitRemaining <= 0 || clientMonitor.IsCompleted || !worker.WaitForExit(exitRemaining) || clientMonitor.IsCompleted) return null; - if (worker.ExitCode != 0) return null; + if (clientMonitor.IsCompleted) return null; byte[] response = ExactWorkerResponse(stdout.ToArray()); if (response == null) return null; WorkerResponse result = new WorkerResponse(response, clientMonitor, clientProbe); @@ -631,12 +638,33 @@ private static WorkerResponse ReadActivityWorkerResponse(Process worker, NamedPi } } + private void StopActivityWorker() + { + lock (workerLock) + { + if (activityWorkerJob != null) { activityWorkerJob.Dispose(); activityWorkerJob = null; } + if (activityWorker != null) + { + try { if (!activityWorker.HasExited) activityWorker.Kill(); } catch { } + activityWorker.Dispose(); + activityWorker = null; + } + } + } + private WorkerResponse InvokeWorker(string access, byte[] request, int maxResponseBytes, NamedPipeServerStream clientPipe) { workerGate.Wait(); try { if (stopping) return null; + bool reusable = String.Equals(access, "activity", StringComparison.Ordinal); + if (String.Equals(access, "mutation", StringComparison.Ordinal)) StopActivityWorker(); + if (reusable && activityWorker != null) + { + if (activityWorker.HasExited) StopActivityWorker(); + else return ExchangeActivityWorker(request, clientPipe, maxResponseBytes); + } ProcessStartInfo start = new ProcessStartInfo(); start.FileName = options.NodeExecutable; start.Arguments = String.Join(" ", new string[] { @@ -645,6 +673,7 @@ private WorkerResponse InvokeWorker(string access, byte[] request, int maxRespon "--state-directory", QuoteArgument(options.StateDirectory), "--authority-directory", QuoteArgument(options.AuthorityDirectory) }); + if (reusable) start.Arguments += " --activity-stream v1"; start.WorkingDirectory = options.ProtectedRoot; start.UseShellExecute = false; start.CreateNoWindow = true; @@ -673,12 +702,21 @@ private WorkerResponse InvokeWorker(string access, byte[] request, int maxRespon activeWorker = worker; activeWorkerJob = job; } + if (reusable) + { + lock (workerLock) + { + activityWorker = worker; + activityWorkerJob = job; + activeWorker = null; + activeWorkerJob = null; + } + return ExchangeActivityWorker(request, clientPipe, maxResponseBytes); + } worker.StandardInput.BaseStream.Write(request, 0, request.Length); worker.StandardInput.BaseStream.Flush(); worker.StandardInput.Close(); - if (String.Equals(access, "activity", StringComparison.Ordinal)) - return ReadActivityWorkerResponse(worker, clientPipe, maxResponseBytes); byte[] response = ReadWorkerResponse(worker, maxResponseBytes); return response == null ? null : new WorkerResponse(response); } @@ -689,20 +727,71 @@ private WorkerResponse InvokeWorker(string access, byte[] request, int maxRespon if (Object.ReferenceEquals(activeWorker, worker)) activeWorker = null; if (Object.ReferenceEquals(activeWorkerJob, job)) activeWorkerJob = null; } - if (job != null) job.Dispose(); - worker.Dispose(); + if (!reusable || !Object.ReferenceEquals(activityWorker, worker)) + { + if (job != null) job.Dispose(); + worker.Dispose(); + } } } finally { workerGate.Release(); } } + + private WorkerResponse ExchangeActivityWorker(byte[] request, NamedPipeServerStream clientPipe, int maxResponseBytes) + { + WorkerResponse response = null; + try + { + Process worker = activityWorker; + worker.StandardInput.BaseStream.Write(request, 0, request.Length); + worker.StandardInput.BaseStream.Flush(); + response = ReadActivityWorkerResponse(worker, clientPipe, maxResponseBytes); + return response; + } + catch (IOException) { return null; } + finally { if (response == null) StopActivityWorker(); } + } } internal static class Program { + private static int HoldFileLease(string target) + { + if (String.IsNullOrWhiteSpace(target) || target.IndexOf('\0') >= 0 || !Regex.IsMatch(target, @"^[A-Za-z]:\\")) return 1; + foreach (string part in target.Split('\\')) if (part == "." || part == "..") return 1; + // .NET expands existing 8.3 components. They name the same local + // subject and must not be mistaken for an unnormalized request. + target = Path.GetFullPath(target); + if (File.Exists(target) && (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) return 1; + FileStream held = null; + Stopwatch clock = Stopwatch.StartNew(); + while (held == null) + { + try { held = new FileStream(target, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException error) + { + int code = error.HResult & 0xffff; + if (code != 32 && code != 33) throw; + if (clock.ElapsedMilliseconds >= 30000) return 75; + Thread.Sleep(50); + } + } + using (held) + { + Console.OutputEncoding = new UTF8Encoding(false); + Console.Write("devbridge/file-lease-held-v1\n"); + Console.Out.Flush(); + // EOF releases ownership even when the requesting worker crashes. + if (Console.OpenStandardInput().ReadByte() != -1) return 1; + } + return 0; + } + private static int Main(string[] args) { try { + if (args.Length == 2 && String.Equals(args[0], "--hold-file-lease", StringComparison.Ordinal)) return HoldFileLease(args[1]); HostOptions options = HostOptions.Parse(args); ServiceBase.Run(new LifecycleAuthorityService(options)); return 0; diff --git a/src/setup/windows-lifecycle-authority-service.js b/src/setup/windows-lifecycle-authority-service.js index 8585985c2..3748d4fd1 100644 --- a/src/setup/windows-lifecycle-authority-service.js +++ b/src/setup/windows-lifecycle-authority-service.js @@ -12,6 +12,8 @@ import process from 'node:process'; import { setTimeout as wait } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { invokeCommand } from '../runtime/command-invocation.js'; +import { migrateLegacyFileGuard } from '../runtime/legacy-file-guard.js'; +import { createWindowsFileLease } from '../runtime/windows-file-lease.js'; import { createConfiguredLifecycleAuthorityClient } from '../runtime/environment-lifecycle-authority-transport.js'; import { createConfiguredEnvironmentActivityClient } from '../runtime/environment-activity-authority-transport.js'; import { createConfiguredEnvironmentConfigurationClient } from '../runtime/environment-configuration-authority-transport.js'; @@ -956,6 +958,20 @@ async function configureServiceGeneration({ generation, previousGeneration }, co throw new Error('Windows lifecycle authority previous service disappeared before promotion'); } + // The service host owns its workers' lifetime. Only this installer transition, + // after exact service quiescence, may retire a legacy token guard. + const guardDirectory = path.join(target.plan.authorityDirectory, 'environment-foundation', 'persistent', 'registry'); + await migrateLegacyFileGuard({ + guardFile: path.join(guardDirectory, 'lifecycle.lock'), + lease: { acquire: (request) => createWindowsFileLease({ subjectPath: path.join(guardDirectory, 'lifecycle.lease'), holderExecutable: target.plan.runtime.serviceHostExecutable }).acquire(request) }, + async assertQuiescent() { + const observed = await inspectService(context.candidatePlan, context.invoke, context.environment); + if (observed.exists !== service.exists || (observed.exists && (!sameWindowsText(observed.pathName, service.pathName) + || !sameWindowsText(observed.startName, service.startName) || observed.description !== service.description || !sameWindowsText(observed.state, 'Stopped')))) { + throw new Error('legacy lifecycle guard migration requires the exact quiescent service'); + } + }, + }); const firstConfiguration = ownership.serviceConfigured !== true; await configureService(service, target.plan, context.invoke, context.environment); if (firstConfiguration) { diff --git a/src/state/environment-lifecycle-state-store.js b/src/state/environment-lifecycle-state-store.js index 986a02393..6bf402477 100644 --- a/src/state/environment-lifecycle-state-store.js +++ b/src/state/environment-lifecycle-state-store.js @@ -28,5 +28,6 @@ export function createEnvironmentLifecycleStateStore(filePath) { return Object.freeze({ declarations: prefixedPort(store, 'declaration'), journal: prefixedPort(store, 'journal'), + diagnostics: prefixedPort(store, 'diagnostic'), }); } diff --git a/src/state/json-record-file.js b/src/state/json-record-file.js index 3f0fbf91c..1ee4f67f5 100644 --- a/src/state/json-record-file.js +++ b/src/state/json-record-file.js @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; function normalizeDocument(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -27,9 +28,11 @@ async function readExact(target) { } } -export function createJsonRecordFile(target, { identifier = randomUUID } = {}) { +export function createJsonRecordFile(target, { identifier = randomUUID, renameFile = rename, + wait = delay, platform = process.platform } = {}) { if (typeof target !== 'string' || target.length === 0 || target.includes('\0')) throw new TypeError('record file target is invalid'); if (typeof identifier !== 'function') throw new TypeError('record file identity dependency is invalid'); + if (typeof renameFile !== 'function' || typeof wait !== 'function') throw new TypeError('record file replacement dependencies are invalid'); const resolved = path.resolve(target); return Object.freeze({ @@ -41,6 +44,7 @@ export function createJsonRecordFile(target, { identifier = randomUUID } = {}) { await mkdir(path.dirname(resolved), { recursive: true }); const temporary = `${resolved}.${suffix}.tmp`; const expected = `${JSON.stringify(document, null, 2)}\n`; + const previous = await readExact(resolved); let handle = null; try { handle = await open(temporary, 'wx', 0o600); @@ -48,10 +52,21 @@ export function createJsonRecordFile(target, { identifier = randomUUID } = {}) { await handle.sync(); await handle.close(); handle = null; - try { - await rename(temporary, resolved); - } catch (error) { - if (await readExact(resolved) !== expected) throw error; + const retryDelays = [25, 75, 150, 300]; + for (let attempt = 0; ; attempt++) { + try { + await renameFile(temporary, resolved); + break; + } catch (error) { + const current = await readExact(resolved); + if (current === expected) break; + if (current !== previous) throw new Error('record file changed during interrupted replacement', { cause: error }); + if (platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error?.code) || attempt >= retryDelays.length) throw error; + await wait(retryDelays[attempt]); + const observed = await readExact(resolved); + if (observed === expected) break; + if (observed !== previous) throw new Error('record file changed before replacement retry', { cause: error }); + } } if (await readExact(resolved) !== expected) throw new Error('record file replacement did not re-observe exactly'); } finally { diff --git a/src/state/json-state-store.js b/src/state/json-state-store.js index 0c1fbe325..64c5308bd 100644 --- a/src/state/json-state-store.js +++ b/src/state/json-state-store.js @@ -1,27 +1,19 @@ -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import path from 'node:path'; +import { createJsonRecordFile } from './json-record-file.js'; export class JsonStateStore { - #filePath; - #loaded = false; + #file; + #loading = null; #data = {}; #writeChain = Promise.resolve(); - constructor(filePath) { - this.#filePath = filePath; + constructor(filePath, { file = createJsonRecordFile(filePath) } = {}) { + if (!file || typeof file.read !== 'function' || typeof file.replace !== 'function') throw new TypeError('JSON state file contract is incomplete'); + this.#file = file; } async #load() { - if (this.#loaded) return; - try { - const text = await readFile(this.#filePath, 'utf8'); - const parsed = JSON.parse(text); - this.#data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - this.#data = {}; - } - this.#loaded = true; + if (!this.#loading) this.#loading = this.#file.read().then(value => { this.#data = value; }).catch(error => { this.#loading = null; throw error; }); + await this.#loading; } async get(key) { @@ -38,23 +30,23 @@ export class JsonStateStore { async set(key, value) { await this.#load(); - this.#data[key] = structuredClone(value); - return this.#queueWrite(); + const selected = structuredClone(value); + return this.#queueWrite(data => { data[key] = selected; }); } async delete(key) { await this.#load(); - delete this.#data[key]; - return this.#queueWrite(); + return this.#queueWrite(data => { delete data[key]; }); } - async #queueWrite() { - this.#writeChain = this.#writeChain.then(async () => { - await mkdir(path.dirname(this.#filePath), { recursive: true }); - const temp = `${this.#filePath}.${process.pid}.${Date.now()}.tmp`; - await writeFile(temp, `${JSON.stringify(this.#data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); - await rename(temp, this.#filePath); + async #queueWrite(change) { + const operation = this.#writeChain.then(async () => { + const next = structuredClone(this.#data); + change(next); + await this.#file.replace(next); + this.#data = next; }); - return this.#writeChain; + this.#writeChain = operation.catch(() => {}); + return operation; } } diff --git a/test-support/mutation-lease.js b/test-support/mutation-lease.js new file mode 100644 index 000000000..6256cc48a --- /dev/null +++ b/test-support/mutation-lease.js @@ -0,0 +1,16 @@ +const active = new Map(); + +export function mutationLease(subject) { + return { + async acquire() { + if (active.has(subject)) return null; + let held = true; + const result = { + assertHeld() { if (!held || active.get(subject) !== result) throw new Error('mutation lease lost'); }, + async release() { held = false; if (active.get(subject) === result) active.delete(subject); }, + }; + active.set(subject, result); + return result; + }, + }; +} diff --git a/test/app-repository-execution.test.js b/test/app-repository-execution.test.js index b26d5f795..8c13856ee 100644 --- a/test/app-repository-execution.test.js +++ b/test/app-repository-execution.test.js @@ -12,6 +12,7 @@ import { REPOSITORY_EXECUTION_REQUEST_PROTOCOL } from '../src/runtime/repository import { WorkerExchange } from '../src/runtime/worker-exchange.js'; import { lifecycleRoundtripDiagnosticProfile } from '../src/runtime/builtin-tool-profiles.js'; import { LIFECYCLE_ROUNDTRIP_NONCE } from '../src/runtime/lifecycle-roundtrip-probe.js'; +import { executionWorkspaceTarget } from '../src/app/execution-profile-routing.js'; async function command(program,args,{cwd,input=null,env=process.env}={}){return new Promise((resolve,reject)=>{const child=spawn(program,args,{cwd,env,shell:false,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';child.stdout.on('data',c=>stdout+=c);child.stderr.on('data',c=>stderr+=c);child.once('error',reject);child.once('exit',(code,signal)=>resolve({exitCode:code,signal,stdout,stderr}));if(input==null)child.stdin.end();else child.stdin.end(input);});} async function initGit(root){await command('git',['init','-q'],{cwd:root});await command('git',['config','user.name','Host'],{cwd:root});await command('git',['config','user.email','host@localhost'],{cwd:root});await command('git',['add','-A'],{cwd:root});await command('git',['commit','-q','-m','base'],{cwd:root});} @@ -26,6 +27,32 @@ function localChannel(root){const classes={};for(const name of ['input','work',' function request(args,{tool='node',operation='test.operation',environment={CI:'1'}}={}){return{protocol:REPOSITORY_EXECUTION_REQUEST_PROTOCOL,operation,scope:{repository:'owner/repo',repositoryId:'123',runId:'run-1'},invocation:{tool,arguments:args,workingDirectory:'.'},environment,transfers:[],limits:{timeoutMs:120000,maxOutputBytes:1024*1024},stdin:null,signal:null,onActivity:null};} +test('protected execution reobserves its exact route without repeating the global inventory', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-selected-route-')); + t.after(() => rm(root, { recursive: true, force: true })); + const target = executionWorkspaceTarget('123', 'linux-dev'); + let lists = 0, observations = 0, preparations = 0, compatible = true; + const entry = () => ({ record: { identity: target, subject: '123', profile: 'linux-dev' }, + observation: { exists: true, owned: true, compatible, reason: compatible ? null : 'selected route changed' } }); + const execution = await createRepositoryExecution({ + stateDirectory: path.join(root, 'state'), + routes: { protocol: ENVIRONMENT_ACTIVITY_POLICY_PROTOCOL, routes: [{ subject: '123', profile: 'linux-dev' }] }, + rootFor: async () => root, listPaths: async () => [], resolveSubject: async () => '123', + resolveTool: async () => ({ program: 'node', arguments: [] }), + activity: { + inspect: async () => ({ ready: true, identity: 'f'.repeat(32) }), + list: async () => { assert.equal(++lists, 1, 'global inventory is startup work'); return [entry()]; }, + observe: async selected => { assert.equal(selected, target); observations++; return entry(); }, + prepare: async selected => { assert.equal(selected, target); preparations++; throw new Error('selected preparation fixture'); }, + exchange: async () => { throw new Error('unexpected guest effect'); }, + }, + }); + await assert.rejects(() => execution.execute(request(['--version'])), /selected preparation fixture/u); + compatible = false; + await assert.rejects(() => execution.execute(request(['--version'])), /selected route changed/u); + assert.deepEqual({ lists, observations, preparations }, { lists: 1, observations: 2, preparations: 1 }); +}); + test('route policy accepts only stable numeric subjects and one validation environment',()=>{assert.throws(()=>normalizeEnvironmentActivityPolicy({protocol:ENVIRONMENT_ACTIVITY_POLICY_PROTOCOL,routes:[{subject:'owner-repo',profile:'linux'}]}),/numeric stable identity/u);assert.throws(()=>normalizeEnvironmentActivityPolicy({protocol:ENVIRONMENT_ACTIVITY_POLICY_PROTOCOL,routes:[{subject:'1',profile:'left',validation:true},{subject:'2',profile:'right',validation:true}]}),/multiple validation routes/u);}); test('production composition round-trips Node/CMake/CTest candidate bytes and preserves ignored guest state across source resync',async()=>{const temp=await mkdtemp(path.join(os.tmpdir(),'db-stage6-app-'));const host=path.join(temp,'host');const guest=path.join(temp,'guest');try{await mkdir(host);await writeFile(path.join(host,'.gitignore'),'build/\n');await writeFile(path.join(host,'a.txt'),'alpha\n');await writeFile(path.join(host,'CMakeLists.txt'),'cmake_minimum_required(VERSION 3.20)\nproject(BridgeFlow NONE)\nenable_testing()\nadd_test(NAME bridge COMMAND "${CMAKE_COMMAND}" -E echo bridge-passed)\n');await initGit(host);const entry={record:{identity:'env-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',subject:'123',profile:'linux-dev'},observation:{exists:true,owned:true,compatible:true,state:'running'}};const fakeState={inspect:async()=>({ready:true,identity:'f'.repeat(32),reason:null}),listEnvironments:async()=>[entry],observeEnvironment:async()=>entry};const channel=localChannel(guest);const routes={protocol:ENVIRONMENT_ACTIVITY_POLICY_PROTOCOL,routes:[{subject:'123',profile:'linux-dev',preferred:true}]};const execution=await createRepositoryExecution({stateDirectory:path.join(temp,'state'),routes,protectedValues:['secret-sentinel'],rootFor:async()=>host,listPaths:async(root)=>visible(root),resolveSubject:async(scope)=>scope.repositoryId,resolveTool:async(tool)=>({program:tool,arguments:[]}),createState:async()=>fakeState,createPreparation:async()=>({ensure:async()=>({generation:'b'.repeat(64)})}),createChannel:async()=>channel});assert.equal(execution.inspect().ready,true); diff --git a/test/command-channel.test.js b/test/command-channel.test.js new file mode 100644 index 000000000..f5846663c --- /dev/null +++ b/test/command-channel.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openJsonCommandChannel } from '../src/runtime/command-channel.js'; + +const echo = `const readline = require('node:readline'); let count = 0; readline.createInterface({input:process.stdin}).on('line', line => process.stdout.write(JSON.stringify({value:JSON.parse(line),count:++count,pid:process.pid})+'\\n'));`; +const channel = (code = echo, options = {}) => openJsonCommandChannel({ executable: process.execPath, arguments: ['-e', code], ...options }); + +test('one bounded process serves sequential requests without replay or reconnect', async t => { + const current = channel(echo, { idleMs: 0 }); t.after(() => current.close()); + const first = await current.exchange({ subject: 'a', bytes: '☃' }); + const second = await current.exchange({ subject: 'b' }); + assert.deepEqual(first.value, { subject: 'a', bytes: '☃' }); + assert.equal(first.pid, second.pid); + assert.equal(second.count, 2); + current.close(); + await assert.rejects(current.exchange({}), { code: 'CLOSED' }); +}); + +test('request bounds and overlapping callers fail without sending another effect', async t => { + const current = channel(`setTimeout(() => process.stdout.write('{}\\n'), 200); process.stdin.resume();`, { inputLimit: 32 }); + t.after(() => current.close()); + await assert.rejects(current.exchange({ data: 'a'.repeat(33) }), /exceeded its bound/); + const first = current.exchange({}); + await assert.rejects(current.exchange({}), /active request/); + assert.deepEqual(await first, {}); +}); + +test('process loss and malformed/oversized responses close the channel and preserve bounded evidence', async t => { + for (const [code, expected] of [ + [`process.stdin.once('data', () => { process.stderr.write('native failure'); process.exit(7); });`, 'DISCONNECTED'], + [`process.stdin.once('data', () => process.stdout.write('{}\\n{}\\n'));`, 'PROTOCOL'], + [`process.stdin.once('data', () => process.stdout.write('x'.repeat(128)));`, 'OUTPUT_LIMIT'], + ]) { + const current = channel(code, { outputLimit: 64 }); t.after(() => current.close()); + await assert.rejects(current.exchange({}), error => { + assert.equal(error.code, expected); + if (expected === 'DISCONNECTED') { assert.equal(error.native.exitCode, 7); assert.equal(error.native.stderr, 'native failure'); } + if (expected === 'OUTPUT_LIMIT') assert.equal(error.native.outputTruncated, true); + return true; + }); + await assert.rejects(current.exchange({}), { code: expected }); + } +}); + +test('deadline and owner cancellation terminate pending work without retry', async t => { + const stalled = `process.stdin.resume();`; + const timed = channel(stalled, { timeoutMs: 100 }); t.after(() => timed.close()); + await assert.rejects(timed.exchange({}), error => error.code === 'TIMEOUT' && error.native.timedOut); + const controller = new AbortController(); + const cancelled = channel(stalled, { signal: controller.signal }); t.after(() => cancelled.close()); + const pending = cancelled.exchange({}); + controller.abort(); + await assert.rejects(pending, error => error.code === 'ABORTED' && error.native.aborted); +}); diff --git a/test/controller-plan.test.js b/test/controller-plan.test.js index d220d189b..c1ab1f20b 100644 --- a/test/controller-plan.test.js +++ b/test/controller-plan.test.js @@ -18,6 +18,40 @@ function basePlan(overrides = {}) { }; } +test('restart preserves an observed operation and rejects conflicting plan identity before effects', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-controller-reuse-')); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const create = () => new ControllerPlanExecutor({ + operationRegistry: { validate() {}, execute: async operation => { + calls.push(operation); return { exitCode: 0, stdout: operation, stderr: '' }; + } }, processRunner: {}, workspaceManager: { validate: async () => ({ changedFiles: [] }) }, + }); + const plan = normalizeControllerPlan(basePlan({ operations: [ + { id: 'first', operation: 'fixture.first', params: {} }, + { id: 'second', operation: 'fixture.second', params: {} }, + ], assertions: [{ kind: 'stdout-contains', operation: 'first', value: 'fixture.first' }] })); + const workspace = { worktreeDir: root }; + let committed, crashed = false; + const state = {}; + await assert.rejects(create().execute({ plan, state, workspace, persist: async () => { + if (crashed) throw new Error('process lost'); + committed = structuredClone(state); + if (committed.controllerPlan.operations[0]?.state === 'observed') { crashed = true; throw new Error('process lost'); } + } }), /process lost/); + const previous = structuredClone(committed.controllerPlan.operations[0]); + const conflicting = normalizeControllerPlan(basePlan({ operations: [{ id: 'first', operation: 'fixture.changed', params: {} }] })); + await assert.rejects(create().execute({ plan: conflicting, state: structuredClone(committed), workspace, persist: async () => {} }), /not bound to the accepted plan/); + assert.deepEqual(calls, ['fixture.first']); + // Existing v1 runs can use their accepted plan receipt without rekeying state. + delete committed.controllerPlan.planDigest; + committed.prior = { receipt: { controllerPlanSha256: controllerPlanDigest(plan) } }; + await create().execute({ plan, state: committed, workspace, persist: async () => {} }); + assert.deepEqual(calls, ['fixture.first', 'fixture.second']); + assert.deepEqual(committed.controllerPlan.operations[0], previous); + assert.equal(committed.controllerPlan.operations[1].attempts, 1); +}); + test('normalizes a bounded controller plan and produces a stable digest', () => { const plan = normalizeControllerPlan(basePlan({ baselineChannel: 'testing', diff --git a/test/environment-activity-host.test.js b/test/environment-activity-host.test.js index ff578d3a1..5698d3e87 100644 --- a/test/environment-activity-host.test.js +++ b/test/environment-activity-host.test.js @@ -32,7 +32,7 @@ test('protected activity composition binds exact environment declarations withou platform: 'win32', invoke: async () => { throw new Error('unexpected invocation'); }, state: { - inspect: async () => ({ ready: true, identity: 'b'.repeat(32) }), + inspect: async () => { throw new Error('unrelated image inspection must not run during selected guest activity'); }, listEnvironments: async () => [structuredClone(record)], observeEnvironment: async (target) => { assert.equal(target, PHYSICAL); return structuredClone(record); }, }, diff --git a/test/environment-activity-runtime.test.js b/test/environment-activity-runtime.test.js index 78edae9c5..276cc03d2 100644 --- a/test/environment-activity-runtime.test.js +++ b/test/environment-activity-runtime.test.js @@ -47,6 +47,30 @@ function response(frame, body) { return { protocol: ENVIRONMENT_BRIDGE_PROTOCOL, request: frame.request, target: frame.target, kind: frame.kind, ok: true, body }; } +test('activity reuses a proved attachment only while committed generation and declaration stay current', async () => { + const selected = state(); + let revision = 1, declaration = 1, nativeReads = 0, exchanges = 0, fail = false; + const originalList = selected.listEnvironments; + selected.listEnvironments = async () => { nativeReads++; return originalList(); }; + selected.readEnvironmentRecords = async () => ({ revision, records: [(await originalList())[0].record] }); + let currentPolicy = policy(); + const runtime = createEnvironmentActivityRuntime({ state: selected, + loadPolicy: async () => currentPolicy, authorityBinding: async () => declaration, + preparation: { ensure: async () => ({ generation: PHYSICAL }) }, + exchange: async frame => { exchanges++; if (fail) throw new Error('connection lost'); return response(frame, { version: '1.0.0', features: [] }); }, + }); + const frame = { protocol: ENVIRONMENT_BRIDGE_PROTOCOL, request: '1'.repeat(32), target: LOGICAL, kind: 'health', body: {} }; + await runtime.exchange(frame); await runtime.exchange(frame); + assert.equal(nativeReads, 1); + revision++; await runtime.exchange(frame); assert.equal(nativeReads, 2); + declaration++; await runtime.exchange(frame); assert.equal(nativeReads, 3); + fail = true; await assert.rejects(runtime.exchange(frame), /connection lost/); + fail = false; await runtime.exchange(frame); assert.equal(nativeReads, 4); + currentPolicy = policy('43'); + await assert.rejects(runtime.exchange(frame), /not admitted/); + assert.equal(exchanges, 6); +}); + test('protected activity maps an accepted logical target and scopes every exchange location', async () => { const prepared = []; const exchanged = []; diff --git a/test/environment-bootstrap.test.js b/test/environment-bootstrap.test.js index 0d07010f8..b13839d99 100644 --- a/test/environment-bootstrap.test.js +++ b/test/environment-bootstrap.test.js @@ -56,6 +56,45 @@ test('generation is deterministic and binds basis plus local plan', () => { assert.notEqual(first, environmentBootstrapGeneration({ basis, plan: { ...plan, revision: 'base-v3' } })); }); +test('an already ready exact generation does not repeat provider preparation or apply', async () => { + let inspections = 0; + const instance = bootstrapWith(async (_target, frame, options) => { + assert.equal(frame.action, 'inspect'); + assert.ok(options.signal instanceof AbortSignal); + inspections += 1; + return makeResponse(frame, readyBody(frame.body.generation, { basisDigest: frame.body.basisDigest })); + }, { prepare: () => assert.fail('warm readiness must not repeat startup preparation') }); + assert.equal((await instance.ensure(target)).ready, true); + assert.equal((await instance.ensure(target)).ready, true); + assert.equal(inspections, 2); +}); + +test('unavailable first observation prepares once, but forged identity never triggers repair', async () => { + let prepared = 0; + const instance = bootstrapWith(async (_target, frame) => { + if (!prepared) throw new Error('guest connection is unavailable'); + return makeResponse(frame, readyBody(frame.body.generation, { basisDigest: frame.body.basisDigest })); + }, { prepare: async () => { prepared += 1; } }); + assert.equal((await instance.ensure(target)).ready, true); + assert.equal(prepared, 1); + const forged = bootstrapWith(async (_target, frame) => ({ + ...makeResponse(frame, readyBody(frame.body.generation, { basisDigest: frame.body.basisDigest })), target: 'env-foreign', + }), { prepare: () => assert.fail('identity failure cannot authorize preparation') }); + await assert.rejects(forged.ensure(target), /identity does not match/); +}); + +test('preparation cannot apply an old intent after basis or policy changes', async () => { + let currentBasis = structuredClone(basis); + const instance = bootstrapWith(async (_target, frame) => { + assert.equal(frame.action, 'inspect'); + return makeResponse(frame, readyBody(null, { basisDigest: null, revision: null })); + }, { + basis: async () => currentBasis, + prepare: async () => { currentBasis = { ...basis, generation: basis.generation + 1 }; }, + }); + await assert.rejects(instance.ensure(target), /basis or policy changed/); +}); + test('ensure prepares then applies an exact generation when observation is stale', async () => { let prepared = 0; let applied = 0; diff --git a/test/environment-construction-observation.test.js b/test/environment-construction-observation.test.js index a90db00a3..6a8b47c6f 100644 --- a/test/environment-construction-observation.test.js +++ b/test/environment-construction-observation.test.js @@ -68,7 +68,28 @@ test('workspace readiness loss degrades the final execution observation', async }); const observation = await composed.observe(request()); assert.equal(observation.guest, 'degraded'); - await assert.rejects(() => composed.readiness.verify(request()), /not healthy/u); + await assert.rejects(() => composed.readiness.verify(request()), /route unavailable/u); +}); + +test('readiness preserves the responsible preparation failure without changing v1 observation', async () => { + let preparations = 0; + const composed = createEnvironmentConstructionObservation({ + materialization: { observe: async () => materialization() }, + preparation: { inspect: async () => { + preparations += 1; + return { ready: false, enrollment: 'ready', bootstrap: 'degraded', reason: 'required capability compiler-c is unavailable\n' + 'x'.repeat(2000) }; + } }, + workspaces: { inspect: async () => ({ ready: false, reason: 'dependent route unavailable' }) }, + }); + await assert.rejects(composed.readiness.verify(request()), error => { + assert.match(error.message, /bootstrap-degraded; required capability compiler-c is unavailable/); + assert.ok(error.message.length < 1200); + assert.ok(!error.message.includes('\n')); + assert.ok(!error.message.includes('dependent route')); + return true; + }); + assert.equal(preparations, 1); + assert.equal(Object.hasOwn(await composed.observe(request()), 'reason'), false); }); test('construction readiness refuses an unidentified implementation even when every health field says ready', async () => { diff --git a/test/environment-construction-runtime.test.js b/test/environment-construction-runtime.test.js index 2f210cd50..973191e44 100644 --- a/test/environment-construction-runtime.test.js +++ b/test/environment-construction-runtime.test.js @@ -4,6 +4,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createEnvironmentConstructionRuntime } from '../src/app/environment-construction-runtime.js'; +import { createEnvironmentConstructionPreparation } from '../src/app/environment-construction-preparation.js'; +import { executionProfileSubject, executionWorkspaceIdentity } from '../src/app/execution-profile-routing.js'; +import { logicalEnvironmentIdentity } from '../src/runtime/environment-declaration.js'; function foundation() { return { @@ -46,3 +49,57 @@ test('production construction composition exposes shared create, diagnosis, repa await rm(directory, { recursive: true, force: true }); } }); + +for (const family of ['ubuntu', 'windows-11']) test(`workspace composition uses the protected foundation and real ${family} preparation contract`, async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'devbridge-workspace-composition-')); + try { + const ordinary = path.join(directory, 'ordinary'); + const authority = path.join(directory, 'protected'); + const profile = family === 'ubuntu' ? 'linux-development' : 'windows-development'; + const target = 'env-0123456789abcdef0123456789abcdef'; + const declaration = { + profile, guest: { family, generation: 'guest-v1' }, + image: { identity: 'image-v1', generation: 'image-v1' }, + bootstrap: { generation: 'tooling-v1', requirements: ['runtime-js'] }, + enrollment: { requirement: 'unique-guest-trust-v1' }, + workspaces: [{ identity: executionWorkspaceIdentity('42', profile), authority: '42' }], + }; + const state = foundation(); + const physical = { + record: { identity: target, profile, subject: executionProfileSubject(profile) }, + observation: { owned: true, exists: true, compatible: true, storageState: 'present', storage: { sourceIdentity: 'image-v1' } }, + }; + state.listEnvironments = async () => [physical]; + state.observeEnvironment = async () => physical; + const connections = []; + const runtime = await createEnvironmentConstructionRuntime({ + stateDirectory: ordinary, authorityDirectory: authority, + foundation: state, availability: { ensure: async () => ({ state: 'local' }) }, + routeState: { load: async () => null, publish: async () => { throw new Error('observation cannot publish routes'); } }, + resolveAuthority: async value => value, + fence: { acquire: async ({ subject }) => ({ subject, release: async () => {} }) }, + invoke: async () => { throw new Error('observation cannot perform native mutations'); }, + }, { + preparationFactory: options => createEnvironmentConstructionPreparation({ + ...options, + createAccess: async () => ({ connection: async selected => ({ target: selected }) }), + createBootstrap: async () => ({ + ensure: async () => { throw new Error('observation cannot bootstrap'); }, + inspect: async () => ({ ready: true, network: { nameResolution: true, secureWeb: true } }), + connection: async selected => { connections.push(selected); return { target: selected }; }, + }), + }), + bridgeFactory: async options => { + assert.equal(options.stateDirectory, authority, 'provider identity must come from the effect-owning foundation'); + return { + health: async selected => { assert.deepEqual(await options.access(selected), { target }); return { ready: true }; }, + execute() {}, put() {}, get() {}, + }; + }, + }); + const observed = await runtime.observer.observe({ environmentIdentity: logicalEnvironmentIdentity(profile), declarationRevision: 1, declaration }); + assert.equal(observed.guest, 'healthy'); + assert.equal(observed.implementationGeneration, target); + assert.deepEqual(connections, [target]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); diff --git a/test/environment-lifecycle-diagnostics.test.js b/test/environment-lifecycle-diagnostics.test.js new file mode 100644 index 000000000..ea4feccd3 --- /dev/null +++ b/test/environment-lifecycle-diagnostics.test.js @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createEnvironmentLifecycleDiagnostics } from '../src/runtime/environment-lifecycle-diagnostics.js'; +import { createEnvironmentLifecycleStateStore } from '../src/state/environment-lifecycle-state-store.js'; +import { createLifecycleAuthorityReadHandler, createLifecycleAuthorityMutationHandler, LifecycleAuthorityClient } from '../src/runtime/environment-lifecycle-authority.js'; + +test('native failure facts survive restart and pass the versioned read contract without executing work', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'devbridge-lifecycle-diagnostics-')); + try { + const create = () => createEnvironmentLifecycleDiagnostics({ port: createEnvironmentLifecycleStateStore(path.join(directory, 'state.json')).diagnostics }); + const error = Object.assign(new Error('copy argument rejected at C:\\protected\\file GH_TOKEN=secret-value'), { + name: 'NativeError', code: 'INVALID_ARGUMENT', + evidence: { exitCode: 9, timedOut: false, aborted: true, outputTruncated: false, category: 'InvalidArgument', nativeCode: '0x1234', stdout: '💥'.repeat(10000), stderr: 'powershell.exe GH_TOKEN=secret-value\n/var/lib/private/file' }, + }); + await create().record({ environmentIdentity: 'environment-a', declarationRevision: 2, operation: 'create', + journal: { operationId: 'operation-a', entries: [{ stage: 'fenced-attempt', implementationGeneration: 'generation-a' }] }, error }); + const effects = () => { throw new Error('diagnostics cannot invoke effects'); }; + const operator = { inspect: effects, list: effects, status: effects, plan: effects, run: effects, resume: effects, setupReentry: effects, + diagnostics: identity => create().inspect(identity) }; + const client = new LifecycleAuthorityClient({ readExchange: createLifecycleAuthorityReadHandler({operator}), mutationExchange: createLifecycleAuthorityMutationHandler({operator}) }); + const result = await client.diagnostics('environment-a'); + assert.equal(result.operationId, 'operation-a'); + assert.equal(result.failure.native.exitCode, 9); + assert.equal(result.failure.native.category, 'InvalidArgument'); + assert.equal(result.failure.native.aborted, true); + assert.equal(result.failure.native.outputTruncated, true); + assert.equal(result.guestEvidence, 'unavailable'); + assert.ok(Buffer.byteLength(JSON.stringify(result)) < 16000); + assert.doesNotMatch(JSON.stringify(result), /secret-value|protected|powershell|\/var\/lib/iu); + assert.equal((await client.diagnostics('environment-other')).available, false); + await assert.rejects(client.run('create', 'environment-a'), /operation failed/u); + } finally { await rm(directory, {recursive:true,force:true}); } +}); + +test('an older operator rejects the optional diagnostic capability explicitly', async () => { + const effects = () => { throw new Error('unexpected effect'); }; + const operator = { inspect: effects, list: effects, status: effects, plan: effects, run: effects, resume: effects, setupReentry: effects }; + const client = new LifecycleAuthorityClient({ readExchange: createLifecycleAuthorityReadHandler({operator}), mutationExchange: effects }); + await assert.rejects(client.diagnostics('environment-a'), error => error.code === 'UNSUPPORTED_CAPABILITY'); +}); diff --git a/test/environment-materialization-rebuild.test.js b/test/environment-materialization-rebuild.test.js index 1a7a2ad2d..130f4f897 100644 --- a/test/environment-materialization-rebuild.test.js +++ b/test/environment-materialization-rebuild.test.js @@ -12,7 +12,8 @@ const declaration = { boot: { requirement: 'efi-v1' }, }; function request() { - return { environmentIdentity: 'environment-0123456789abcdef0123456789abcdef', operationId: 'lifecycle-rebuild-1', declarationRevision: 1, declaration }; + const input = { environmentIdentity: 'environment-0123456789abcdef0123456789abcdef', operationId: 'lifecycle-rebuild-1', declarationRevision: 1, declaration }; + return { ...input, operationSubject: { environmentIdentity: input.environmentIdentity, operationId: input.operationId, declarationRevision: 1, operation: 'rebuild', previousImplementationGeneration: CURRENT, imageIdentity: declaration.image.identity, imageGeneration: declaration.image.generation } }; } function subject() { return { resolve: async () => 'profile-subject-1' }; } function settings() { return { resolve: async () => ({ memoryBytes: 4096, processorCount: 4, firmware: 'efi' }) }; } @@ -37,7 +38,7 @@ test('materialization projects neutral missing and invalid storage evidence into assert.equal(environmentObservationCondition(invalid), 'system-storage-invalid'); }); -test('rebuild materialization binds replacement to active outer lifecycle and previous generation', async () => { +test('rebuild materialization binds replacement to the supplied operation without reading a journal', async () => { let supplied = null; const state = { listEnvironments: async () => [{ record: { identity: CURRENT, subject: 'profile-subject-1', profile: declaration.profile, source: { identity: declaration.image.identity } }, observation: {} }], @@ -50,13 +51,7 @@ test('rebuild materialization binds replacement to active outer lifecycle and pr }; }, }; - const journal = { - current: async () => ({ - operation: 'rebuild', operationId: 'lifecycle-rebuild-1', declarationRevision: 1, - entries: [{ stage: 'intent' }, { stage: 'pre-observation', implementationGeneration: CURRENT }], - }), - }; - const materialization = createEnvironmentRebuildMaterialization({ state, subject: subject(), journal }); + const materialization = createEnvironmentRebuildMaterialization({ state, subject: subject() }); const result = await materialization.ensure(request()); assert.equal(result.ready, true); assert.equal(result.implementationGeneration, NEXT); @@ -73,9 +68,11 @@ test('rebuild materialization refuses lifecycle authority drift even when the de rebuildEnvironment: async () => { throw new Error('unused'); }, }; const materialization = createEnvironmentRebuildMaterialization({ - state, subject: subject(), journal: { current: async () => null }, + state, subject: subject(), }); - await assert.rejects(() => materialization.ensure(request()), /not bound to the active rebuild lifecycle/u); + const input = request(); + input.operationSubject = { ...input.operationSubject, declarationRevision: 2 }; + await assert.rejects(() => materialization.ensure(input), /does not match request authority/u); }); test('rebuild materialization refuses a replacement from an undeclared source', async () => { @@ -84,10 +81,7 @@ test('rebuild materialization refuses a replacement from an undeclared source', listEnvironments: async () => [{ record: { identity: CURRENT, subject: 'profile-subject-1', profile: declaration.profile } }], rebuildEnvironment: async () => ({ record: { identity: NEXT, source: { identity: 'img-other' } }, observation: { exists: true, owned: true, compatible: true } }), }, - subject: subject(), journal: { current: async () => ({ - operation: 'rebuild', operationId: request().operationId, declarationRevision: 1, - entries: [{ stage: 'pre-observation', implementationGeneration: CURRENT }], - }) }, + subject: subject(), }); await assert.rejects(() => materialization.ensure(request()), /rebuilt source does not match declaration/u); }); diff --git a/test/environment-operation-subject.test.js b/test/environment-operation-subject.test.js new file mode 100644 index 000000000..f5ee6a9d9 --- /dev/null +++ b/test/environment-operation-subject.test.js @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeEnvironmentOperationSubject, requireEnvironmentOperationSubject } from '../src/runtime/environment-operation-subject.js'; + +const raw = { environmentIdentity: 'environment-a', operationId: 'operation-a', operation: 'rebuild', declarationRevision: 2, previousImplementationGeneration: 'generation-a', imageIdentity: 'image-b', imageGeneration: 'image-generation-b' }; +const request = { environmentIdentity: raw.environmentIdentity, operationId: raw.operationId, declarationRevision: 2, declaration: { image: { identity: raw.imageIdentity, generation: raw.imageGeneration } }, operationSubject: raw }; + +test('operation subjects are immutable values and bind every consumer authority field', () => { + const subject = requireEnvironmentOperationSubject(request, 'rebuild'); + assert.ok(Object.isFrozen(subject)); + assert.notEqual(subject, raw); + assert.deepEqual(subject, raw); + for (const [key, value] of Object.entries({ environmentIdentity: 'other', operationId: 'other', declarationRevision: 3, operation: 'reset', imageIdentity: 'other', imageGeneration: 'other' })) { + assert.throws(() => requireEnvironmentOperationSubject({ ...request, operationSubject: { ...raw, [key]: value } }, 'rebuild'), /does not match request authority/u); + } +}); + +test('replacement subjects require prior identity and reject extra authority', () => { + assert.throws(() => normalizeEnvironmentOperationSubject({ ...raw, previousImplementationGeneration: null }), /previous generation is required/u); + assert.throws(() => normalizeEnvironmentOperationSubject({ ...raw, journal: {} }), /unknown field/u); + assert.throws(() => normalizeEnvironmentOperationSubject({ ...raw, declarationRevision: 0 }), /revision is invalid/u); +}); diff --git a/test/environment-operator.test.js b/test/environment-operator.test.js index 079c7267c..2204ecd41 100644 --- a/test/environment-operator.test.js +++ b/test/environment-operator.test.js @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createEnvironmentOperator } from '../src/app/environment-operator.js'; import { ENVIRONMENT_DECLARATION_PROTOCOL } from '../src/runtime/environment-declaration.js'; +import { createEnvironmentLifecycleDiagnostics } from '../src/runtime/environment-lifecycle-diagnostics.js'; function record() { return Object.freeze({ @@ -65,6 +66,25 @@ function runtimeFor({ current = null, diagnosis = null } = {}) { return { runtime, calls, selected }; } +test('failed lifecycle continuation retains its subject and diagnostic evidence without another effect', async () => { + const current = { operation: 'rebuild', operationId: 'operation-a', declarationRevision: 3, entries: [{stage:'fenced-attempt'}] }; + const { runtime } = runtimeFor({current}); + let saved = null; + runtime.lifecycle.diagnostics = createEnvironmentLifecycleDiagnostics({port:{load:async()=>saved,save:async(_identity,value)=>{saved=structuredClone(value);}}}); + let attempts = 0; + runtime.rebuild = async () => { attempts += 1; throw Object.assign(new Error('workspace generation is missing'),{code:'INVALID_SUBJECT'}); }; + const operator = createEnvironmentOperator({runtime}); + await assert.rejects(operator.resume('environment-test'), /workspace generation/u); + const restarted = createEnvironmentOperator({runtime}); + const diagnostic = await restarted.diagnostics('environment-test'); + assert.equal(diagnostic.current, true); + assert.equal(diagnostic.operationId, 'operation-a'); + assert.equal(diagnostic.failure.code, 'INVALID_SUBJECT'); + assert.equal(attempts, 1); + current.entries.push({stage:'terminal'}); + assert.equal((await restarted.diagnostics('environment-test')).current, false); +}); + test('operator reports rebuild as the next action for missing system storage without provider details', async () => { const { runtime } = runtimeFor(); const operator = createEnvironmentOperator({ runtime }); diff --git a/test/environment-recreate-app.test.js b/test/environment-recreate-app.test.js index e2264c152..022d7356d 100644 --- a/test/environment-recreate-app.test.js +++ b/test/environment-recreate-app.test.js @@ -12,7 +12,8 @@ function request() { environmentIdentity: 'logical-environment-a', operationId: 'lifecycle-recreate-1', declarationRevision: 1, - declaration: { profile: 'linux-development', image: { identity: IMAGE } }, + declaration: { profile: 'linux-development', image: { identity: IMAGE, generation: 'image-v1' } }, + operationSubject: { environmentIdentity: 'logical-environment-a', operationId: 'lifecycle-recreate-1', operation: 'recreate', declarationRevision: 1, previousImplementationGeneration: OLD, imageIdentity: IMAGE, imageGeneration: 'image-v1' }, }; } function activeJournal(stage = 'fenced-attempt') { @@ -42,7 +43,6 @@ test('recreate materialization binds replacement to the active lifecycle and acc }, }, subject: { resolve: async () => 'profile-subject' }, - journal: { current: async () => activeJournal() }, }); const result = await materialization.ensure(request()); assert.deepEqual(result, { ready: true, implementationGeneration: NEXT, superseded: { identity: OLD, cleanup: 'absent' } }); @@ -57,9 +57,8 @@ test('recreate materialization refuses provider effects not bound to the exact r async recreateEnvironment() { calls += 1; throw new Error('unused'); }, }, subject: { resolve: async () => 'profile-subject' }, - journal: { current: async () => ({ ...activeJournal(), operation: 'reset' }) }, }); - await assert.rejects(() => materialization.ensure(request()), /active recreate lifecycle/u); + await assert.rejects(() => materialization.ensure({ ...request(), operationSubject: { ...request().operationSubject, operation: 'reset' } }), /operation subject does not match/u); assert.equal(calls, 0); }); diff --git a/test/environment-reset-app.test.js b/test/environment-reset-app.test.js index 10fd484b4..4f068e036 100644 --- a/test/environment-reset-app.test.js +++ b/test/environment-reset-app.test.js @@ -12,7 +12,8 @@ function request() { environmentIdentity: 'logical-environment-a', operationId: 'lifecycle-reset-1', declarationRevision: 1, - declaration: { profile: 'linux-development', image: { identity: IMAGE } }, + declaration: { profile: 'linux-development', image: { identity: IMAGE, generation: 'image-v1' } }, + operationSubject: { environmentIdentity: 'logical-environment-a', operationId: 'lifecycle-reset-1', operation: 'reset', declarationRevision: 1, previousImplementationGeneration: OLD, imageIdentity: IMAGE, imageGeneration: 'image-v1' }, }; } @@ -49,7 +50,6 @@ test('reset materialization binds staged replacement to the active outer lifecyc const materialization = createEnvironmentResetMaterialization({ state, subject: { resolve: async () => 'profile-subject' }, - journal: { current: async () => activeJournal() }, }); const result = await materialization.ensure(request()); assert.equal(result.ready, true); @@ -66,9 +66,8 @@ test('reset materialization refuses a replacement not bound to the exact reset l async replaceEnvironment() { calls += 1; throw new Error('unused'); }, }, subject: { resolve: async () => 'profile-subject' }, - journal: { current: async () => ({ ...activeJournal(), operation: 'rebuild' }) }, }); - await assert.rejects(() => materialization.ensure(request()), /active reset lifecycle/u); + await assert.rejects(() => materialization.ensure({ ...request(), operationSubject: { ...request().operationSubject, operation: 'rebuild' } }), /operation subject does not match/u); assert.equal(calls, 0); }); diff --git a/test/execution-profile-routing.test.js b/test/execution-profile-routing.test.js index b0a95fd71..3242d4844 100644 --- a/test/execution-profile-routing.test.js +++ b/test/execution-profile-routing.test.js @@ -77,6 +77,23 @@ test('multiple repository workspaces resolve to one physical profile environment assert.equal(await routing.representativeTarget(PHYSICAL), environments[0].record.identity); }); +test('physical route requests only its subject and profile and rejects stale compatibility', async () => { + const state = physicalState(); + const original = state.listEnvironments; + let incompatible = false; + state.listEnvironments = async selection => { + assert.deepEqual(selection, { subject: executionProfileSubject(PROFILE), profile: PROFILE }); + const entries = await original(); + if (incompatible) entries[0].observation.compatible = false; + return entries; + }; + const routing = createExecutionProfileRouting({ state, policy: policy([{ subject: '101' }]) }); + const target = executionWorkspaceTarget('101', PROFILE); + assert.equal(await routing.physicalTarget(target), PHYSICAL); + incompatible = true; + await assert.rejects(routing.physicalTarget(target), /not compatible/); +}); + test('profile routing rejects credential-bearing topology in admission policy', () => { assert.throws(() => createExecutionProfileRouting({ state: physicalState(), diff --git a/test/guest-bootstrap-qualification.test.js b/test/guest-bootstrap-qualification.test.js new file mode 100644 index 000000000..fe82ba4b1 --- /dev/null +++ b/test/guest-bootstrap-qualification.test.js @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { qualifyGuestBootstrap } from '../src/setup/guest-bootstrap-qualification.js'; +import { copyHyperVGuestFile } from '../src/runtime/providers/hyperv-file-copy.js'; + +const IDENTITY = { packageDigest: 'a'.repeat(64), nodeDigest: 'b'.repeat(64) }; +const TARGET = `env-${'a'.repeat(32)}`; +const success = (stdout) => ({ exitCode: 0, stdout, stderr: '', timedOut: false, aborted: false, outputTruncated: false }); +function declaration(family) { + return { protocol: 'devbridge/environment-declaration-v1', profile: `${family}-development`, schemaGeneration: 'profile-v1', + guest: { family: family === 'linux' ? 'ubuntu' : 'windows-11', generation: 'guest-v1' }, image: { identity: 'image-v1', generation: 'final-v1' }, + resources: { memoryBytes: 4 * 1024 ** 3, processorCount: 2 }, boot: { requirement: 'efi-v1' }, + network: { requirement: 'managed-egress-v1' }, bootstrap: { generation: 'tooling-v1', requirements: ['runtime-js'] }, + enrollment: { requirement: 'unique-guest-trust-v1' }, workspaces: [], protectedStateClasses: [] }; +} + +async function fixture(t, family, incorrect = false) { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-guest-qualification-')); + t.after(() => rm(root, { recursive: true, force: true })); + const files = new Map(); + let entry = null; + let bootstrapCalls = 0; + let removals = 0; + const expectedFile = family === 'linux' ? '/var/lib/devbridge/bootstrap/network-seed.json' : 'C:\\ProgramData\\DevBridge\\bootstrap\\network-seed.json'; + const ports = { + foundationFactory: async () => ({ + listEnvironments: async () => entry ? [entry] : [], + verifyImage: async () => ({ identity: 'image-v1', verified: true, usable: true }), + observeImage: async () => ({ identity: 'image-v1', usable: true, entry: { generation: 'final-v1', digest: 'c'.repeat(64) } }), + ensureEnvironment: async (request) => entry ??= { record: { identity: TARGET, subject: request.subject, profile: request.profile, source: { identity: request.sourceIdentity } }, observation: { exists: true } }, + stopEnvironment: async (target) => assert.equal(target, TARGET), + removeEnvironment: async (target) => { assert.equal(target, TARGET); removals++; entry = null; }, + }), + accessFactory: async () => ({ connection: async () => ({ family }), prepare: async () => {}, discard: async (target) => assert.equal(target, TARGET) }), + bootstrapFactory: async ({ invoke }) => ({ + ensure: async (target) => { + bootstrapCalls++; + const source = path.join(root, `seed-${randomUUID()}.json`); + await writeFile(source, JSON.stringify({ protocol: 'devbridge/network-seed-v1', target })); + await copyHyperVGuestFile({ invoke, location: { reference: 'fixture-vm', proof: 'fixture-owned' }, family, source, destination: expectedFile }); + return { ready: true }; + }, + connection: async () => ({ family }), reconcile: async () => {}, + }), + bridgeFactory: async () => ({ + health: async () => ({ ready: true, version: '1.0.0' }), + execute: async () => { + const bytes = files.get(expectedFile); + if (!bytes) return { completion: 'observed', result: { ...success(''), exitCode: 1, stderr: 'requested seed file missing' } }; + return { completion: 'observed', result: success(JSON.stringify({ target: JSON.parse(bytes).target, family, digest: createHash('sha256').update(bytes).digest('hex') })) }; + }, + }), + identityLoader: async () => 'a'.repeat(32), + }; + const request = { stateDirectory: root, authorityDirectory: root, evidenceDirectory: root, declaration: declaration(family), runtimeIdentity: IDENTITY, + qualificationId: randomUUID(), platform: 'win32', invoke: async (request) => { + const payload = JSON.parse(request.input); + // Model each native provider's destination contract, including a wrong-path + // regression that a command-accepting fake would silently accept. + const destination = family === 'linux' ? path.posix.join(payload.destination, incorrect ? 'wrong-seed.json' : path.basename(payload.source)) : payload.destination; + files.set(destination, await readFile(payload.source)); + return success(JSON.stringify({ delivered: true })); + } }; + return { request, ports, counts: () => ({ bootstrapCalls, removals }) }; +} + +for (const family of ['linux', 'windows']) test(`fresh ${family} consumer proves exact seed bytes and cleans only its owned fixture`, async (t) => { + const f = await fixture(t, family); + const result = await qualifyGuestBootstrap(f.request, f.ports); + assert.equal(result.passed, true); + assert.equal(result.cleanup, 'complete'); + assert.equal(result.freshSubjectObserved, true); + assert.deepEqual(f.counts(), { bootstrapCalls: 1, removals: 1 }); + assert.deepEqual(await qualifyGuestBootstrap(f.request, f.ports), result); + assert.deepEqual(f.counts(), { bootstrapCalls: 1, removals: 1 }); + await assert.rejects(qualifyGuestBootstrap({ ...f.request, runtimeIdentity: { ...IDENTITY, packageDigest: 'd'.repeat(64) } }, f.ports), /intent changed/u); +}); + +test('qualification rejects a copy reported successful at the wrong path and retains failure evidence', async (t) => { + const f = await fixture(t, 'linux', true); + await assert.rejects(qualifyGuestBootstrap(f.request, f.ports), /seed file missing/u); + const result = JSON.parse(await readFile(path.join(f.request.evidenceDirectory, f.request.qualificationId, 'qualification.json'), 'utf8')); + assert.equal(result.passed, false); + assert.equal(result.stage, 'first-access'); + assert.match(result.failure.message, /seed file missing/u); + assert.deepEqual(f.counts(), { bootstrapCalls: 1, removals: 0 }); +}); diff --git a/test/guest-image-payload.test.js b/test/guest-image-payload.test.js index 2549541e2..f8062f8ab 100644 --- a/test/guest-image-payload.test.js +++ b/test/guest-image-payload.test.js @@ -45,6 +45,18 @@ test('guest image payload generation changes when one owned helper changes', asy } finally { await rm(directory, { recursive: true, force: true }); } }); +test('execution-owned source packing can change without invalidating the image payload', async () => { + const directory = await root(); + try { + await writeFixture(directory); + const before = await createGuestImagePayload({ directory }); + await writeFile(path.join(directory, 'source-pack-agent.mjs'), 'runtime-owned version one'); + assert.deepEqual(await createGuestImagePayload({ directory }), before); + await writeFile(path.join(directory, 'source-pack-agent.mjs'), 'runtime-owned version two'); + assert.deepEqual(await createGuestImagePayload({ directory }), before); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + test('guest image payload canonicalizes LF and CRLF source delivery to identical bytes', async () => { const lfDirectory = await root(); const crlfDirectory = await root(); diff --git a/test/hyperv-console-evidence.test.js b/test/hyperv-console-evidence.test.js new file mode 100644 index 000000000..5d3e9c563 --- /dev/null +++ b/test/hyperv-console-evidence.test.js @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { HyperVConsoleEvidence } from '../src/runtime/providers/hyperv-image-construction/console-evidence.js'; +import { HyperVConstructionChannel } from '../src/runtime/providers/hyperv-image-construction/management-channel.js'; + +test('console channel defaults compatibly and bounds native output for readable dimensions', async () => { + const requests = []; + const channel = new HyperVConstructionChannel({ invoke: async (request) => { + requests.push(request); + return { exitCode: 0, stdout: '{"available":false,"reason":"off"}' }; + } }); + await channel.console({ name: 'fixture', providerIdentity: 'id' }); + await channel.console({ name: 'fixture', providerIdentity: 'id', width: 1024, height: 768 }); + assert.equal(JSON.parse(requests[0].input).width, 320); + assert.equal(JSON.parse(requests[1].input).height, 768); + assert.ok(requests[1].maxOutputBytes > 1024 * 768 * 2 * 4 / 3); + assert.ok(requests[1].maxOutputBytes < 3 * 1024 * 1024); + for (const size of [[65535, 65535], [320, 768], ['320', 240]]) { + assert.throws(() => channel.console({ width: size[0], height: size[1] }), /unsupported/u); + } + assert.equal(requests.length, 2); +}); + +test('console evidence decodes RGB565 at each supported size with exact bounds', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'db-console-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const owner = new HyperVConsoleEvidence({ directory, now: () => new Date('2026-09-08T00:00:00Z') }); + for (const [width, height] of [[320, 240], [640, 480], [1024, 768]]) { + const pixels = Buffer.alloc(width * height * 2); + pixels.writeUInt16LE(0xf800, 0); + pixels.writeUInt16LE(0x07e0, 2); + pixels.writeUInt16LE(0x001f, pixels.length - 2); + const prefix = Buffer.alloc(4); + prefix.writeUInt32BE(pixels.length + 4); + for (const bytes of [pixels, Buffer.concat([prefix, pixels])]) { + const published = await owner.publish(`fixture-${width}`, { available: true, width, height, imageData: bytes.toString('base64') }); + const bmp = await readFile(published.location); + assert.equal(bmp.readInt32LE(18), width); + assert.equal(bmp.readInt32LE(22), -height); + assert.deepEqual([...bmp.subarray(54, 60)], [0, 0, 255, 0, 255, 0]); + assert.deepEqual([...bmp.subarray(-3)], [255, 0, 0]); + assert.equal(bmp.length, 54 + width * height * 3); + } + await assert.rejects(owner.publish('bad', { available: true, width, height, imageData: pixels.subarray(2).toString('base64') }), /size/u); + } + await assert.rejects(owner.publish('bad', { available: true, width: 65535, height: 65535, imageData: 'AAAA' }), /unsupported/u); + assert.deepEqual(await owner.publish('off', { available: false, reason: 'guest is off' }), { available: false, reason: 'guest is off' }); +}); diff --git a/test/hyperv-environment-bootstrap.test.js b/test/hyperv-environment-bootstrap.test.js index c74b21341..03d2b638b 100644 --- a/test/hyperv-environment-bootstrap.test.js +++ b/test/hyperv-environment-bootstrap.test.js @@ -1,4 +1,5 @@ import test from 'node:test'; +import { mutationLease } from '../test-support/mutation-lease.js'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; @@ -24,6 +25,44 @@ function location(value) { const baseConnection = { family: 'linux', user: 'guest', identityFile: '/keys/id', knownHostsFile: '/keys/known' }; +test('bootstrap retries only a confirmed pre-copy transient failure within its deadline', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-bootstrap-retry-')); + t.after(() => rm(root, { recursive: true, force: true })); + let now = 0; + let calls = 0; + const adapter = new HyperVEnvironmentBootstrap({ + directory: root, locate: async () => location(target), connection: async () => baseConnection, + dnsServers: () => ['10.0.0.53'], now: () => now, wait: async (ms) => { now += ms; }, + invoke: async (request) => { + assert.ok(request.timeoutMs <= 90_000 - now); + calls += 1; + if (calls === 1) return success(JSON.stringify({ delivered: false, failure: { code: 'service-not-ready', attempted: false, message: 'starting' } })); + return success(JSON.stringify({ delivered: true })); + }, + }); + assert.equal((await adapter.activate(target)).ready, true); + assert.equal(calls, 2); + assert.equal(now, 1000); +}); + +test('bootstrap preserves an uncertain copy outcome without a blind retry', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-bootstrap-uncertain-')); + t.after(() => rm(root, { recursive: true, force: true })); + let calls = 0; + const adapter = new HyperVEnvironmentBootstrap({ + directory: root, locate: async () => location(target), connection: async () => baseConnection, + dnsServers: () => ['10.0.0.53'], wait: async () => assert.fail('must not retry an uncertain copy'), + invoke: async () => { calls += 1; return { ...success(''), exitCode: null, timedOut: true, stderr: 'native timeout' }; }, + }); + await assert.rejects(adapter.activate(target), (error) => { + assert.equal(error.effect, 'uncertain'); + assert.equal(error.evidence.timedOut, true); + assert.match(error.message, /native timeout/u); + return true; + }); + assert.equal(calls, 1); +}); + test('Hyper-V preparation uses only located ownership/network state and activation copies a bounded seed', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-bootstrap-')); let copies = 0; @@ -40,8 +79,9 @@ test('Hyper-V preparation uses only located ownership/network state and activati if (payload.source) { copies += 1; copiedSeed = JSON.parse(await readFile(payload.source, 'utf8')); - assert.equal(payload.destination, '/var/lib/devbridge/bootstrap/network-seed.json'); - return success(JSON.stringify({ copied: true })); + // Linux fcopy appends the source basename to the supplied directory. + assert.equal(path.posix.join(payload.destination, path.basename(payload.source)), '/var/lib/devbridge/bootstrap/network-seed.json'); + return success(JSON.stringify({ delivered: true })); } throw new Error('unexpected management request'); }; @@ -173,6 +213,17 @@ test('reserved addresses cannot silently adopt managed allocation ownership', as } }); +test('bootstrap refuses a host-local DNS resolver before native delivery', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-dns-')); + try { + const adapter = new HyperVEnvironmentBootstrap({ + directory: root, locate: async (value) => location(value), connection: async () => baseConnection, + dnsServers: async () => ['127.0.0.1'], invoke: async () => assert.fail('invalid DNS must not reach delivery'), + }); + await assert.rejects(adapter.activate(target), /guest-reachable DNS/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); + test('separate bootstrap instances cannot overlap allocation mutation', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-address-exclusive-')); const options = { diff --git a/test/hyperv-environment-bridge.test.js b/test/hyperv-environment-bridge.test.js index 5d4488bac..2e69f4af5 100644 --- a/test/hyperv-environment-bridge.test.js +++ b/test/hyperv-environment-bridge.test.js @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { runInNewContext } from 'node:vm'; import { HyperVEnvironmentBridge } from '../src/runtime/providers/hyperv-environment-bridge.js'; +import { LINUX_BRIDGE_SESSION_COMMAND } from '../src/runtime/providers/guest-bridge-command-session.js'; const target = 'env-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const location = { reference: 'db-env-local-reference', proof: 'local-ownership-proof' }; @@ -14,6 +17,84 @@ function decode(request) { return Buffer.from(request.arguments.at(-1), 'base64' function success(stdout) { return { exitCode: 0, signal: null, timedOut: false, aborted: false, outputTruncated: false, stdout, stderr: '' }; } const locate = async (receivedTarget) => { assert.equal(receivedTarget, target); return location; }; +test('Linux guest session keeps fixed execution mechanics, binds its target and carries exact frame bytes', () => { + const input = new EventEmitter(), output = [], calls = []; + const source = Buffer.from(LINUX_BRIDGE_SESSION_COMMAND.match(/Buffer.from\("([A-Za-z0-9+/=]+)"/)[1], 'base64').toString('utf8'); + runInNewContext(source, { Buffer, TextDecoder, + require(name) { assert.equal(name, 'node:child_process'); return { spawnSync(program, args, options) { + calls.push({ program, args, options }); return { status: 0, stdout: JSON.stringify(reply), stderr: '' }; + } }; }, + process: { stdin: input, stdout: { write(value) { output.push(JSON.parse(value)); } }, env: {}, exit() { throw new Error('session closed'); } }, + }); + input.emit('data', Buffer.from(JSON.stringify({ target }) + '\n')); + input.emit('data', Buffer.from(JSON.stringify(frame) + '\n')); + input.emit('data', Buffer.from(JSON.stringify(frame) + '\n')); + assert.equal(calls.length, 2); + assert.deepEqual(output, [{ ready: true }, reply, reply]); + assert.equal(calls[0].program, 'node'); + assert.deepEqual(Array.from(calls[0].args), ['/usr/local/libexec/devbridge/bridge-agent.mjs', '--exchange-stdin']); + assert.equal(calls[0].options.input, JSON.stringify(frame)); + assert.equal(calls[0].options.env.DEVBRIDGE_GUEST_TARGET, target); + assert.equal(calls[0].options.shell, false); + assert.throws(() => input.emit('data', Buffer.from(JSON.stringify({ ...frame, target: 'env-' + 'b'.repeat(32) }) + '\n')), /session closed/); + assert.equal(calls.length, 2); +}); + +test('Linux channel performs native proof once per connection and pinned SSH never receives credentials in a frame', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-hv-session-')); + t.after(() => rm(root, { recursive: true, force: true })); + const identityFile = path.join(root, 'key'), knownHostsFile = path.join(root, 'known'); + await writeFile(identityFile, 'key'); await writeFile(knownHostsFile, 'pinned'); + let proofs = 0; const opened = []; + const adapter = new HyperVEnvironmentBridge({ locate, + invoke: async () => { proofs++; return success('{"ready":true}'); }, + access: async () => ({ family: 'linux', user: 'bridge', address: '10.0.0.2', identityFile, knownHostsFile }), + openChannel(options) { + const channel = { closed: false, requests: [], close() { this.closed = true; }, async exchange(value) { + this.requests.push(value); return value.kind ? reply : { ready: true }; + } }; opened.push({ channel, options }); return channel; + }, + }); + t.after(() => adapter.close()); + await adapter.exchange(frame); await adapter.exchange(frame); + assert.equal(proofs, 1); assert.equal(opened.length, 1); + assert.deepEqual(opened[0].channel.requests[0], { target }); + assert.equal(opened[0].options.executable, 'ssh.exe'); + assert.equal(opened[0].options.arguments.includes('StrictHostKeyChecking=yes'), true); + opened[0].channel.closed = true; + await adapter.exchange(frame); assert.equal(proofs, 2); assert.equal(opened.length, 2); +}); + +test('bound Windows connection is reused, invalidated by authority changes, and never replays a lost frame', async () => { + const opened = [], delivered = []; + let secret = 'first', fail = false; + const adapter = new HyperVEnvironmentBridge({ invoke: async () => { throw new Error('unexpected one-shot invocation'); }, locate, + access: async () => ({ family: 'windows', username: 'guest', password: secret }), + openChannel(options) { + const channel = { closed: false, close() { this.closed = true; }, async exchange(value) { + delivered.push(value); + if (!value.kind) return { ready: true }; + if (fail) throw new Error('response lost'); + return reply; + } }; + opened.push({ channel, options }); return channel; + }, + }); + await adapter.exchange(frame, { binding: 'generation-one' }); + await adapter.exchange(frame, { binding: 'generation-one' }); + assert.equal(opened.length, 1); + assert.equal(delivered.length, 3); + assert.equal(JSON.stringify(opened[0].options).includes(secret), false); + secret = 'rotated'; await adapter.exchange(frame, { binding: 'generation-one' }); + assert.equal(opened[0].channel.closed, true); assert.equal(opened.length, 2); + await adapter.exchange(frame, { binding: 'declaration-two' }); assert.equal(opened.length, 3); + fail = true; const before = delivered.length; + await assert.rejects(adapter.exchange(frame, { binding: 'declaration-two' }), /response lost/); + assert.equal(delivered.length, before + 1); assert.equal(opened[2].channel.closed, true); + fail = false; await adapter.exchange(frame, { binding: 'declaration-two' }); assert.equal(opened.length, 4); + adapter.close(); assert.equal(opened[3].channel.closed, true); +}); + test('Windows attachment uses only fixed PowerShell Direct mechanics for the exact located target', async () => { const calls = []; const invoke = async (request) => { diff --git a/test/hyperv-file-copy.test.js b/test/hyperv-file-copy.test.js new file mode 100644 index 000000000..bcfa37016 --- /dev/null +++ b/test/hyperv-file-copy.test.js @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { copyHyperVGuestFile, HyperVFileCopyError } from '../src/runtime/providers/hyperv-file-copy.js'; + +const location = { reference: 'owned-machine', proof: 'owned-proof' }; +const success = (value) => ({ exitCode: 0, timedOut: false, aborted: false, outputTruncated: false, stdout: JSON.stringify(value), stderr: '' }); + +async function fixture(t) { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-copy-contract-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = path.join(root, '.random-seed.json'); + const bytes = Buffer.from([0, 10, 13, 128, 255]); + await writeFile(source, bytes); + return { root, source, bytes }; +} + +for (const family of ['linux', 'windows']) { + test(`${family} copy preserves exact destination and bytes without changing its source`, async (t) => { + const { root, source, bytes } = await fixture(t); + const destination = family === 'linux' ? '/guest/nested/seed.json' : 'C:\\guest\\nested\\seed.json'; + const guest = new Map(); + await copyHyperVGuestFile({ + family, location, source, destination, + invoke: async (request) => { + const native = JSON.parse(request.input); + assert.equal(native.reference, location.reference); + assert.equal(native.proof, location.proof); + const resolved = family === 'linux' ? path.posix.join(native.destination, path.basename(native.source)) : native.destination; + guest.set(resolved, await readFile(native.source)); + if (family === 'windows') assert.equal(native.source.toLowerCase(), (await realpath(source)).toLowerCase()); + return success({ delivered: true }); + }, + }); + assert.deepEqual(guest.get(destination), bytes); + assert.equal(guest.size, 1); + assert.deepEqual(await readFile(source), bytes); + assert.deepEqual(await readdir(root), [path.basename(source)]); + }); +} + +test('copy failures retain native facts and clean staging without implying a retry is safe', async (t) => { + const { root, source } = await fixture(t); + const cases = [ + { result: success({ delivered: false, failure: { code: 'service-not-ready', attempted: false, nativeCode: '0x00000001', message: 'no contact' } }), code: 'service-not-ready', retryable: true, effect: 'not-attempted' }, + { result: success({ delivered: false, failure: { code: 'ownership-mismatch', attempted: false, message: 'proof changed' } }), code: 'ownership-mismatch', retryable: false, effect: 'not-attempted' }, + { result: success({ delivered: false, failure: { code: 'invalid-argument', attempted: true, nativeCode: '0x80070057', category: 'InvalidArgument', message: 'invalid destination' } }), code: 'invalid-argument', retryable: false, effect: 'uncertain' }, + { result: { ...success(null), exitCode: null, timedOut: true, stderr: 'native deadline' }, code: 'copy-outcome-unknown', retryable: false, effect: 'uncertain' }, + { result: { ...success(null), aborted: true }, code: 'copy-outcome-unknown', retryable: false, effect: 'uncertain' }, + { result: { ...success({ delivered: true }), outputTruncated: true }, code: 'copy-outcome-unknown', retryable: false, effect: 'uncertain' }, + { result: { ...success(null), stdout: 'invalid JSON' }, code: 'copy-outcome-unknown', retryable: false, effect: 'uncertain' }, + ]; + for (const item of cases) { + await assert.rejects(copyHyperVGuestFile({ family: 'linux', location, source, destination: '/seed.json', invoke: async () => item.result }), (error) => { + assert.ok(error instanceof HyperVFileCopyError); + assert.equal(error.code, item.code); + assert.equal(error.retryable, item.retryable); + assert.equal(error.effect, item.effect); + assert.equal(error.evidence.exitCode, item.result.exitCode); + assert.equal(error.evidence.timedOut, item.result.timedOut); + return true; + }); + assert.deepEqual(await readdir(root), [path.basename(source)]); + } +}); + +test('invalid or unrepresentable destinations fail before invocation', async (t) => { + const { source } = await fixture(t); + for (const destination of ['relative', '/guest/../seed', '/guest/', '/guest/a:b', '/guest/CON', '/guest/a\\b']) { + await assert.rejects(copyHyperVGuestFile({ family: 'linux', location, source, destination, invoke: async () => assert.fail('must not invoke') }), TypeError); + } +}); diff --git a/test/hyperv-guest-file-delivery.test.js b/test/hyperv-guest-file-delivery.test.js index a0c5dcaa7..0211f7490 100644 --- a/test/hyperv-guest-file-delivery.test.js +++ b/test/hyperv-guest-file-delivery.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { HyperVGuestFileDelivery } from '../src/runtime/providers/hyperv-guest-file-delivery.js'; @@ -12,18 +12,26 @@ const target = 'env-0123456789abcdef0123456789abcdef'; test('Hyper-V guest delivery binds copy to exact owned environment proof', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-hyperv-delivery-')); try { - const source = path.join(root, 'seed.json'); + const source = path.join(root, '.seed-random-identity.json'); await writeFile(source, '{}\n'); let supplied = null; const delivery = new HyperVGuestFileDelivery({ identity, - invoke: async (input) => { supplied = input; return { exitCode: 0, timedOut: false, aborted: false, outputTruncated: false, stdout: '{"delivered":true}', stderr: '' }; }, + invoke: async (input) => { + supplied = input; + const native = JSON.parse(input.input); + assert.equal(path.posix.join(native.destination, path.basename(native.source)), '/var/lib/devbridge/access/seed.json'); + assert.equal(await readFile(native.source, 'utf8'), '{}\n'); + return { exitCode: 0, timedOut: false, aborted: false, outputTruncated: false, stdout: '{"delivered":true}', stderr: '' }; + }, }); assert.deepEqual(await delivery.put(target, source, '/var/lib/devbridge/access/seed.json'), { delivered: true }); const payload = JSON.parse(supplied.input); assert.equal(payload.reference, `db-env-${createHash('sha256').update(`${identity}:persistent:${target}`).digest('hex').slice(0, 16)}`); assert.equal(payload.proof, `devbridge-owned:${identity}:persistent:${target}:v1`); - assert.equal(payload.destination, '/var/lib/devbridge/access/seed.json'); + assert.equal(payload.destination, '/var/lib/devbridge/access/'); + assert.deepEqual(await readdir(root), [path.basename(source)]); + assert.equal(await readFile(source, 'utf8'), '{}\n'); assert.match(supplied.executable, /powershell\.exe$/iu); } finally { await rm(root, { recursive: true, force: true }); } }); diff --git a/test/hyperv-image-construction.test.js b/test/hyperv-image-construction.test.js index 285c6ae37..a82a135c4 100644 --- a/test/hyperv-image-construction.test.js +++ b/test/hyperv-image-construction.test.js @@ -678,7 +678,7 @@ test('Hyper-V image construction rejects malformed console transport variants be await assert.rejects(() => construction.captureInstallConsole(data.request.identity), /evidence size is invalid/u); host.state.consoleResult = { available: true, width: 321, height: 240, imageData: 'AAAA' }; - await assert.rejects(() => construction.captureInstallConsole(data.request.identity), /evidence contract is invalid/u); + await assert.rejects(() => construction.captureInstallConsole(data.request.identity), /console dimensions are unsupported/u); const location = path.join(data.stateRoot, `${data.request.identity}-install-console.bmp`); await assert.rejects(() => readFile(location), /ENOENT/u); diff --git a/test/issue-status-delivery.test.js b/test/issue-status-delivery.test.js index 1679d7186..7571aa544 100644 --- a/test/issue-status-delivery.test.js +++ b/test/issue-status-delivery.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { IssueStatusReporter } from '../src/github/issue-status-reporter.js'; import { RateLimitError } from '../src/errors.js'; import { captureFailureDiagnostics } from '../src/run/failure-diagnostics.js'; +import { DeterministicFaultInjector } from '../src/runtime/fault-injector.js'; function store() { const records = new Map(); @@ -56,6 +57,19 @@ test('ambiguous initial POST is reconciled after reporter restart without duplic assert.deepEqual(await f.create().pending(), []); }); +for (const point of ['status.before-delivery', 'status.after-effect']) test(`local ${point} qualification fault preserves delivery intent across restart`, async () => { + const f = fixture(); + const faultInjector = new DeterministicFaultInjector({ enabled: true, rules: [{ id: 'delivery-recovery', point, action: 'error', operation: 'terminal' }] }); + await assert.rejects(f.create({ faultInjector }).publish(request()), /delivery-recovery/u); + assert.equal(f.comments.length, point === 'status.after-effect' ? 1 : 0); + assert.equal((await f.create().pending()).length, 1); + const recovered = await f.create().reconcile(request()); + assert.equal(recovered.published, true); + assert.equal(f.comments.length, 1); + assert.equal(f.calls.filter(entry => entry.method === 'POST' && entry.options.mutation !== false).length, 1); + assert.deepEqual(await f.create().pending(), []); +}); + test('expanded early failure evidence survives ambiguous delivery and restart in the same task comment', async () => { const f = fixture(); const diagnostics = captureFailureDiagnostics({ stage: 'apt-install', attempt: 4, secretValues: ['fixture-secret-value'], result: { diff --git a/test/json-state-store.test.js b/test/json-state-store.test.js new file mode 100644 index 000000000..a591b9790 --- /dev/null +++ b/test/json-state-store.test.js @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import os from 'node:os'; +import path from 'node:path'; +import { JsonStateStore } from '../src/state/json-state-store.js'; +import { createJsonRecordFile } from '../src/state/json-record-file.js'; + +async function fixture(t) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'db-state-replace-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const file = path.join(directory, 'state.json'); + await writeFile(file, '{"previous":true}\n'); + return file; +} + +test('state commits serialize, share initial loading and keep failed mutations out of later saves', async () => { + let reads = 0, fail = true, disk = { old: 1 }; + const store = new JsonStateStore('fixture', { file: { + read: async () => { reads++; return structuredClone(disk); }, + replace: async next => { if (fail) throw new Error('replacement unavailable'); disk = structuredClone(next); }, + } }); + await assert.rejects(store.set('failed', 2), /replacement unavailable/); + assert.equal(await store.get('failed'), undefined); + fail = false; + await Promise.all([store.set('a', { value: 3 }), store.set('b', 4), store.delete('old')]); + assert.deepEqual(disk, { a: { value: 3 }, b: 4 }); + const a = await store.get('a'); a.value = 99; + assert.deepEqual(await store.entries(), [['a', { value: 3 }], ['b', 4]]); + assert.equal(reads, 1); +}); + +test('replacement observes a lost success response without replaying its effect', async t => { + const file = await fixture(t); let calls = 0; + const owner = createJsonRecordFile(file, { platform: 'win32', renameFile: async (...args) => { + calls++; await rename(...args); throw Object.assign(new Error('response lost'), { code: 'EPERM' }); + } }); + await owner.replace({ next: 1 }); + assert.deepEqual(await owner.read(), { next: 1 }); + assert.equal(calls, 1); +}); + +test('replacement bounds Windows sharing retries and leaves permanent failures unchanged', async t => { + const file = await fixture(t); let calls = 0; const waits = []; + const owner = createJsonRecordFile(file, { platform: 'win32', wait: async ms => waits.push(ms), renameFile: async () => { + calls++; throw Object.assign(new Error('sharing violation'), { code: 'EPERM' }); + } }); + await assert.rejects(owner.replace({ next: 1 }), /sharing violation/); + assert.equal(calls, 5); assert.deepEqual(waits, [25, 75, 150, 300]); + assert.deepEqual(await owner.read(), { previous: true }); + calls = 0; + const permanent = createJsonRecordFile(file, { platform: 'win32', renameFile: async () => { + calls++; throw Object.assign(new Error('invalid operation'), { code: 'EINVAL' }); + } }); + await assert.rejects(permanent.replace({ next: 1 }), /invalid operation/); + assert.equal(calls, 1); +}); + +test('replacement refuses to overwrite a different committed value during recovery', async t => { + const file = await fixture(t); let calls = 0; + const owner = createJsonRecordFile(file, { platform: 'win32', renameFile: async () => { + calls++; await writeFile(file, '{"other":true}\n'); + throw Object.assign(new Error('sharing violation'), { code: 'EPERM' }); + } }); + await assert.rejects(owner.replace({ next: 1 }), /changed during interrupted replacement/); + assert.equal(calls, 1); + assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), { other: true }); +}); + +test('Windows replacement survives a native handle temporarily denying delete sharing', { skip: process.platform !== 'win32', timeout: 15_000 }, async t => { + const file = await fixture(t); + const code = String.raw`$ErrorActionPreference='Stop'; $file=[Console]::ReadLine(); $handle=[IO.File]::Open($file,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); try { [Console]::WriteLine('ready'); [Console]::ReadLine() | Out-Null } finally { $handle.Dispose() }`; + const child = spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(code, 'utf16le').toString('base64')], { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode == null) child.kill(); }); + const closed = once(child, 'close'); + const ready = once(child.stdout, 'data'); + child.stdin.write(`${file}\n`); + assert.equal(String((await ready)[0]).trim(), 'ready'); + let retries = 0; + const owner = createJsonRecordFile(file, { wait: async () => { + retries++; child.stdin.end('\n'); assert.equal((await closed)[0], 0); + } }); + await owner.replace({ next: 1 }); + assert.equal(retries, 1); + assert.deepEqual(await owner.read(), { next: 1 }); +}); diff --git a/test/lease-bound-invocation.test.js b/test/lease-bound-invocation.test.js new file mode 100644 index 000000000..fcdce1ab8 --- /dev/null +++ b/test/lease-bound-invocation.test.js @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { bindInvocationToLease } from '../src/runtime/lease-bound-invocation.js'; + +test('lease loss cancels submitted native work and prevents another submission', async () => { + const loss = new AbortController(); + let calls = 0; + const invoke = bindInvocationToLease(async (request) => { + calls++; + assert.equal(request.signal.aborted, false); + loss.abort(new Error('holder died')); + assert.equal(request.signal.aborted, true); + return { exitCode: 0 }; + }, { signal: loss.signal, assertHeld: () => loss.signal.throwIfAborted() }); + await assert.rejects(invoke({ executable: 'native-provider' }), /holder died/u); + await assert.rejects(invoke({ executable: 'native-provider' }), /holder died/u); + assert.equal(calls, 1); +}); + +test('caller cancellation remains linked without affecting lease ownership', async () => { + const lease = new AbortController(); + const caller = new AbortController(); + const invoke = bindInvocationToLease(async ({ signal, timeoutMs }) => { + assert.equal(timeoutMs, 500); + caller.abort(); + assert.equal(signal.aborted, true); + assert.equal(lease.signal.aborted, false); + return { aborted: true }; + }, { signal: lease.signal, assertHeld: () => lease.signal.throwIfAborted() }); + assert.deepEqual(await invoke({ signal: caller.signal, timeoutMs: 500 }), { aborted: true }); +}); diff --git a/test/legacy-file-guard.test.js b/test/legacy-file-guard.test.js new file mode 100644 index 000000000..4af55c3f0 --- /dev/null +++ b/test/legacy-file-guard.test.js @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { migrateLegacyFileGuard } from '../src/runtime/legacy-file-guard.js'; +import { mutationLease } from '../test-support/mutation-lease.js'; + +const TOKEN = '01234567-1234-5678-abcd-012345678901\n'; +async function fixture(t) { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-legacy-guard-')); + t.after(() => rm(root, { recursive: true, force: true })); + const guardFile = path.join(root, 'lifecycle.lock'); + await writeFile(guardFile, TOKEN); + return { guardFile, lease: mutationLease(root), assertQuiescent: async () => {} }; +} + +test('quiescent migration retains exact legacy evidence under an exclusive lease and is repeatable', async (t) => { + const request = await fixture(t); + let observations = 0; + request.assertQuiescent = async () => { observations++; }; + assert.deepEqual(await migrateLegacyFileGuard(request), { changed: true }); + assert.equal(observations, 3); + assert.equal(await readFile(`${request.guardFile}.retired-${TOKEN.trim()}`, 'utf8'), TOKEN); + assert.deepEqual(await migrateLegacyFileGuard(request), { changed: false }); + const held = await request.lease.acquire({ mode: 'exclusive' }); + assert.ok(held); + await held.release(); +}); + +test('an active old authority or competing new owner preserves the legacy token', async (t) => { + const request = await fixture(t); + await assert.rejects(migrateLegacyFileGuard({ ...request, assertQuiescent: async () => { throw new Error('still running'); } }), /still running/u); + const held = await request.lease.acquire({ mode: 'exclusive' }); + await assert.rejects(migrateLegacyFileGuard(request), /exclusive ownership/u); + assert.equal(await readFile(request.guardFile, 'utf8'), TOKEN); + await held.release(); +}); + +test('guard substitution and renewed old authority are checked immediately before retirement', async (t) => { + const request = await fixture(t); + let observations = 0; + request.assertQuiescent = async () => { + observations++; + if (observations === 3) await writeFile(request.guardFile, TOKEN.replace('01234567-', 'aaaaaaaa-')); + }; + await assert.rejects(migrateLegacyFileGuard(request), /changed during migration/u); + assert.equal(await readFile(request.guardFile, 'utf8'), TOKEN.replace('01234567-', 'aaaaaaaa-')); + observations = 0; + request.assertQuiescent = async () => { if (++observations === 3) throw new Error('authority restarted'); }; + await assert.rejects(migrateLegacyFileGuard(request), /authority restarted/u); +}); + +test('unrecognized legacy content cannot be adopted or retired', async (t) => { + const request = await fixture(t); + await writeFile(request.guardFile, 'foreign'); + await assert.rejects(migrateLegacyFileGuard(request), /token is invalid/u); + assert.equal(await readFile(request.guardFile, 'utf8'), 'foreign'); +}); diff --git a/test/linux-access-preparation.test.js b/test/linux-access-preparation.test.js index aae4b0898..7ce6b232d 100644 --- a/test/linux-access-preparation.test.js +++ b/test/linux-access-preparation.test.js @@ -29,7 +29,7 @@ test('Linux access preparation delivers one seed then requires strict probe read }, delivery: { put: async (receivedTarget, source, destination) => { events.push(['deliver', receivedTarget, source, destination]); } }, probe: { inspect: async () => { probes += 1; return { ready: probes >= 3, reason: 'starting' }; } }, - settleMs: 100, + settleMs: 1000, pollMs: 1, }); assert.deepEqual(await access.ensure({ target, access: resolved }), { ready: true, changed: true }); @@ -45,3 +45,46 @@ test('Linux access preparation rejects a connection identity that changed under }); await assert.rejects(() => access.ensure({ target, access: { ...resolved, identityFile: '/other' } }), /connection changed/u); }); + +test('first access allows observed two-minute guest startup with one delivery', async () => { + let clock = 0; + let deliveries = 0; + let cleaned = 0; + const access = createLinuxAccessPreparation({ + material: { connection: () => connection, prepare: async () => ({ seedFile: '/owned/seed', connection, cleanup: async () => { cleaned++; } }) }, + delivery: { put: async () => { deliveries++; } }, + probe: { inspect: async () => ({ ready: clock >= 125_000, reason: 'waiting for SSH banner' }) }, + now: () => clock, + wait: async (ms) => { clock += ms; }, + }); + assert.deepEqual(await access.ensure({ target, access: resolved }), { ready: true, changed: true }); + assert.equal(deliveries, 1); + assert.equal(cleaned, 1); + assert.equal(clock, 125_000); + assert.deepEqual(await access.ensure({ target, access: resolved }), { ready: true, changed: false }); + assert.equal(deliveries, 1); +}); + +test('first access respects enclosing deadline and cancellation and always cleans staging', async () => { + for (const cancel of [false, true]) { + let clock = 0; + let cleaned = 0; + let deliveries = 0; + const controller = new AbortController(); + const access = createLinuxAccessPreparation({ + material: { connection: () => connection, prepare: async () => ({ seedFile: '/owned/seed', connection, cleanup: async () => { cleaned++; } }) }, + delivery: { put: async (_target, _source, _destination, options) => { deliveries++; assert.equal(options.signal, controller.signal); assert.ok(options.timeoutMs <= 2_000 - clock); } }, + probe: { inspect: async (_access, options) => { + assert.equal(options.signal, controller.signal); + assert.ok(options.timeoutMs <= 2_000 - clock); + return { ready: false, reason: 'waiting for SSH banner' }; + } }, + now: () => clock, + wait: async (ms) => { clock += ms; if (cancel) controller.abort(new Error('lease lost')); }, + }); + await assert.rejects(access.ensure({ target, access: resolved, deadline: 2_000, signal: controller.signal }), cancel ? /lease lost/u : /deadline: waiting for SSH banner/u); + assert.equal(deliveries, 1); + assert.equal(cleaned, 1); + assert.ok(clock <= 2_000); + } +}); diff --git a/test/linux-file-lease.test.js b/test/linux-file-lease.test.js index cd86f056a..542939f86 100644 --- a/test/linux-file-lease.test.js +++ b/test/linux-file-lease.test.js @@ -100,7 +100,7 @@ test('contention and cancellation return no lease only after process closure', a test('malformed, stderr, and oversized readiness evidence fail closed and terminate', async () => { for (const outcome of ['malformed', 'stderr']) { const values = fixture({ outcome }); - await assert.rejects(() => values.lease.acquire({ mode: 'shared', signal: null }), /Linux file lease process/u); + await assert.rejects(() => values.lease.acquire({ mode: 'shared', signal: null }), /file lease process/u); assert.deepEqual(values.created[0].child.kills, ['SIGTERM']); } const values = fixture({ outcome: 'pending' }); diff --git a/test/liveness-projecting-plan-executor.test.js b/test/liveness-projecting-plan-executor.test.js index adfe95455..1baf0b981 100644 --- a/test/liveness-projecting-plan-executor.test.js +++ b/test/liveness-projecting-plan-executor.test.js @@ -36,6 +36,20 @@ function stateFixture() { }; } +test('workspace transfer progress is persisted and reported separately from compiler execution', async () => { + const state = stateFixture(); + const published = []; + const executor = new LivenessProjectingPlanExecutor({ + delegate: { execute: async ({ onLiveness }) => onLiveness({ operationId: 'test-op', operation: 'node.test', kind: 'source-transfer 3/9 parts', elapsedMs: 30000 }) }, + statusReporter: { publish: async result => published.push(result) }, + }); + await executor.execute({ state, persist: async () => {} }); + assert.equal(state.prior.liveness.activity, 'source-transfer 3/9 parts'); + assert.match(published[0].summary, /Preparing the workspace/); + assert.match(published[0].summary, /has not started/); + assert.equal(state.prior.liveness.processAlive, null); +}); + test('plan executor decorator durably persists and projects bounded liveness', async () => { const state = stateFixture(); const persisted = []; diff --git a/test/persistent-environment-ledger.test.js b/test/persistent-environment-ledger.test.js index cfd876381..e891f73a7 100644 --- a/test/persistent-environment-ledger.test.js +++ b/test/persistent-environment-ledger.test.js @@ -1,3 +1,4 @@ +import { mutationLease } from '../test-support/mutation-lease.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; @@ -10,7 +11,7 @@ const PROTOCOL = 'devbridge/persistent-environments-v1'; test('nested ledger publishes exact revisioned state and reloads it independently', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-environment-ledger-')); try { - const first = new EnvironmentLedger({ directory: root, protocol: PROTOCOL }); + const first = new EnvironmentLedger({ directory: root, lease: mutationLease(root), protocol: PROTOCOL }); await first.run(async () => { const state = await first.read(); assert.deepEqual(state, { protocol: PROTOCOL, revision: 0, entries: {}, operations: {} }); @@ -19,7 +20,7 @@ test('nested ledger publishes exact revisioned state and reloads it independentl assert.equal(state.revision, 1); }); - const second = new EnvironmentLedger({ directory: root, protocol: PROTOCOL }); + const second = new EnvironmentLedger({ directory: root, lease: mutationLease(root), protocol: PROTOCOL }); const observed = await second.run(() => second.read()); assert.equal(observed.revision, 1); assert.equal(observed.entries.slot.current.identity, 'env-a'); @@ -36,8 +37,8 @@ test('nested ledger serializes local work and rejects a concurrent external owne const entered = new Promise((resolve) => { enter = resolve; }); const blocked = new Promise((resolve) => { release = resolve; }); try { - const first = new EnvironmentLedger({ directory: root, protocol: PROTOCOL }); - const second = new EnvironmentLedger({ directory: root, protocol: PROTOCOL }); + const first = new EnvironmentLedger({ directory: root, lease: mutationLease(root), protocol: PROTOCOL }); + const second = new EnvironmentLedger({ directory: root, lease: mutationLease(root), protocol: PROTOCOL }); const active = first.run(async () => { enter(); await blocked; @@ -53,14 +54,36 @@ test('nested ledger serializes local work and rejects a concurrent external owne } }); -test('nested ledger release fails closed when the exact guard token changes', async () => { +test('legacy token guards remain intact while committed status stays readable', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-environment-ledger-token-')); try { - const ledger = new EnvironmentLedger({ directory: root, protocol: PROTOCOL }); - await assert.rejects(() => ledger.run(async () => { - await writeFile(path.join(root, 'lifecycle.lock'), 'substituted\n', 'utf8'); - }), /guard ownership changed/u); + const ledger = new EnvironmentLedger({ directory: root, lease: mutationLease(root), protocol: PROTOCOL }); + await writeFile(path.join(root, 'lifecycle.lock'), 'retained-legacy-token\n', 'utf8'); + assert.equal(await ledger.snapshot((state) => state.revision), 0); + await assert.rejects(() => ledger.run(() => assert.fail('must not mutate')), /quiescent authority owner/u); + assert.equal(await readFile(path.join(root, 'lifecycle.lock'), 'utf8'), 'retained-legacy-token\n'); } finally { await rm(root, { recursive: true, force: true }); } }); + +test('lease loss prevents a later commit and does not block independent status', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-ledger-loss-')); + t.after(() => rm(root, { recursive: true, force: true })); + let held; + const lease = mutationLease(root); + const ledger = new EnvironmentLedger({ directory: root, protocol: PROTOCOL, lease: { async acquire(input) { held = await lease.acquire(input); return held; } } }); + await assert.rejects(ledger.run(async () => { + const state = await ledger.read(); + await ledger.commit(state); + assert.equal(await ledger.snapshot((value) => value.revision), 1); + await held.release(); + state.entries.foreign = {}; + await ledger.commit(state); + }), /mutation lease lost/u); + const observed = await ledger.snapshot((state) => state); + assert.equal(observed.revision, 1); + assert.deepEqual(observed.entries, {}); + const replacement = new EnvironmentLedger({ directory: root, protocol: PROTOCOL, lease: mutationLease(root) }); + await replacement.run(() => replacement.read()); +}); diff --git a/test/persistent-environments-rebuild.test.js b/test/persistent-environments-rebuild.test.js index ef5851a2a..e83f585d1 100644 --- a/test/persistent-environments-rebuild.test.js +++ b/test/persistent-environments-rebuild.test.js @@ -1,3 +1,4 @@ +import { mutationLease } from '../test-support/mutation-lease.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -65,7 +66,7 @@ test('rebuild replaces a missing-storage generation without requiring the old di const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-registry-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); loseSystemStorage(fake, created.record.identity, 'absent'); const rebuilt = await registry.rebuild(created.record.identity, { @@ -94,7 +95,7 @@ test('rebuild waits for the outer lifecycle owner after restart, then reconciles const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-reconcile-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); loseSystemStorage(fake, created.record.identity, 'invalid'); fake.failNextProvision(); @@ -104,7 +105,7 @@ test('rebuild waits for the outer lifecycle owner after restart, then reconciles }), /simulated interruption/u); assert.equal(fake.instances.size, 2); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const generic = await registry.reconcile(); assert.equal(generic.length, 1); assert.equal(generic[0].record.identity, created.record.identity); @@ -125,7 +126,7 @@ test('rebuild rejects a healthy, foreign, running-unquiesceable, or stale previo const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-guards-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); await assert.rejects(() => registry.rebuild(created.record.identity, { requestId: 'healthy-rebuild', expectedPreviousIdentity: created.record.identity, @@ -154,7 +155,7 @@ test('definitive rebuild preflight rejection does not become latent reconciliati const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-inert-rejection-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); await assert.rejects(() => registry.rebuild(created.record.identity, { requestId: 'rejected-while-healthy', expectedPreviousIdentity: created.record.identity, @@ -172,7 +173,7 @@ test('rebuild re-proves provider existence and ownership after quiesce before re const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-quiesce-proof-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); loseSystemStorage(fake, created.record.identity, 'absent'); fake.instances.get(created.record.identity).state = 'running'; @@ -190,7 +191,7 @@ test('rebuild retains the superseded generation even if it later appears compati const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-retention-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); loseSystemStorage(fake, created.record.identity, 'invalid'); const provision = fake.operations.provision.bind(fake.operations); @@ -217,7 +218,7 @@ test('declared-image rebuild resumes one replacement through materialization and const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-declared-image-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const old = structuredClone(fake.instances.get(created.record.identity)); const input = { @@ -232,16 +233,14 @@ test('declared-image rebuild resumes one replacement through materialization and const subject = { resolve: async () => request().subject }; const observe = createEnvironmentMaterialization({ state, subject, settings: { resolve: async () => request().settings } }); assert.equal(environmentObservationCondition(await observe.observe(input)), 'system-storage-invalid'); - const materialization = createEnvironmentRebuildMaterialization({ state, subject, journal: { current: async () => ({ - operation: 'rebuild', operationId: input.operationId, declarationRevision: 2, - entries: [{ stage: 'pre-observation', implementationGeneration: created.record.identity }, { stage: 'fenced-attempt' }], - }) } }); + input.operationSubject = { environmentIdentity: input.environmentIdentity, operationId: input.operationId, declarationRevision: 2, operation: 'rebuild', previousImplementationGeneration: created.record.identity, imageIdentity: TARGET, imageGeneration: input.declaration.image.generation }; + const materialization = createEnvironmentRebuildMaterialization({ state, subject }); // Reconstruction must depend on the desired image, even if the old base is no longer available. fake.sources.delete(SOURCE); fake.failNextProvision(); await assert.rejects(() => materialization.ensure(input), /simulated interruption/u); assert.equal(fake.instances.size, 2); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); assert.equal((await registry.reconcile())[0].record.identity, created.record.identity); const result = await materialization.ensure(input); assert.equal(result.ready, true); @@ -262,12 +261,12 @@ test('pending declared-image rebuild refuses retargeting and target lineage drif const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-target-drift-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const options = { requestId: 'target-drift', expectedPreviousIdentity: created.record.identity, sourceIdentity: TARGET }; fake.failNextProvision(); await assert.rejects(() => registry.rebuild(created.record.identity, options), /simulated interruption/u); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); await assert.rejects(() => registry.rebuild(created.record.identity, { ...options, sourceIdentity: SOURCE }), /target source changed/u); const target = structuredClone(fake.sources.get(TARGET)); for (const changed of [{ digest: 'c'.repeat(64) }, { revision: '2026.09.2' }, { profile: 'guest-b' }]) { @@ -290,7 +289,7 @@ test('declared-image rebuild rejects unknown, foreign, missing, or unexplained e const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-target-guards-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const old = structuredClone(fake.instances.get(created.record.identity)); const options = { requestId: 'target-guards', expectedPreviousIdentity: created.record.identity, sourceIdentity: TARGET }; @@ -320,7 +319,7 @@ test('declared-image rebuild rechecks old lineage after quiescing', async () => const root = await mkdtemp(path.join(os.tmpdir(), 'db-rebuild-target-quiesce-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const old = fake.instances.get(created.record.identity); old.state = 'running'; diff --git a/test/persistent-environments-recreate.test.js b/test/persistent-environments-recreate.test.js index 91b0ca5e0..2a792c199 100644 --- a/test/persistent-environments-recreate.test.js +++ b/test/persistent-environments-recreate.test.js @@ -1,3 +1,4 @@ +import { mutationLease } from '../test-support/mutation-lease.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -53,7 +54,7 @@ test('recreate advances the generation when the registered provider object is al const root = await mkdtemp(path.join(os.tmpdir(), 'db-recreate-missing-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.instances.delete(created.record.identity); const recreated = await registry.recreate(created.record.identity, { @@ -76,7 +77,7 @@ test('recreate tolerates incompatible owned state but retains it until explicit const root = await mkdtemp(path.join(os.tmpdir(), 'db-recreate-retain-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.instances.get(created.record.identity).state = 'running'; fake.instances.get(created.record.identity).compatible = false; @@ -101,7 +102,7 @@ test('recreate refuses foreign provider ownership before provisioning or retirem const root = await mkdtemp(path.join(os.tmpdir(), 'db-recreate-foreign-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.instances.get(created.record.identity).owned = false; const beforeProvision = fake.provisionCalls(); @@ -118,7 +119,7 @@ test('interrupted recreate is not replayed by generic reconciliation and reuses const root = await mkdtemp(path.join(os.tmpdir(), 'db-recreate-resume-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.failNextProvision(); await assert.rejects(() => registry.recreate(created.record.identity, { @@ -127,7 +128,7 @@ test('interrupted recreate is not replayed by generic reconciliation and reuses assert.equal(fake.instances.size, 2); assert.equal(fake.provisionCalls(), 2); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const generic = await registry.reconcile(); assert.equal(generic[0].record.identity, created.record.identity); assert.equal(fake.provisionCalls(), 2); diff --git a/test/persistent-environments-replacement.test.js b/test/persistent-environments-replacement.test.js index 111d73dd6..3a60c8c1c 100644 --- a/test/persistent-environments-replacement.test.js +++ b/test/persistent-environments-replacement.test.js @@ -1,3 +1,4 @@ +import { mutationLease } from '../test-support/mutation-lease.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -52,7 +53,7 @@ test('request-bound replacement retains the exact superseded generation until ex const root = await mkdtemp(path.join(os.tmpdir(), 'db-replacement-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const replaced = await registry.replace(created.record.identity, { requestId: 'lifecycle-reset-1', @@ -90,7 +91,7 @@ test('interrupted replacement waits for the outer lifecycle owner, then reconcil const root = await mkdtemp(path.join(os.tmpdir(), 'db-replacement-reconcile-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.failNextProvision(); await assert.rejects(() => registry.replace(created.record.identity, { @@ -99,7 +100,7 @@ test('interrupted replacement waits for the outer lifecycle owner, then reconcil }), /simulated interruption/u); assert.equal(fake.instances.size, 2); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const generic = await registry.reconcile(); assert.equal(generic.length, 1); assert.equal(generic[0].record.generation, 1); @@ -123,7 +124,7 @@ test('retirement rejects foreign or non-history subjects and never broadens dele const root = await mkdtemp(path.join(os.tmpdir(), 'db-replacement-retire-guards-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const replaced = await registry.replace(created.record.identity, { requestId: 'lifecycle-reset-3', diff --git a/test/persistent-environments.test.js b/test/persistent-environments.test.js index cac71e53d..70686d405 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.test.js @@ -1,9 +1,11 @@ +import { mutationLease } from '../test-support/mutation-lease.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { PersistentEnvironments } from '../src/runtime/persistent-environments.js'; +import { requiredBootProtection } from '../src/values/boot-protection.js'; const SOURCE_A = 'img-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const SOURCE_B = 'img-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; @@ -50,11 +52,74 @@ function request(sourceIdentity = SOURCE_A) { return { subject: 'immutable-subject-42', profile: 'guest-a', sourceIdentity, settings: { memoryBytes: 2147483648, processorCount: 2, firmware: 'efi' } }; } +test('selected environment observation does not inspect an unrelated unavailable profile', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-selected-environment-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fixture(); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); + const selected = await registry.ensure(request()); + fake.sources.get(SOURCE_B).profile = 'guest-b'; + const sibling = await registry.ensure({ ...request(SOURCE_B), subject: 'opaque/sibling', profile: 'guest-b' }); + const observe = fake.operations.observe; + const calls = []; + fake.operations.observe = async identity => { + calls.push(identity); + if (identity === sibling.record.identity) throw new Error('unrelated provider unavailable'); + return observe(identity); + }; + const found = await registry.list({ subject: request().subject, profile: request().profile }); + assert.deepEqual(found.map(x => x.record.identity), [selected.record.identity]); + assert.deepEqual(calls, [selected.record.identity]); + assert.deepEqual(await registry.list({ subject: 'absent' }), []); + await assert.rejects(registry.list({ nativePath: '/forbidden' }), /not allowed/); + await assert.rejects(registry.list({ profile: '../foreign' }), /invalid/); + await assert.rejects(registry.list(), /unrelated provider unavailable/); +}); + +test('committed record queries stay available through native failure and invalidate observations by revision', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-environment-records-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fixture(); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); + const current = await registry.ensure(request()); + fake.operations.observe = async () => { throw new Error('native observation unavailable'); }; + const snapshot = await registry.records({ subject: request().subject }); + assert.deepEqual(snapshot.records, [current.record]); + snapshot.records[0].profile = 'tampered'; + assert.equal((await registry.records()).records[0].profile, 'guest-a'); + await assert.rejects(registry.stop(current.record.identity), /native observation unavailable/); + const interrupted = await registry.records(); + assert.deepEqual(interrupted.records, [current.record]); + assert.ok(interrupted.revision > snapshot.revision); + assert.deepEqual((await registry.records({ subject: 'unrelated' })).records, []); +}); + +test('protected boot intent reaches the provider, survives restart, and cannot be silently downgraded', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-protected-boot-intent-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fixture(); + const provision = fake.operations.provision; + fake.operations.provision = async (input) => { + assert.deepEqual(input.settings.bootProtection, requiredBootProtection()); + return provision(input); + }; + const options = { directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }; + const protectedRequest = { ...request(), settings: { ...request().settings, bootProtection: requiredBootProtection() } }; + const created = await new PersistentEnvironments(options).ensure(protectedRequest); + assert.deepEqual(created.record.settings.bootProtection, requiredBootProtection()); + const resumed = new PersistentEnvironments(options); + assert.equal((await resumed.ensure(protectedRequest)).record.identity, created.record.identity); + await assert.rejects(resumed.ensure(request()), /settings changed/u); + await assert.rejects(resumed.ensure({ ...protectedRequest, settings: { ...protectedRequest.settings, firmware: 'bios' } }), /requires EFI/u); + await assert.rejects(resumed.ensure({ ...protectedRequest, settings: { ...protectedRequest.settings, bootProtection: { ...requiredBootProtection(), integrity: 'optional' } } }), /integrity is invalid/u); + assert.equal(fake.provisionCalls(), 1); +}); + test('stable identity excludes display topology and rejects foreign request properties', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-registry-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const first = await registry.ensure(request()); const second = await registry.ensure(request()); assert.equal(second.record.identity, first.record.identity); @@ -69,7 +134,7 @@ test('nested composition preserves the exact durable v1 catalog shape', async () const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-catalog-shape-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const catalog = JSON.parse(await readFile(path.join(root, 'catalog.json'), 'utf8')); assert.deepEqual(Object.keys(catalog).sort(), ['entries', 'operations', 'protocol', 'revision']); @@ -96,7 +161,7 @@ test('source drift is rejected until explicit reseed and stale generation identi const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-reseed-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const first = await registry.ensure(request()); await assert.rejects(() => registry.ensure(request(SOURCE_B)), /explicit reseed is required/u); const reseeded = await registry.reseed(first.record.identity, { sourceIdentity: SOURCE_B }); @@ -116,7 +181,7 @@ test('registered source identity cannot be reused with changed lineage metadata' const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-source-metadata-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); await registry.ensure(request()); fake.sources.get(SOURCE_A).revision = '2026.08.changed'; await assert.rejects(() => registry.ensure(request()), /source lineage changed/u); @@ -128,11 +193,11 @@ test('command completion and daemon restart preserve one owned environment', asy const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-restart-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); await registry.start(created.record.identity); await registry.stop(created.record.identity); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const observed = await registry.observe(created.record.identity); assert.equal(observed.record.identity, created.record.identity); assert.equal(observed.observation.state, 'stopped'); @@ -146,11 +211,11 @@ test('restart reconciles an ambiguous provision effect instead of allocating ano const fake = fixture(); try { fake.failNextProvision(); - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); await assert.rejects(() => registry.ensure(request()), /simulated interruption/u); assert.equal(fake.instances.size, 1); const effectIdentity = [...fake.instances.keys()][0]; - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const reconciled = await registry.reconcile(); assert.equal(reconciled.length, 1); assert.equal(reconciled[0].record.identity, effectIdentity); @@ -163,7 +228,7 @@ test('restart reconciles an interrupted reseed without deleting the current gene const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-reseed-reconcile-')); const fake = fixture(); try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.failNextProvision(); await assert.rejects(() => registry.reseed(created.record.identity, { sourceIdentity: SOURCE_B }), /simulated interruption/u); @@ -172,7 +237,7 @@ test('restart reconciles an interrupted reseed without deleting the current gene assert.equal(identitiesAfterInterruption.includes(created.record.identity), true); assert.equal(identitiesAfterInterruption.length, 2); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const protectedWhilePending = await registry.protectedSourceIdentities(); assert.deepEqual(protectedWhilePending, [SOURCE_A, SOURCE_B]); const reconciled = await registry.reconcile(); @@ -199,12 +264,12 @@ test('restart reconciles an ambiguous removal effect without retaining catalog a return result; }; try { - let registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + let registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); await assert.rejects(() => registry.remove(created.record.identity), /simulated interruption/u); assert.equal(fake.instances.has(created.record.identity), false); - registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); assert.deepEqual(await registry.reconcile(), []); await assert.rejects(() => registry.observe(created.record.identity), /not registered/u); } finally { await rm(root, { recursive: true, force: true }); } @@ -214,7 +279,7 @@ test('lifecycle transitions reject a provider observation that reports the wrong const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-lineage-observe-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); fake.instances.get(created.record.identity).storage.sourceIdentity = SOURCE_B; await assert.rejects(() => registry.start(created.record.identity), /writable lineage does not match/u); @@ -226,7 +291,7 @@ test('concurrent lifecycle mutations serialize and stale callers cannot rotate t const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-concurrent-')); const fake = fixture(); try { - const registry = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const registry = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const created = await registry.ensure(request()); const results = await Promise.allSettled([registry.reset(created.record.identity), registry.reset(created.record.identity)]); assert.equal(results.filter((entry) => entry.status === 'fulfilled').length, 1); @@ -242,10 +307,10 @@ test('attachment identity drift never silently adopts an environment created by const root = await mkdtemp(path.join(os.tmpdir(), 'db-stage3-binding-')); const fake = fixture(); try { - const first = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const first = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); await first.ensure(request()); const foreignOperations = { ...fake.operations, async inspect() { return { identity: '22222222222222222222222222222222' }; } }; - const second = new PersistentEnvironments({ directory: root, source: fake.source, operations: foreignOperations }); + const second = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: foreignOperations }); await assert.rejects(() => second.ensure(request()), /attachment identity changed/u); } finally { await rm(root, { recursive: true, force: true }); } }); @@ -265,8 +330,8 @@ test('separate registry instances cannot overlap one directory lifecycle', async }, }; try { - const first = new PersistentEnvironments({ directory: root, source: delayedSource, operations: fake.operations }); - const second = new PersistentEnvironments({ directory: root, source: fake.source, operations: fake.operations }); + const first = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: delayedSource, operations: fake.operations }); + const second = new PersistentEnvironments({ directory: root, lease: mutationLease(root), source: fake.source, operations: fake.operations }); const ensuring = first.ensure(request()); await entered; await assert.rejects(() => second.ensure(request()), /lifecycle mutation is already active/u); diff --git a/test/provider-local-nested-lego.test.js b/test/provider-local-nested-lego.test.js index e6f057a47..6adb1d927 100644 --- a/test/provider-local-nested-lego.test.js +++ b/test/provider-local-nested-lego.test.js @@ -46,17 +46,26 @@ async function sourceFiles(directory) { return nested.flat(); } -test('provider-local nested LEGOs are sibling-independent and cross-provider isolated', async () => { - for (const relative of Object.values(groups).flat()) { - const text = await source(relative); - assert.doesNotMatch(text, /from ['"]\.\.?\//u, `${relative} imported another local implementation`); - assert.doesNotMatch(text, /github|codex|repository|remote agent/iu, `${relative} leaked external topology`); - } - for (const relative of [...groups.hypervEnvironment, ...groups.hypervConstruction]) { - assert.doesNotMatch(await source(relative), /libvirt|qemu|qcow2|virsh/iu, `${relative} leaked another provider`); +function dependencies(text) { + return [...text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/gu)].map((match) => match[1]); +} + +test('provider children depend on native ports or pure shared contracts, not sibling effects or other owners', async () => { + // The parent owns this shared value contract. It grants no effect or storage + // capability. A blanket ban on relative imports confused contracts with peers. + const contracts = new Set([path.join(providerRoot, 'hyperv-image-construction/console-format.js')]); + for (const contract of contracts) { + assert.deepEqual(dependencies(await readFile(contract, 'utf8')), [], 'shared console format must remain a capability-free leaf'); } - for (const relative of groups.libvirtEnvironment) { - assert.doesNotMatch(await source(relative), /hyper-?v|powershell|vhdx/iu, `${relative} leaked another provider`); + const directories = [...new Set(Object.values(groups).flat().map((file) => path.dirname(file)))]; + for (const directory of directories) { + for (const file of await sourceFiles(path.join(providerRoot, directory))) { + for (const dependency of dependencies(await readFile(file, 'utf8'))) { + if (dependency.startsWith('node:')) continue; + const target = path.resolve(path.dirname(file), dependency); + assert.ok(dependency.startsWith('.') && contracts.has(target), `${file} depends on implementation ${dependency}; compose that effect through its parent`); + } + } } }); diff --git a/test/public-repository.test.js b/test/public-repository.test.js index 86b60029b..56e1a6e7a 100644 --- a/test/public-repository.test.js +++ b/test/public-repository.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { parseRepositoryPreflightArguments } from "../src/bootstrap/repository-preflight.mjs"; const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); @@ -92,25 +93,31 @@ test("Windows full CI coverage serializes test files while other platforms retai assert.match(windows, /^ run: npm\.cmd test -- --test-concurrency=1$/mu); }); -test("Windows smoke preflight selects a closed concurrency bound while other platforms retain default scheduling", async () => { +test("CI static prerequisites do not repeat the full job's behavioral suites", async () => { const workflow = await read(".github/workflows/ci.yml"); const ordinary = workflowStep(workflow, "Cheap preflight (non-Windows default)"); const windows = workflowStep(workflow, "Cheap preflight (Windows bounded)"); - assert.match(workflow.replaceAll("\r\n", "\n"), /^ smoke:\n timeout-minutes: 11$/mu); - assert.match(ordinary, /^ if: runner\.os != 'Windows'$/mu); - assert.match(ordinary, /^ timeout-minutes: 7$/mu); - assert.match(ordinary, /^ run: npm run preflight -- --ci-qualification$/mu); - assert.doesNotMatch(ordinary, /bound-targeted-test-concurrency/u); - - assert.match(windows, /^ if: runner\.os == 'Windows'$/mu); - assert.match(windows, /^ timeout-minutes: 7$/mu); - assert.match(windows, /^ run: npm\.cmd run preflight -- --ci-qualification --bound-targeted-test-concurrency$/mu); + for (const step of [ordinary, windows]) { + const command = /^\s+run: npm(?:\.cmd)? run preflight -- (.+)$/mu.exec(step); + assert.ok(command, 'static prerequisite must invoke the real preflight entry'); + const options = parseRepositoryPreflightArguments(command[1].trim().split(/\s+/u)); + assert.equal(options.staticOnly, true); + assert.equal(options.ciQualification, true); + } + assert.equal([...workflow.matchAll(/^\s+run: npm(?:\.cmd)? test(?: .*)?$/gmu)].length, 2); + assert.doesNotMatch(workflow, /^\s+run: node --test /mu, 'full-suite subsets must not be invoked again'); + const fullJob = workflow.split(/^ test:\s*$/mu)[1]; + assert.match(fullJob, /^ needs: smoke$/mu, 'expensive tests depend on successful static prerequisites'); }); test("CI job deadlines reserve setup and downstream time beyond their largest selected suite", async () => { const workflow = (await read(".github/workflows/ci.yml")).replaceAll("\r\n", "\n"); - assert.match(workflow, /^ smoke:\n timeout-minutes: 11$/mu); - assert.match(workflow, /^ test:\n timeout-minutes: 14$/mu); + for (const name of ['smoke', 'test']) { + const section = workflow.split(new RegExp(`^ ${name}:\\s*$`, 'mu'))[1].split(/^ [a-z]+:\s*$/mu)[0]; + const jobLimit = Number(/^ timeout-minutes: (\d+)$/mu.exec(section)?.[1]); + const stepLimits = [...section.matchAll(/^ timeout-minutes: (\d+)$/gmu)].map(match => Number(match[1])); + assert.ok(stepLimits.length > 0 && jobLimit > Math.max(...stepLimits), `${name} reserves time beyond its largest step`); + } assert.doesNotMatch(workflow, /continue-on-error|retry/u); }); diff --git a/test/repository-execution-nested-lego.test.js b/test/repository-execution-nested-lego.test.js index 959af1ee6..d9aa0b8a9 100644 --- a/test/repository-execution-nested-lego.test.js +++ b/test/repository-execution-nested-lego.test.js @@ -85,6 +85,18 @@ test('byte channel copies retained input and enforces contiguous bounded output' await assert.rejects(() => invalid.emit({ class: 'output', path: 'value' }, { write: async () => {} }), /not contiguous/u); }); +test('byte channel checks cancellation between frames and reports confirmed progress', async () => { + const fixture = byteFixture(); + const controller = new AbortController(); + const offsets = []; + await assert.rejects(fixture.channel.write(Buffer.from('source'), { class: 'input', path: 'value' }, { + signal: controller.signal, + onProgress: ({ offset }) => { offsets.push(offset); if (offset === 2) controller.abort(new Error('lease lost')); }, + }), /lease lost/); + assert.deepEqual(offsets, [0, 2]); + assert.equal(fixture.writes.length, 0); +}); + test('operation materializer stages bounded resources and one closed descriptor', async () => { const staged = []; const owner = new OperationMaterializer({ @@ -166,6 +178,17 @@ test('route owner resolves exactly one compatible target and one admitted root', }).resolve({}), /invalid-root/u); }); +test('route owner requests only the selected subject and profile and rejects a foreign observation', async () => { + const selections = []; + const owner = routeOwner({ list: async selection => { + selections.push(selection); + return [{ record: { subject: '999', profile: selection.profile, identity: 'foreign' }, + observation: { exists: true, owned: true, compatible: true } }]; + } }); + await assert.rejects(() => owner.resolve({}), /absent/u); + assert.deepEqual(selections, [{ subject: '123', profile: 'profile-a' }]); +}); + function sessionMessages() { return { activityUnavailable: 'activity-unavailable', @@ -182,6 +205,7 @@ function sessionMessages() { test('workspace session sequences only its local ports and closes exact ownership', async () => { const calls = []; + const progress = []; const snapshot = { manifest: { digest: 'source-digest', entries: [] }, manifestBytes: () => Buffer.from('{}'), @@ -195,8 +219,13 @@ test('workspace session sequences only its local ports and closes exact ownershi source: { snapshot: async () => { calls.push('snapshot'); return snapshot; }, install: async () => calls.push('install'), - observe: async () => { calls.push('observe'); return { appliedDigest: 'source-digest' }; }, - writePart: async () => calls.push('part'), + observe: async (_digest, { onActivity }) => { + calls.push('observe'); + await onActivity({ kind: 'started', processAlive: true }); + await onActivity({ kind: 'finished', processAlive: false }); + return { appliedDigest: 'source-digest' }; + }, + transfer: async () => calls.push('part'), writeManifest: async () => calls.push('manifest'), apply: async () => { calls.push('apply-source'); return { digest: 'source-digest' }; }, }, @@ -224,7 +253,8 @@ test('workspace session sequences only its local ports and closes exact ownershi messages: sessionMessages(), }); - assert.deepEqual(await session.prepare(), { identity: 'run-evidence' }); + assert.deepEqual(await session.prepare({ onActivity: async event => progress.push(event.kind) }), { identity: 'run-evidence' }); + assert.deepEqual(progress, ['workspace-prepare', 'workspace-health', 'source-snapshot', 'source-agent', 'source-check', 'source-check', 'source-check', 'source-verify']); await session.input('input', { read: async () => Buffer.alloc(0) }); await session.run({ invocation: { workingDirectory: '.' }, environment: {}, transfers: [], limits: {}, stdin: null }); await session.output('output', { write: async () => {} }); @@ -243,13 +273,6 @@ test('workspace session sequences only its local ports and closes exact ownershi test('nested execution owners import no sibling and cannot name provider or host fallback topology', async () => { const directory = path.join(ROOT, 'src', 'app', 'repository-execution'); const names = (await readdir(directory)).filter((name) => name.endsWith('.js')).sort(); - assert.deepEqual(names, [ - 'byte-channel.js', - 'operation-materializer.js', - 'route-access.js', - 'session-guard.js', - 'workspace-session.js', - ]); const forbidden = /(?:from ['"]\.\.?\/|hyper-?v|libvirt|qemu|powershell|virsh|github|codex|environment-bridge|persistent-environment|workspace-agent|resource-agent|bubblewrap|appcontainer|processcontainer|child_process|\bspawn\b|\bexecFile\b)/imu; for (const name of names) { const source = await readFile(path.join(directory, name), 'utf8'); diff --git a/test/repository-preflight-options.test.js b/test/repository-preflight-options.test.js index 7d3a58783..305b1f9e3 100644 --- a/test/repository-preflight-options.test.js +++ b/test/repository-preflight-options.test.js @@ -17,8 +17,8 @@ function successfulRunner(calls) { } test('preflight arguments expose only closed scheduling and qualification selections', () => { - assert.deepEqual(parseRepositoryPreflightArguments([]), { boundTargetedTestConcurrency: false, ciQualification: false }); - assert.deepEqual(parseRepositoryPreflightArguments(['--bound-targeted-test-concurrency']), { boundTargetedTestConcurrency: true, ciQualification: false }); + assert.deepEqual(parseRepositoryPreflightArguments([]), { boundTargetedTestConcurrency: false, ciQualification: false, staticOnly: false }); + assert.deepEqual(parseRepositoryPreflightArguments(['--bound-targeted-test-concurrency']), { boundTargetedTestConcurrency: true, ciQualification: false, staticOnly: false }); assert.throws(() => parseRepositoryPreflightArguments('--bound-targeted-test-concurrency'), /must be an array/u); assert.throws(() => parseRepositoryPreflightArguments(['--bound-targeted-test-concurrency=1']), /accepts only/u); assert.throws(() => parseRepositoryPreflightArguments(['--bound-targeted-test-concurrency', '--bound-targeted-test-concurrency']), /accepts only/u); @@ -62,7 +62,22 @@ test('programmatic preflight scheduling rejects open or malformed options before ); }); +test('static preflight retains artifact, syntax and inventory checks without repeating behavioral suites', () => { + const fullCalls = [], staticCalls = []; + const full = runRepositoryPreflight(root, successfulRunner(fullCalls), {}, {}); + const selected = parseRepositoryPreflightArguments(['--static-only']); + const observed = runRepositoryPreflight(root, successfulRunner(staticCalls), {}, selected); + assert.deepEqual(staticCalls.map(call => call.args), fullCalls.filter(call => call.args[0] !== '--test').map(call => call.args)); + assert.equal(observed.staticOnly, true); + assert.equal(observed.targetedTests, 0); + assert.equal(observed.syntaxFiles, full.syntaxFiles); + assert.ok(full.targetedTests > 0); + assert.throws(() => runRepositoryPreflight(root, successfulRunner([]), {}, { staticOnly: 'yes' }), /must be boolean/); +}); + test('CI qualification is explicit, finite, independent of scheduling and does not alter inventory', () => { + const ordinaryCalls = []; + const ordinary = runRepositoryPreflight(root, successfulRunner(ordinaryCalls), {}, {}); for (const args of [['--ci-qualification'], ['--ci-qualification', '--bound-targeted-test-concurrency'], ['--bound-targeted-test-concurrency', '--ci-qualification']]) { const options = parseRepositoryPreflightArguments(args); @@ -71,7 +86,8 @@ test('CI qualification is explicit, finite, independent of scheduling and does n const result = runRepositoryPreflight(root, successfulRunner(calls), {}, options, { now: () => 0, onProgress: (event) => events.push(event), }); - assert.equal(result.targetedTests, 244); + assert.equal(result.targetedTests, ordinary.targetedTests); + assert.deepEqual(calls.at(-1).args.filter(arg => arg.endsWith('.test.js')), ordinaryCalls.at(-1).args.filter(arg => arg.endsWith('.test.js'))); assert.equal(events[0].remainingMs, 360_000); assert.equal(calls.at(-1).options.timeout, 300_000); assert.equal(calls.at(-1).args.includes('--test-concurrency=2'), args.length === 2); @@ -94,7 +110,9 @@ test('preflight emits operation evidence before work and does not renew its aggr const result = runRepositoryPreflight(root, runner, {}, {}, { now: () => time, onProgress: (event) => events.push(event), }); - assert.equal(result.targetedTests, 244); + const selectedTests = calls.at(-1).args.filter(arg => arg.endsWith('.test.js')); + assert.equal(result.targetedTests, selectedTests.length); + assert.equal(new Set(selectedTests).size, selectedTests.length); assert.equal(events[0].remainingMs, 210_000); assert.equal(events.at(-1).status, 'passed'); assert.ok(events.at(-1).remainingMs < 20_000); @@ -177,7 +195,9 @@ test('invalid clocks fail closed and rejected invocations do not poison later qu assert.throws(() => runRepositoryPreflight(root, () => { time = 0; return { status: 0 }; }, {}, {}, { now: () => time, }), /clock is invalid/u); - assert.equal(runRepositoryPreflight(root, successfulRunner([]), {}, {}, { + const recoveredCalls = []; + const recovered = runRepositoryPreflight(root, successfulRunner(recoveredCalls), {}, {}, { now: () => 0, - }).targetedTests, 244); + }); + assert.equal(recovered.targetedTests, recoveredCalls.at(-1).args.filter(arg => arg.endsWith('.test.js')).length); }); diff --git a/test/repository-source-transfer.test.js b/test/repository-source-transfer.test.js new file mode 100644 index 000000000..fbdd433d2 --- /dev/null +++ b/test/repository-source-transfer.test.js @@ -0,0 +1,233 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { snapshotFileTree } from '../src/runtime/file-tree-transfer.js'; +import { transferRepositorySource } from '../src/app/repository-execution/source-transfer.js'; + +const agent = fileURLToPath(new URL('../src/guest/workspace-agent.mjs', import.meta.url)); +const packAgent = fileURLToPath(new URL('../src/guest/source-pack-agent.mjs', import.meta.url)); +const hash = value => createHash('sha256').update(value).digest('hex'); +function run(cwd, args, executable = agent) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [executable, ...args], { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', err = ''; + child.stdout.on('data', bytes => { out += bytes; }); + child.stderr.on('data', bytes => { err += bytes; }); + child.once('error', reject); + child.once('close', code => resolve({ code, out, err })); + }); +} +async function fixture(t) { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-source-pack-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'), input = path.join(root, 'input'), work = path.join(root, 'work'); + await Promise.all([mkdir(source), mkdir(input), mkdir(work)]); + return { root, source, input, work, pack: path.join(input, 'parts.gz') }; +} + +test('consumer packs many files and actual guest provider applies the exact original manifest', async t => { + const f = await fixture(t); + const content = new Map(Array.from({ length: 360 }, (_, i) => [`source-${i}.txt`, Buffer.from(`int value_${i} = ${i};\n`.repeat(8))])); + content.set('empty.txt', Buffer.alloc(0)); + content.set('binary.dat', randomBytes(32768)); + await Promise.all([...content].map(([name, bytes]) => writeFile(path.join(f.source, name), bytes))); + const snapshot = await snapshotFileTree({ root: f.source, listPaths: async () => [...content.keys()] }); + let packs = 0, compressedBytes = 0; + await transferRepositorySource({ + snapshot, + writePack: async bytes => { packs += 1; compressedBytes += bytes.length; await writeFile(f.pack, bytes); }, + unpack: async identity => { + const result = await run(f.work, [f.pack, identity], packAgent); + assert.equal(result.code, 0, result.err); + return JSON.parse(result.out); + }, + writePart: () => assert.fail('small parts should use the packed transport'), + }); + assert.equal(packs, 1); + assert.ok(compressedBytes < snapshot.manifest.totalBytes); + await assert.rejects(readFile(f.pack), { code: 'ENOENT' }); + const manifestFile = path.join(f.input, 'manifest.json'); + await writeFile(manifestFile, snapshot.manifestBytes()); + const applied = await run(f.work, ['apply', manifestFile, path.join(f.root, 'state.json')]); + assert.equal(applied.code, 0, applied.err); + assert.equal(JSON.parse(applied.out).digest, snapshot.manifest.digest); + for (const [name, bytes] of content) assert.deepEqual(await readFile(path.join(f.work, name)), bytes); +}); + +test('source transfer bounds packs, streams large parts, and rejects changed source and receipts', async t => { + const f = await fixture(t); + const small = Buffer.alloc(1024 * 1024, 'a'), large = Buffer.alloc(3 * 1024 * 1024, 'b'); + await writeFile(path.join(f.source, 'small-a'), small); + await writeFile(path.join(f.source, 'small-b'), small); + await writeFile(path.join(f.source, 'large'), large); + const snapshot = await snapshotFileTree({ root: f.source, listPaths: async () => ['small-a', 'small-b', 'large'] }); + let packed, raw = 0; + const ports = { + snapshot, + writePack: async bytes => { packed = JSON.parse(gunzipSync(bytes).toString()); }, + unpack: async identity => ({ ready: true, digest: identity, parts: packed.parts.length }), + writePart: async (part, read) => { raw += 1; const response = await read({ offset: 0, limit: part.size }); assert.deepEqual(response.data, large); }, + }; + await transferRepositorySource(ports); + assert.equal(raw, 1); + assert.equal(packed.parts.length, 2); + await assert.rejects(transferRepositorySource({ ...ports, unpack: async () => ({ ready: true, digest: '0'.repeat(64), parts: 2 }) }), /receipt does not match/); + await writeFile(path.join(f.source, 'small-a'), 'changed'); + await assert.rejects(transferRepositorySource(ports), /source part changed/); +}); + +test('cancellation after pack delivery prevents unpack and subsequent effects', async t => { + const f = await fixture(t); + await writeFile(path.join(f.source, 'a'), 'a'); + const snapshot = await snapshotFileTree({ root: f.source, listPaths: async () => ['a'] }); + const controller = new AbortController(); + await assert.rejects(transferRepositorySource({ snapshot, signal: controller.signal, + writePack: async () => controller.abort(new Error('lease lost')), + unpack: () => assert.fail('cancelled transfer cannot unpack'), writePart: () => assert.fail('unexpected raw transfer'), + }), /lease lost/); +}); + +test('source progress uses the run liveness contract and awaits checkpointing before effects', async t => { + const f = await fixture(t); + await writeFile(path.join(f.source, 'a'), 'a'); + const snapshot = await snapshotFileTree({ root: f.source, listPaths: async () => ['a'] }); + const events = []; + let checkpointed = false; + await transferRepositorySource({ snapshot, + onActivity: async event => { await Promise.resolve(); events.push(event.kind); checkpointed = true; }, + writePack: async (_bytes, controls) => { assert.equal(checkpointed, true); await controls.onProgress({ offset: 0, total: 123 }); }, + unpack: async digest => ({ ready: true, digest, parts: 1 }), + writePart: () => assert.fail('unexpected raw transfer'), + }); + assert.deepEqual(events, ['source-transfer 0/1 parts', 'source-transfer 0/1 parts; pack 0/123 bytes', 'source-transfer 1/1 parts']); + await assert.rejects(transferRepositorySource({ snapshot, + onActivity: async () => { throw new Error('checkpoint failed'); }, + writePack: () => assert.fail('effect cannot precede accepted progress'), + }), /checkpoint failed/); +}); + +test('guest rejects forged digest, traversal, duplicates, invalid bytes and decompression overflow before writing parts', async t => { + const f = await fixture(t); + const member = { name: 'part-0-0', size: 1, digest: hash('a'), data: Buffer.from('a').toString('base64') }; + const payload = parts => Buffer.from(JSON.stringify({ protocol: 'devbridge/source-part-pack-v1', parts })); + const cases = [ + { bytes: gzipSync(payload([member])), expected: '0'.repeat(64) }, + { bytes: gzipSync(payload([{ ...member, name: '../escaped' }])) }, + { bytes: gzipSync(payload([member, member])) }, + { bytes: gzipSync(payload([{ ...member, data: 'Yg==' }])) }, + { bytes: gzipSync(Buffer.alloc(4 * 1024 * 1024 + 1, 'a')) }, + ]; + for (const item of cases) { + await writeFile(f.pack, item.bytes); + const result = await run(f.work, [f.pack, item.expected ?? hash(item.bytes)], packAgent); + assert.notEqual(result.code, 0); + assert.deepEqual(await readdir(f.input), ['parts.gz']); + } +}); + +test('partial guest staging can resume the same pack without changing repository state', async t => { + const f = await fixture(t); + const parts = ['alpha', 'beta'].map((value, index) => ({ name: `part-${index}-0`, size: value.length, digest: hash(value), data: Buffer.from(value).toString('base64') })); + const bytes = gzipSync(Buffer.from(JSON.stringify({ protocol: 'devbridge/source-part-pack-v1', parts }))); + const identity = hash(bytes); + await writeFile(f.pack, bytes); + await writeFile(path.join(f.work, 'retained.txt'), 'unchanged repository'); + // An incompatible second destination interrupts staging after the first + // atomic part write. The original pack remains available for reconciliation. + await mkdir(path.join(f.input, 'part-1-0')); + const interrupted = await run(f.work, [f.pack, identity], packAgent); + assert.notEqual(interrupted.code, 0); + assert.equal(await readFile(path.join(f.input, 'part-0-0'), 'utf8'), 'alpha'); + assert.deepEqual(await readFile(f.pack), bytes); + assert.equal(await readFile(path.join(f.work, 'retained.txt'), 'utf8'), 'unchanged repository'); + await rm(path.join(f.input, 'part-1-0'), { recursive: true }); + const resumed = await run(f.work, [f.pack, identity], packAgent); + assert.equal(resumed.code, 0, resumed.err); + assert.deepEqual(JSON.parse(resumed.out), { ready: true, digest: identity, parts: 2 }); + assert.equal(await readFile(path.join(f.input, 'part-1-0'), 'utf8'), 'beta'); + assert.deepEqual(await readdir(f.work), ['retained.txt']); +}); + +test('changed snapshots transfer only missing or corrupt parts and still apply the full exact manifest', async t => { + const f = await fixture(t); + await writeFile(path.join(f.source, 'a.txt'), 'retained source'); + await writeFile(path.join(f.source, 'b.txt'), 'original source'); + const snapshot = () => snapshotFileTree({ root: f.source, listPaths: async () => ['a.txt', 'b.txt'] }); + const manifest = path.join(f.input, 'manifest.json'); + const state = path.join(f.root, 'state.json'); + let transferred = []; + const synchronize = async tree => { + const bytes = tree.manifestBytes(); + await writeFile(manifest, bytes); + const observed = await run(f.work, ['needed', manifest, hash(bytes)], packAgent); + assert.equal(observed.code, 0, observed.err); + const needed = JSON.parse(observed.out); + transferred = []; + await transferRepositorySource({ snapshot: tree, needed, + writePack: async bytes => { + transferred.push(...JSON.parse(gunzipSync(bytes).toString()).parts.map(part => part.name)); + await writeFile(f.pack, bytes); + }, + unpack: async digest => { + const result = await run(f.work, [f.pack, digest], packAgent); + assert.equal(result.code, 0, result.err); + return JSON.parse(result.out); + }, + writePart: () => assert.fail('unexpected large part'), + }); + const applied = await run(f.work, ['apply', manifest, state]); + assert.equal(applied.code, 0, applied.err); + assert.equal(JSON.parse(applied.out).digest, tree.manifest.digest); + return needed; + }; + await synchronize(await snapshot()); + assert.equal(transferred.length, 2); + await writeFile(path.join(f.source, 'b.txt'), 'changed source'); + const changed = await snapshot(); + await synchronize(changed); + assert.deepEqual(transferred, [changed.manifest.entries[1].parts[0].name]); + assert.equal(await readFile(path.join(f.work, 'a.txt'), 'utf8'), 'retained source'); + assert.equal(await readFile(path.join(f.work, 'b.txt'), 'utf8'), 'changed source'); + await synchronize(changed); + assert.deepEqual(transferred, []); + + const first = changed.manifest.entries[0].parts[0]; + await writeFile(path.join(f.input, first.name), 'corrupt source!'); + await synchronize(changed); + assert.deepEqual(transferred, [first.name]); + await rm(path.join(f.input, first.name)); + await synchronize(changed); + assert.deepEqual(transferred, [first.name]); + + // The observation cannot authorize stale/corrupt bytes at application time. + await writeFile(path.join(f.input, first.name), 'corrupt source!'); + const tampered = await run(f.work, ['apply', manifest, state]); + assert.notEqual(tampered.code, 0); + assert.match(tampered.err, /digest|size|length/); +}); + +test('part selection is exact-manifest-bound and cannot introduce or duplicate host reads', async t => { + const f = await fixture(t); + await writeFile(path.join(f.source, 'a'), 'a'); + const snapshot = await snapshotFileTree({ root: f.source, listPaths: async () => ['a'] }); + const good = { protocol: 'devbridge/source-parts-needed-v1', manifestDigest: hash(snapshot.manifestBytes()), needed: [] }; + const ports = { snapshot, writePack: () => assert.fail('invalid selection cannot transfer'), writePart: () => assert.fail('invalid selection cannot read') }; + for (const needed of [ + { ...good, manifestDigest: '0'.repeat(64) }, + { ...good, needed: ['../escaped'] }, + { ...good, needed: ['part-0-0', 'part-0-0'] }, + { ...good, protocol: 'unknown' }, + ]) await assert.rejects(transferRepositorySource({ ...ports, needed }), /selection does not match/); + await transferRepositorySource({ ...ports, needed: good }); + const manifest = path.join(f.input, 'manifest.json'); + await writeFile(manifest, snapshot.manifestBytes()); + const wrong = await run(f.work, ['needed', manifest, '0'.repeat(64)], packAgent); + assert.notEqual(wrong.code, 0); + assert.match(wrong.err, /manifest bytes changed/); +}); diff --git a/test/resource-agent.test.js b/test/resource-agent.test.js new file mode 100644 index 000000000..68b9b61af --- /dev/null +++ b/test/resource-agent.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import os from 'node:os'; +import path from 'node:path'; +const exec = promisify(execFile); +const agent = fileURLToPath(new URL('../src/guest/resource-agent.mjs', import.meta.url)); + +test('scratch layout reuses existing legacy work and selects compact roots only for new work', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-scratch-layout-')); + t.after(() => rm(root, { recursive: true, force: true })); + const legacy = path.join(root, 'legacy'), compact = path.join(root, 'compact'); + const choose = async () => JSON.parse((await exec(process.execPath, [agent, 'select-directory', legacy, compact], { windowsHide: true })).stdout); + assert.deepEqual(await choose(), { selected: 'compact' }); + await mkdir(legacy); + assert.deepEqual(await choose(), { selected: 'legacy' }); + await mkdir(compact); + await assert.rejects(choose(), /both resource layouts are present/); + await rm(legacy, { recursive: true }); + assert.deepEqual(await choose(), { selected: 'compact' }); + await symlink(compact, legacy, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(choose(), /not a real directory/); +}); diff --git a/test/serial-reconciliation.test.js b/test/serial-reconciliation.test.js index cd0549a88..6769b5216 100644 --- a/test/serial-reconciliation.test.js +++ b/test/serial-reconciliation.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { reconcileSerialSelection } from '../src/setup/serial-reconciliation.js'; -test('serial reconciliation returns after one changed item and resumes in fixed input order', async () => { +test('serial reconciliation continues after ready changes in fixed input order', async () => { const calls = []; const result = await reconcileSerialSelection({ items: ['profile-a', 'profile-b'], @@ -14,9 +14,9 @@ test('serial reconciliation returns after one changed item and resumes in fixed : { ready: true, changed: false, blocker: null }; }, }); - assert.deepEqual(calls, ['profile-a']); + assert.deepEqual(calls, ['profile-a', 'profile-b']); assert.deepEqual(result, { - ready: false, changed: true, state: 'pending', item: 'profile-a', completedCount: 1, totalCount: 2, blocker: null, + ready: true, changed: true, state: 'ready', item: 'profile-a', completedCount: 2, totalCount: 2, blocker: null, }); const resumed = await reconcileSerialSelection({ diff --git a/test/setup-environment-activation.test.js b/test/setup-environment-activation.test.js index f42a91db1..41ca6dbd4 100644 --- a/test/setup-environment-activation.test.js +++ b/test/setup-environment-activation.test.js @@ -109,14 +109,31 @@ test('setup activation fails closed on absent or ambiguous profile inventory', a } }); -test('setup activation does not broaden a foreign or destructive active transition', async () => { +test('setup hands an existing resumable rebuild to its lifecycle owner without new destructive authority', async () => { const selected = client({ before: status({ health: 'degraded', cause: 'system-storage-missing', action: 'resume', active: true, operation: 'rebuild', resumable: true }), + after: status(), }); const result = await reconcileSetupEnvironmentActivation({ client: selected.value, profile: PROFILE }); - assert.equal(result.ready, false); - assert.match(result.blocker, /non-create lifecycle transition/u); - assert.deepEqual(selected.calls, [['list']]); + assert.equal(result.ready, true); + assert.deepEqual(selected.calls, [['list'], ['resume', IDENTITY], ['status', IDENTITY]]); +}); + +test('setup cannot resume an unsupported or nonresumable operation', async () => { + for (const operation of ['foreign', 'rebuild']) { + const selected = client({ before: status({ health: 'degraded', action: 'resume', active: true, operation, resumable: false }) }); + const result = await reconcileSetupEnvironmentActivation({ client: selected.value, profile: PROFILE }); + assert.equal(result.ready, false); + assert.deepEqual(selected.calls, [['list']]); + } +}); + +test('declaration drift after activation cannot be reported ready', async () => { + const selected = client({ + before: { ...status({ health: 'absent', cause: 'materialization-not-created', action: 'create' }), declarationDigest: 'a'.repeat(64), declarationRevision: 1 }, + after: { ...status(), declarationDigest: 'b'.repeat(64), declarationRevision: 2 }, + }); + await assert.rejects(reconcileSetupEnvironmentActivation({ client: selected.value, profile: PROFILE }), /declaration changed/u); }); test('setup activation refuses an ordinary degraded state instead of selecting a repair', async () => { diff --git a/test/setup.test.js b/test/setup.test.js index 77d648d49..aa133f302 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -654,7 +654,7 @@ test('widened activation-policy status fails closed before media or protected ef assert.equal(JSON.stringify(result).includes('must-not-project'), false); }); -test('multi-profile activation advances one changed environment and resumes in accepted order', async () => { +test('multi-profile activation completes accepted ready changes in one invocation', async () => { const profileSelection = { protocol: 'devbridge/setup-profile-selection-status-v1', state: 'accepted', revision: 3, changed: false, profiles: ['linux-development', 'windows-development'], pendingProfiles: null, source: 'accepted', @@ -675,18 +675,18 @@ test('multi-profile activation advances one changed environment and resumes in a const pending = await runDevBridgeSetup({ home: path.join(os.tmpdir(), 'db-setup-activation-first'), }, first.deps); - assert.equal(pending.blocked, true); - assert.match(pending.blocker, /Additional accepted environment profile activation remains/u); + assert.equal(pending.blocked, false); + assert.equal(pending.blocker, null); assert.deepEqual(pending.environment, { - ready: false, + ready: true, changed: true, - state: 'pending', - profile: 'linux-development', - environmentCount: 1, + state: 'ready', + profile: 'windows-development', + environmentCount: 2, profileCount: 2, }); - assert.deepEqual(first.calls.environmentActivationProfiles, ['linux-development']); - assert.equal(first.calls.operationalConfiguration, 0); + assert.deepEqual(first.calls.environmentActivationProfiles, ['linux-development', 'windows-development']); + assert.equal(first.calls.operationalConfiguration, 1); const second = dependencies({ ...imageState, @@ -712,7 +712,7 @@ test('multi-profile activation advances one changed environment and resumes in a assert.equal(second.calls.operationalConfiguration, 1); }); -test('multi-profile activation never skips a blocked earlier environment', async () => { +test('a blocked profile is reported while an independent ready profile becomes operational', async () => { const fixture = dependencies({ profileSelection: { protocol: 'devbridge/setup-profile-selection-status-v1', state: 'accepted', revision: 3, changed: false, @@ -732,8 +732,10 @@ test('multi-profile activation never skips a blocked earlier environment', async assert.equal(result.blocked, true); assert.match(result.blocker, /requires review/u); - assert.deepEqual(fixture.calls.environmentActivationProfiles, ['linux-development']); - assert.equal(fixture.calls.operationalConfiguration, 0); + assert.deepEqual(fixture.calls.environmentActivationProfiles, ['linux-development', 'windows-development']); + assert.equal(fixture.calls.operationalConfiguration, 1); + assert.equal(result.environment.environmentCount, 1); + assert.equal(result.operational.executionEnabled, true); }); test('setup rejects Windows media actions outside the selected profile before its adapter runs', async () => { diff --git a/test/ssh-access-probe.test.js b/test/ssh-access-probe.test.js index 5a19ac0dc..966cade2c 100644 --- a/test/ssh-access-probe.test.js +++ b/test/ssh-access-probe.test.js @@ -24,6 +24,11 @@ test('SSH access probe enforces strict pinned noninteractive options', async () assert.ok(supplied.arguments.includes('BatchMode=yes')); assert.ok(supplied.arguments.includes('PasswordAuthentication=no')); assert.ok(supplied.arguments.includes('KbdInteractiveAuthentication=no')); + const controller = new AbortController(); + await probe.inspect(await access(root), { timeoutMs: 1200, signal: controller.signal }); + assert.equal(supplied.timeoutMs, 1200); + assert.equal(supplied.signal, controller.signal); + assert.ok(supplied.arguments.includes('ConnectTimeout=1')); } finally { await rm(root, { recursive: true, force: true }); } }); diff --git a/test/windows-elevated-setup-environment-activation.test.js b/test/windows-elevated-setup-environment-activation.test.js index f7a26814e..f09fb64d2 100644 --- a/test/windows-elevated-setup-environment-activation.test.js +++ b/test/windows-elevated-setup-environment-activation.test.js @@ -22,7 +22,7 @@ function record(profiles = ['linux-development']) { test('elevated activation stays a small orchestration LEGO over accepted configuration and lifecycle ports', async () => { const source = await readFile(new URL('../src/app/windows-elevated-setup-environment-activation.js', import.meta.url), 'utf8'); assert.doesNotMatch(source, /node:(?:fs|child_process)|providers\//iu); - assert.doesNotMatch(source, /environment-lifecycle-authority-transport|createConfiguredLifecycleAuthorityClient/u); + assert.doesNotMatch(source, /environment-operator-runtime|createLocalEnvironmentOperator/u); assert.doesNotMatch(source, /\b(?:construct|delete|rebuild|replace|retire|acl|service)\b/iu); }); @@ -47,7 +47,7 @@ test('elevated activation derives every operation from accepted profiles and reu async reconcile() { calls.push(['configure']); return { ready: true, changed: false }; }, }); }, - operatorFactory: async (request) => { + clientFactory: async (request) => { calls.push(['operator', request]); return client; }, @@ -69,9 +69,9 @@ test('elevated activation derives every operation from accepted profiles and reu ['configuration', { stateDirectory: STATE, platform: 'win32' }], ['configure'], ['record', { stateDirectory: STATE }], - ['operator', { stateDirectory: STATE, authorityDirectory: PLAN.authorityDirectory, platform: 'win32', invoke: INVOKE }], - ['activate', { client, profile: 'linux-development' }], - ['activate', { client, profile: 'windows-development' }], + ['operator', { stateDirectory: STATE, platform: 'win32' }], + ['activate', { client, profile: 'linux-development', expectedDeclaration: { profile: 'linux-development' } }], + ['activate', { client, profile: 'windows-development', expectedDeclaration: { profile: 'windows-development' } }], ]); }); @@ -83,7 +83,7 @@ test('elevated activation fails closed without an accepted non-empty configurati planFactory: () => PLAN, recordReader: async () => selected, configurationFactory: () => ({ reconcile: async () => ({ ready: true, changed: false }) }), - operatorFactory: () => { operatorCreated = true; }, + clientFactory: () => { operatorCreated = true; }, }); assert.equal(result.ready, false); assert.equal(result.changed, false); @@ -100,7 +100,7 @@ test('elevated activation stops at the first non-ready accepted profile', async planFactory: () => PLAN, recordReader: async () => record(['linux-development', 'windows-development']), configurationFactory: () => ({ reconcile: async () => ({ ready: true, changed: false }) }), - operatorFactory: () => Object.freeze({}), + clientFactory: () => Object.freeze({}), activationReconciler: async ({ profile }) => { profiles.push(profile); return profile === 'linux-development' @@ -123,7 +123,7 @@ test('elevated activation reconciles accepted configuration before lifecycle mut planFactory: () => PLAN, configurationFactory: () => ({ reconcile: async () => ({ ready: false, changed: true, blocker: 'configuration blocked' }) }), recordReader: async () => { recordRead = true; return record(); }, - operatorFactory: () => { operatorCreated = true; }, + clientFactory: () => { operatorCreated = true; }, }); assert.equal(result.ready, false); assert.equal(result.changed, true); @@ -138,7 +138,7 @@ test('elevated activation refuses a non-elevated host before protected configura const result = await reconcileWindowsElevatedSetupEnvironmentActivation({ stateDirectory: STATE, platform: 'win32', invoke: INVOKE }, { hostInspector: async () => ({ ...HOST, elevated: false }), configurationFactory: () => { configured = true; }, - operatorFactory: () => { operatorCreated = true; }, + clientFactory: () => { operatorCreated = true; }, }); assert.equal(result.ready, false); assert.match(result.blocker, /bounded elevated setup child/u); diff --git a/test/windows-environment-access.test.js b/test/windows-environment-access.test.js index 313527daa..7b1385a2a 100644 --- a/test/windows-environment-access.test.js +++ b/test/windows-environment-access.test.js @@ -35,7 +35,7 @@ test('Windows environment access composes replaceable local ports under protecte assert.equal(captured.material.user, 'devbridge'); assert.equal(captured.seed.directory, path.join(root, 'access', 'windows', 'transient')); assert.equal(captured.seed.user, 'devbridge'); - assert.deepEqual(captured.delivery, { identity, invoke: captured.material.invoke }); + assert.deepEqual(captured.delivery, { identity, invoke: captured.material.invoke, family: 'windows' }); assert.deepEqual(captured.probe, { identity, invoke: captured.material.invoke }); assert.deepEqual(captured.preparation, { material, seed, delivery, probe }); }); diff --git a/test/windows-file-lease.test.js b/test/windows-file-lease.test.js new file mode 100644 index 000000000..488324ed2 --- /dev/null +++ b/test/windows-file-lease.test.js @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createWindowsFileLease } from '../src/runtime/windows-file-lease.js'; + +test('Windows lease uses the compiled host and releases on holder and requester death', { skip: process.platform !== 'win32', timeout: 60_000 }, async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-windows-lease-')); + const children = new Set(); + t.after(async () => { + for (const child of children) if (child.exitCode == null && child.signalCode == null) child.kill(); + await Promise.all([...children].filter((child) => child.exitCode == null && child.signalCode == null).map((child) => once(child, 'close'))); + await rm(root, { recursive: true, force: true }); + }); + const holderExecutable = path.join(root, 'host.exe'); + const source = fileURLToPath(new URL('../src/setup/windows-lifecycle-authority-host.cs', import.meta.url)); + const script = `Add-Type -LiteralPath $env:DB_LEASE_SOURCE -OutputAssembly $env:DB_LEASE_OUTPUT -OutputType ConsoleApplication -ReferencedAssemblies 'System.ServiceProcess.dll' +Add-Type -TypeDefinition 'using System.Text; using System.Runtime.InteropServices; public static class LeaseShortPath { [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetShortPathName(string path, StringBuilder output, uint length); }' +$buffer = New-Object Text.StringBuilder 4096 +if ([LeaseShortPath]::GetShortPathName([IO.Path]::GetDirectoryName($env:DB_LEASE_OUTPUT), $buffer, 4096) -eq 0) { throw 'short path lookup failed' } +[Console]::Write($buffer.ToString())`; + const compiled = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + windowsHide: true, encoding: 'utf8', timeout: 30_000, + env: { ...process.env, DB_LEASE_SOURCE: source, DB_LEASE_OUTPUT: holderExecutable }, + }); + assert.equal(compiled.status, 0, compiled.stderr); + // CI commonly supplies an 8.3 TEMP path. Exercise that spelling locally too. + const subjectPath = path.join(compiled.stdout.trim(), 'mutation.lease'); + let holder; + const tracked = (executable, args, options) => { + assert.equal(options.shell, false); + assert.equal(options.windowsHide, true); + assert.equal(options.env.GITHUB_TOKEN, undefined); + const child = spawn(executable, args, options); + children.add(child); + holder = child; + return child; + }; + const factory = () => createWindowsFileLease({ subjectPath, holderExecutable }, { spawnProcess: tracked }); + const first = await factory().acquire({ mode: 'exclusive' }); + first.assertHeld(); + const firstHolder = holder; + const competitor = createWindowsFileLease({ subjectPath, holderExecutable }, { + spawnProcess: tracked, + timingPolicy: { sharedAcquireMs: 200, exclusiveAcquireMs: 200, releaseMs: 2000, killMs: 1000 }, + }); + await assert.rejects(competitor.acquire({ mode: 'exclusive' }), /did not complete in time/u); + first.assertHeld(); + const holderClosed = once(firstHolder, 'close'); + firstHolder.kill(); + await holderClosed; + assert.equal(first.signal.aborted, true); + assert.throws(() => first.assertHeld(), /ended unexpectedly/u); + await assert.rejects(first.release(), /ended unexpectedly/u); + const replacement = await factory().acquire({ mode: 'exclusive' }); + replacement.assertHeld(); + await replacement.release(); + + const requesterFile = path.join(root, 'requester.mjs'); + await writeFile(requesterFile, `import { createWindowsFileLease } from ${JSON.stringify(new URL('../src/runtime/windows-file-lease.js', import.meta.url).href)}; +const lease = await createWindowsFileLease(${JSON.stringify({ subjectPath, holderExecutable })}).acquire({ mode: 'exclusive' }); +process.stdout.write('HELD\\n'); +process.stdin.resume(); +process.stdin.once('end', () => lease.release()); +`); + const requester = spawn(process.execPath, [requesterFile], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false }); + children.add(requester); + await new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => reject(new Error('requester did not acquire lease')), 10_000); + requester.stdout.on('data', (chunk) => { + output += chunk; + if (output.includes('HELD\n')) { clearTimeout(timer); resolve(); } + }); + requester.once('exit', (code) => { clearTimeout(timer); reject(new Error(`requester exited ${code}`)); }); + }); + const requesterClosed = once(requester, 'close'); + requester.kill(); + await requesterClosed; + const recovered = await factory().acquire({ mode: 'exclusive' }); + recovered.assertHeld(); + await recovered.release(); +}); diff --git a/test/windows-guest-dns.test.js b/test/windows-guest-dns.test.js new file mode 100644 index 000000000..ab66adfbc --- /dev/null +++ b/test/windows-guest-dns.test.js @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { guestDnsServers, observeWindowsGuestDns } from '../src/runtime/providers/windows-guest-dns.js'; + +test('guest DNS excludes host-local and non-unicast addresses and keeps configured order', () => { + assert.deepEqual(guestDnsServers(['127.0.0.1', '127.1.2.3', '0.0.0.0', '169.254.1.1', '224.0.0.1', '255.255.255.255', '::1', 'bad', '192.168.1.1', '10.0.0.53', '192.168.1.1']), ['192.168.1.1', '10.0.0.53']); + assert.throws(() => guestDnsServers('8.8.8.8'), /observation/u); + assert.throws(() => guestDnsServers(Array(65).fill('8.8.8.8')), /observation/u); +}); + +test('Windows guest DNS inherits the first usable active route and never invents a resolver', async () => { + const invoke = async (request) => { + assert.equal(request.executable, 'powershell.exe'); + assert.equal(request.input, null); + assert.ok(request.timeoutMs <= 15_000); + return { exitCode: 0, stdout: JSON.stringify({ routes: [ + { interfaceIndex: 1, servers: ['127.0.0.1'] }, + { interfaceIndex: 2, servers: ['192.168.1.1'] }, + { interfaceIndex: 3, servers: ['8.8.8.8'] }, + ] }) }; + }; + assert.deepEqual(await observeWindowsGuestDns({ invoke }), ['192.168.1.1']); + for (const routes of [[], [{ interfaceIndex: 1, servers: ['127.0.0.1'] }]]) { + await assert.rejects(observeWindowsGuestDns({ invoke: async () => ({ exitCode: 0, stdout: JSON.stringify({ routes }) }) }), /no guest-reachable DNS/u); + } + for (const result of [{ exitCode: 1, stderr: 'native failed' }, { exitCode: 0, timedOut: true }, { exitCode: 0, outputTruncated: true }]) { + await assert.rejects(observeWindowsGuestDns({ invoke: async () => result }), /observation failed/u); + } +}); diff --git a/test/windows-lifecycle-authority-host.test.js b/test/windows-lifecycle-authority-host.test.js index f701bfa5d..716ddbf82 100644 --- a/test/windows-lifecycle-authority-host.test.js +++ b/test/windows-lifecycle-authority-host.test.js @@ -142,9 +142,7 @@ test('Windows protected activity workers are bounded by client lifetime without assert.match(source, /Task clientMonitor = clientPipe\.ReadAsync\(clientProbe, 0, clientProbe\.Length\);/u); assert.match(source, /Task\.WaitAny\(new Task\[\] \{ read, clientMonitor \}, remaining\)/u); assert.match(source, /CancelPendingPipeRead\(clientPipe, clientMonitor\);/u); - assert.match(source, /if \(String\.Equals\(access, "activity", StringComparison\.Ordinal\)\)\s*return ReadActivityWorkerResponse\(worker, clientPipe, maxResponseBytes\);/su); assert.match(source, /if \(!monitorTransferred\)\s*\{\s*CancelPendingPipeRead\(clientPipe, clientMonitor\);/su); - assert.match(source, /catch \(IOException\)\s*\{\s*continue;\s*\}/su); }); test('Windows lifecycle worker cannot inherit common operator credential channels', async () => { @@ -248,14 +246,24 @@ test('compiled Windows host serves configuration through its distinct five-endpo const node = path.join(plan.protectedRoot, 'node.exe'); const worker = path.join(plan.protectedRoot, 'worker.mjs'); await writeFile(worker, [ + "import { appendFileSync } from 'node:fs';", + "import readline from 'node:readline';", + "const access = process.argv[process.argv.indexOf('--access') + 1];", + "if (access === 'activity') {", + " appendFileSync(new URL('./activity-pids.txt', import.meta.url), String(process.pid) + '\\n');", + " for await (const line of readline.createInterface({ input: process.stdin })) {", + " const request = JSON.parse(line);", + " if (request.operation === 'inspect') { await new Promise(() => { setInterval(() => {}, 1000); }); }", + " process.stdout.write(JSON.stringify({ protocol: 'devbridge/environment-activity-authority-result-v1', requestId: request.requestId, ok: true, value: [] }) + '\\n');", + " }", + " process.exit(0);", + "}", "let input = '';", "process.stdin.setEncoding('utf8');", "for await (const chunk of process.stdin) input += chunk;", "const request = JSON.parse(input.trim());", - "const access = process.argv[process.argv.indexOf('--access') + 1];", "if (access === 'configuration') process.stdout.write(JSON.stringify({ protocol: 'devbridge/environment-configuration-authority-result-v1', requestId: request.requestId, ok: true, value: { ready: true } }) + '\\n');", "else if (access === 'read') process.stdout.write(JSON.stringify({ protocol: 'devbridge/environment-lifecycle-authority-result-v1', requestId: request.requestId, ok: true, value: request.operation === 'fixture-large' ? { payload: 'x'.repeat(8000) } : [] }) + '\\n');", - "else if (access === 'activity') process.stdout.write(JSON.stringify({ protocol: 'devbridge/environment-activity-authority-result-v1', requestId: request.requestId, ok: true, value: [] }) + '\\n');", "else process.exit(2);", ].join('\n')); await writeFile(harnessSource, String.raw`using System; @@ -398,14 +406,34 @@ internal static class IntegrationHarness } assert.deepEqual(result, []); } + const workerPids = async () => (await readFile(path.join(plan.protectedRoot, 'activity-pids.txt'), 'utf8')).trim().split('\n'); + assert.equal((await workerPids()).length, 1, '100 sequential activity requests must reuse one worker'); + const activityClient = createConfiguredEnvironmentActivityClient({ stateDirectory, platform: 'win32', connectTimeoutMs: 3000 }); + const cancellation = new AbortController(); + const stalled = activityClient.inspect({ signal: cancellation.signal }); + setTimeout(() => cancellation.abort(), 100); + await assert.rejects(stalled, /interrupted|unavailable/); + assert.deepEqual(await activityClient.list(), []); + assert.equal((await workerPids()).length, 2, 'cancelled worker must be replaced before another request'); + assert.deepEqual(await createConfiguredEnvironmentConfigurationClient({ stateDirectory, platform: 'win32' }).inspect(), { ready: true }); + assert.deepEqual(await activityClient.list(), []); + assert.equal((await workerPids()).length, 2, 'read-only configuration inspection must preserve healthy activity resources'); child.stdin.end('\n'); assert.equal(await waitForExit(child), 0); child = null; } finally { if (child && child.exitCode == null) { child.stdin.end('\n'); - try { await waitForExit(child, 10_000); } catch { child.kill(); } + try { await waitForExit(child, 10_000); } catch { + if (child.exitCode == null && child.signalCode == null) { + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill(); + await exited; + } + } } - await rm(temp, { recursive: true, force: true }); + // Closing the host's Windows job terminates its children asynchronously. + // Wait for those owned executable handles to close before removing the fixture. + await rm(temp, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); } }); diff --git a/test/windows-lifecycle-authority-worker.test.js b/test/windows-lifecycle-authority-worker.test.js index 83a02487d..2d2fb4370 100644 --- a/test/windows-lifecycle-authority-worker.test.js +++ b/test/windows-lifecycle-authority-worker.test.js @@ -1,11 +1,49 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { Readable } from 'node:stream'; +import { Readable, PassThrough } from 'node:stream'; import { handleWindowsLifecycleAuthorityWorkerRequest, parseWindowsLifecycleAuthorityWorkerArguments, runWindowsLifecycleAuthorityWorker, + runWindowsActivityAuthorityStream, } from '../src/entry/windows-lifecycle-authority-worker.mjs'; + +test('activity stream composes once, preserves request identities and closes its resources on EOF', async () => { + const input = new PassThrough(); + let factoryCalls = 0, closes = 0; + const responses = []; + let delivered; + const task = runWindowsActivityAuthorityStream({ input, + output: { write(value) { responses.push(JSON.parse(value)); delivered?.(); } }, + activityFactory: async () => { factoryCalls++; return { + inspect: async () => ({ ready: true, identity: 'a'.repeat(32), reason: null }), + list: async () => [], observe: async () => {}, prepare: async () => {}, exchange: async () => {}, + close() { closes++; }, + }; }, + }); + for (let i = 1; i <= 2; i++) { + const response = new Promise(resolve => { delivered = resolve; }); + input.write(JSON.stringify({ protocol: 'devbridge/environment-activity-authority-request-v1', + requestId: `00000000-0000-4000-8000-00000000000${i}`, operation: 'inspect', payload: {} }) + '\n'); + await response; + } + input.end(); await task; + assert.equal(factoryCalls, 1); assert.equal(closes, 1); + assert.equal(responses.length, 2); assert.notEqual(responses[0].requestId, responses[1].requestId); + assert.equal(responses.every(value => value.ok), true); +}); + +test('activity stream rejects oversized and unsolicited framing before composing authority', async () => { + for (const wire of ['{}\n{}\n', 'x'.repeat(70 * 1024), '{"truncated":']) { + let calls = 0; + await assert.rejects(runWindowsActivityAuthorityStream({ input: Readable.from([wire]), output: { write() {} }, + activityFactory: async () => { calls++; }, + }), /framing|bound|interrupted/); + assert.equal(calls, 0); + } + const input = new PassThrough(); + await assert.rejects(runWindowsActivityAuthorityStream({ input, output: { write() {} }, activityFactory: async () => {}, idleMs: 10 }), /idle lifetime/); +}); import { ENVIRONMENT_LIFECYCLE_AUTHORITY_REQUEST_PROTOCOL, ENVIRONMENT_LIFECYCLE_AUTHORITY_RESULT_PROTOCOL, diff --git a/test/windows-protected-access-material.test.js b/test/windows-protected-access-material.test.js index aef296c9b..359c41945 100644 --- a/test/windows-protected-access-material.test.js +++ b/test/windows-protected-access-material.test.js @@ -58,6 +58,29 @@ test('Windows protected access material fails closed off Windows and on substitu } finally { await rm(root, { recursive: true, force: true }); } }); +test('unchanged protected records decrypt once; replacement, deletion and restart invalidate reuse', async t => { + const root = await mkdtemp(path.join(os.tmpdir(), 'db-win-access-reuse-')); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const options = { directory: root, invoke: fakeInvoke(calls), user: 'devbridge', platform: 'win32', entropy: () => FIXED_BYTES }; + const material = new WindowsProtectedAccessMaterial(options); + await material.ensure(IDENTITY); + const first = await material.resolve(IDENTITY); + assert.deepEqual(await material.resolve(IDENTITY), first); + assert.equal(calls.filter(call => Object.hasOwn(JSON.parse(call.input), 'protected')).length, 1); + const [file] = await readdir(root), location = path.join(root, file); + const record = JSON.parse(await readFile(location, 'utf8')); + await writeFile(location, JSON.stringify({ ...record, secretDigest: '0'.repeat(64) })); + await assert.rejects(material.resolve(IDENTITY), /integrity changed/); + await writeFile(location, JSON.stringify(record)); + assert.deepEqual(await material.resolve(IDENTITY), first); + assert.equal(calls.filter(call => Object.hasOwn(JSON.parse(call.input), 'protected')).length, 3); + await new WindowsProtectedAccessMaterial(options).resolve(IDENTITY); + assert.equal(calls.filter(call => Object.hasOwn(JSON.parse(call.input), 'protected')).length, 4); + await material.discard(IDENTITY); + await assert.rejects(material.resolve(IDENTITY), /unavailable/); +}); + test('Windows protected access material binds a persistent environment to its fixed non-admin user', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'db-win-access-environment-')); const target = `env-${'9'.repeat(32)}`;