From 065840e863cf3c7dd77f8a6b16444e9aa539dfee Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 11:22:26 -0700 Subject: [PATCH 01/20] Repair lifecycle recovery and Hyper-V first-access contracts --- docs/refactoring-plan.md | 82 ++++++++ .../environment-construction-preparation.js | 2 + src/app/environment-construction-runtime.js | 6 +- src/app/environment-foundation.js | 3 + src/app/environment-materialization.js | 11 +- src/app/environment-recreate.js | 11 +- src/app/environment-reset.js | 11 +- src/app/file-lease.js | 9 + src/app/setup-environment-activation.js | 10 +- src/app/setup.js | 10 +- ...s-elevated-setup-environment-activation.js | 23 +- src/app/windows-environment-access.js | 3 +- src/runtime/environment-construction.js | 5 +- src/runtime/environment-create.js | 3 +- src/runtime/environment-operation-subject.js | 25 +++ src/runtime/environment-rebuild.js | 2 + src/runtime/environment-recreate.js | 2 + src/runtime/environment-reset.js | 2 + src/runtime/lease-bound-invocation.js | 11 + src/runtime/legacy-file-guard.js | 36 ++++ src/runtime/linux-file-lease.js | 199 +----------------- src/runtime/persistent-environments.js | 17 +- .../persistent-environments/effect-channel.js | 23 +- src/runtime/persistent-environments/ledger.js | 72 +++++-- src/runtime/process-file-lease.js | 197 +++++++++++++++++ .../providers/hyperv-environment-bootstrap.js | 39 ++-- src/runtime/providers/hyperv-file-copy.js | 113 ++++++++++ .../providers/hyperv-guest-file-delivery.js | 43 +--- .../hyperv-persistent-environment.js | 18 +- .../libvirt-persistent-environment.js | 18 +- src/runtime/windows-file-lease.js | 17 ++ src/setup/guest-bootstrap-qualification.js | 136 ++++++++++++ ...lifecycle-authority-refresh-composition.js | 11 + src/setup/serial-reconciliation.js | 25 +-- src/setup/windows-lifecycle-authority-host.cs | 29 +++ .../windows-lifecycle-authority-service.js | 16 ++ test-support/mutation-lease.js | 16 ++ ...nvironment-materialization-rebuild.test.js | 24 +-- test/environment-operation-subject.test.js | 22 ++ test/environment-recreate-app.test.js | 7 +- test/environment-reset-app.test.js | 7 +- test/guest-bootstrap-qualification.test.js | 90 ++++++++ test/hyperv-environment-bootstrap.test.js | 44 +++- test/hyperv-file-copy.test.js | 74 +++++++ test/hyperv-guest-file-delivery.test.js | 16 +- test/lease-bound-invocation.test.js | 31 +++ test/legacy-file-guard.test.js | 59 ++++++ test/linux-file-lease.test.js | 2 +- test/persistent-environment-ledger.test.js | 41 +++- test/persistent-environments-rebuild.test.js | 33 ++- test/persistent-environments-recreate.test.js | 11 +- ...ersistent-environments-replacement.test.js | 9 +- test/persistent-environments.test.js | 37 ++-- test/serial-reconciliation.test.js | 6 +- test/setup-environment-activation.test.js | 25 ++- test/setup.test.js | 26 +-- ...vated-setup-environment-activation.test.js | 18 +- test/windows-environment-access.test.js | 2 +- test/windows-file-lease.test.js | 82 ++++++++ 59 files changed, 1453 insertions(+), 469 deletions(-) create mode 100644 docs/refactoring-plan.md create mode 100644 src/app/file-lease.js create mode 100644 src/runtime/environment-operation-subject.js create mode 100644 src/runtime/lease-bound-invocation.js create mode 100644 src/runtime/legacy-file-guard.js create mode 100644 src/runtime/process-file-lease.js create mode 100644 src/runtime/providers/hyperv-file-copy.js create mode 100644 src/runtime/windows-file-lease.js create mode 100644 src/setup/guest-bootstrap-qualification.js create mode 100644 test-support/mutation-lease.js create mode 100644 test/environment-operation-subject.test.js create mode 100644 test/guest-bootstrap-qualification.test.js create mode 100644 test/hyperv-file-copy.test.js create mode 100644 test/lease-bound-invocation.test.js create mode 100644 test/legacy-file-guard.test.js create mode 100644 test/windows-file-lease.test.js diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md new file mode 100644 index 000000000..e330cf87f --- /dev/null +++ b/docs/refactoring-plan.md @@ -0,0 +1,82 @@ +# 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 + +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. Linux `flock` process qualification and the Node 22.16.0 CI matrix +remain separately outstanding. 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. +Native Linux and Windows guest qualification is running against a fixed candidate; +neither route nor Hello World has been declared complete. + +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/src/app/environment-construction-preparation.js b/src/app/environment-construction-preparation.js index c00a8b2b4..780c79935 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') { diff --git a/src/app/environment-construction-runtime.js b/src/app/environment-construction-runtime.js index 8891e9e3b..8d81d2726 100644 --- a/src/app/environment-construction-runtime.js +++ b/src/app/environment-construction-runtime.js @@ -56,12 +56,12 @@ 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({ stateDirectory, 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-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-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/file-lease.js b/src/app/file-lease.js new file mode 100644 index 000000000..879994575 --- /dev/null +++ b/src/app/file-lease.js @@ -0,0 +1,9 @@ +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') } = {}) { + if (platform === 'linux') return createLinuxFileLease({ subjectPath }); + if (platform === 'win32') return createWindowsFileLease({ subjectPath, holderExecutable }); + return Object.freeze({ async acquire() { throw new Error('no local mutation lease is available on this host'); } }); +} 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/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-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/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..60497aa98 100644 --- a/src/runtime/persistent-environments.js +++ b/src/runtime/persistent-environments.js @@ -120,9 +120,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), @@ -143,16 +143,16 @@ export class PersistentEnvironments { } async list() { - return this.#ledger.run(async () => { + 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); }); } 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); }); } @@ -251,8 +251,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/process-file-lease.js b/src/runtime/process-file-lease.js new file mode 100644 index 000000000..fd36d8b8b --- /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')); + } 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/hyperv-environment-bootstrap.js b/src/runtime/providers/hyperv-environment-bootstrap.js index c52bdd4c1..4094c5e9a 100644 --- a/src/runtime/providers/hyperv-environment-bootstrap.js +++ b/src/runtime/providers/hyperv-environment-bootstrap.js @@ -2,6 +2,7 @@ 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'; const PROTOCOL = 'devbridge/hyperv-environment-bootstrap-state-v1'; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/u; @@ -113,35 +114,27 @@ $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; #guardFile; + #lease; + #held = null; #invoke; #locate; #connection; #dnsServers; + #now; + #wait; #tail = Promise.resolve(); - constructor({ directory, invoke, locate, connection, dnsServers = () => dns.getServers() }) { + constructor({ directory, invoke, locate, connection, dnsServers = () => dns.getServers(), 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 +142,8 @@ export class HyperVEnvironmentBootstrap { this.#locate = locate; this.#connection = connection; this.#dnsServers = dnsServers; + this.#now = now; + this.#wait = wait; } async #acquire() { @@ -323,19 +318,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-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..b1d923f1d 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) { 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 }); } } 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/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..648473b03 --- /dev/null +++ b/src/setup/guest-bootstrap-qualification.js @@ -0,0 +1,136 @@ +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); + if (image?.verified !== true || image?.usable !== true || image.entry?.generation !== declaration.image.generation) throw new Error('qualification requires the exact verified accepted image'); + record.imageDigest = image.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..d2e25d384 100644 --- a/src/setup/windows-lifecycle-authority-host.cs +++ b/src/setup/windows-lifecycle-authority-host.cs @@ -699,10 +699,39 @@ private WorkerResponse InvokeWorker(string access, byte[] request, int maxRespon internal static class Program { + private static int HoldFileLease(string target) + { + if (String.IsNullOrWhiteSpace(target) || target.IndexOf('\0') >= 0 || !Regex.IsMatch(target, @"^[A-Za-z]:\\") || !String.Equals(Path.GetFullPath(target), target, StringComparison.OrdinalIgnoreCase)) return 1; + 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/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/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-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/guest-bootstrap-qualification.test.js b/test/guest-bootstrap-qualification.test.js new file mode 100644 index 000000000..7f6cc9a42 --- /dev/null +++ b/test/guest-bootstrap-qualification.test.js @@ -0,0 +1,90 @@ +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 () => ({ verified: true, 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/hyperv-environment-bootstrap.test.js b/test/hyperv-environment-bootstrap.test.js index c74b21341..0954fc91e 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'); }; diff --git a/test/hyperv-file-copy.test.js b/test/hyperv-file-copy.test.js new file mode 100644 index 000000000..a9d38d812 --- /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, 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(), 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/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-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/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..dac46ebca 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.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 } from 'node:fs/promises'; @@ -54,7 +55,7 @@ test('stable identity excludes display topology and rejects foreign request prop 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 +70,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 +97,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 +117,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 +129,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 +147,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 +164,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 +173,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 +200,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 +215,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 +227,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 +243,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 +266,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/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/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..f8beb2fcb --- /dev/null +++ b/test/windows-file-lease.test.js @@ -0,0 +1,82 @@ +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'"; + 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); + const subjectPath = path.join(root, '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(); +}); From 9dd4d78bb37ed61431e3e88ce8954841ec02d2e8 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 11:28:22 -0700 Subject: [PATCH 02/20] Preserve protected boot intent and correct qualification image reads --- src/app/file-lease.js | 12 +++++++++--- src/runtime/persistent-environments.js | 10 +++++++--- src/setup/guest-bootstrap-qualification.js | 6 ++++-- test/guest-bootstrap-qualification.test.js | 3 ++- test/persistent-environments.test.js | 22 ++++++++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/app/file-lease.js b/src/app/file-lease.js index 879994575..b5eda103b 100644 --- a/src/app/file-lease.js +++ b/src/app/file-lease.js @@ -3,7 +3,13 @@ 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') } = {}) { - if (platform === 'linux') return createLinuxFileLease({ subjectPath }); - if (platform === 'win32') return createWindowsFileLease({ subjectPath, holderExecutable }); - return Object.freeze({ async acquire() { throw new Error('no local mutation lease is available on this host'); } }); + // 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/runtime/persistent-environments.js b/src/runtime/persistent-environments.js index 60497aa98..3662ab27c 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) { diff --git a/src/setup/guest-bootstrap-qualification.js b/src/setup/guest-bootstrap-qualification.js index 648473b03..a2ac18756 100644 --- a/src/setup/guest-bootstrap-qualification.js +++ b/src/setup/guest-bootstrap-qualification.js @@ -87,8 +87,10 @@ export async function qualifyGuestBootstrap({ if (record.cleanup === 'complete') return Object.freeze(record); try { const image = await foundation.verifyImage(declaration.image.identity); - if (image?.verified !== true || image?.usable !== true || image.entry?.generation !== declaration.image.generation) throw new Error('qualification requires the exact verified accepted image'); - record.imageDigest = image.entry.digest; + 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, diff --git a/test/guest-bootstrap-qualification.test.js b/test/guest-bootstrap-qualification.test.js index 7f6cc9a42..fe82ba4b1 100644 --- a/test/guest-bootstrap-qualification.test.js +++ b/test/guest-bootstrap-qualification.test.js @@ -29,7 +29,8 @@ async function fixture(t, family, incorrect = false) { const ports = { foundationFactory: async () => ({ listEnvironments: async () => entry ? [entry] : [], - verifyImage: async () => ({ verified: true, usable: true, entry: { generation: 'final-v1', digest: 'c'.repeat(64) } }), + 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; }, diff --git a/test/persistent-environments.test.js b/test/persistent-environments.test.js index dac46ebca..cca8f4484 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.test.js @@ -5,6 +5,7 @@ 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'; @@ -51,6 +52,27 @@ function request(sourceIdentity = SOURCE_A) { return { subject: 'immutable-subject-42', profile: 'guest-a', sourceIdentity, settings: { memoryBytes: 2147483648, processorCount: 2, firmware: 'efi' } }; } +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(); From 6b15626a74db125e5493a3a17bfdcf33931cc222 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 11:51:55 -0700 Subject: [PATCH 03/20] Accept Windows path aliases and expose readable native console evidence --- docs/refactoring-plan.md | 16 ++++-- src/runtime/process-file-lease.js | 2 +- .../console-evidence.js | 30 +++++------ .../console-format.js | 8 +++ .../management-channel.js | 17 ++++--- src/setup/windows-lifecycle-authority-host.cs | 6 ++- test/hyperv-console-evidence.test.js | 51 +++++++++++++++++++ test/hyperv-file-copy.test.js | 4 +- test/hyperv-image-construction.test.js | 2 +- test/windows-file-lease.test.js | 9 +++- 10 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 src/runtime/providers/hyperv-image-construction/console-format.js create mode 100644 test/hyperv-console-evidence.test.js diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index e330cf87f..c3d8ec5e6 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -65,14 +65,22 @@ 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. Linux `flock` process qualification and the Node 22.16.0 CI matrix -remain separately outstanding. These results do not establish native VM readiness. +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. -Native Linux and Windows guest qualification is running against a fixed candidate; -neither route nor Hello World has been declared complete. +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. This is diagnostic +evidence, not a successful bootstrap qualification. Windows first access and both +Hello World routes remain outstanding. Remaining recovery and separation work includes bootstrap allocation's legacy token, lifecycle-owned retirement authorization, consolidated capability status, diff --git a/src/runtime/process-file-lease.js b/src/runtime/process-file-lease.js index fd36d8b8b..82eb8afa5 100644 --- a/src/runtime/process-file-lease.js +++ b/src/runtime/process-file-lease.js @@ -174,7 +174,7 @@ function acquireProcess({ mode, command, readyLine, cancellation, start, policy 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')); + 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); 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/setup/windows-lifecycle-authority-host.cs b/src/setup/windows-lifecycle-authority-host.cs index d2e25d384..4d498a68b 100644 --- a/src/setup/windows-lifecycle-authority-host.cs +++ b/src/setup/windows-lifecycle-authority-host.cs @@ -701,7 +701,11 @@ internal static class Program { private static int HoldFileLease(string target) { - if (String.IsNullOrWhiteSpace(target) || target.IndexOf('\0') >= 0 || !Regex.IsMatch(target, @"^[A-Za-z]:\\") || !String.Equals(Path.GetFullPath(target), target, StringComparison.OrdinalIgnoreCase)) return 1; + 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(); 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-file-copy.test.js b/test/hyperv-file-copy.test.js index a9d38d812..bcfa37016 100644 --- a/test/hyperv-file-copy.test.js +++ b/test/hyperv-file-copy.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +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'; @@ -30,7 +30,7 @@ for (const family of ['linux', 'windows']) { 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(), source.toLowerCase()); + if (family === 'windows') assert.equal(native.source.toLowerCase(), (await realpath(source)).toLowerCase()); return success({ delivered: 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/windows-file-lease.test.js b/test/windows-file-lease.test.js index f8beb2fcb..488324ed2 100644 --- a/test/windows-file-lease.test.js +++ b/test/windows-file-lease.test.js @@ -18,13 +18,18 @@ test('Windows lease uses the compiled host and releases on holder and requester }); 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'"; + 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); - const subjectPath = path.join(root, 'mutation.lease'); + // 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); From 76ab41e1f8db5deaadff72b6b76bdf2b8c034043 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 11:57:45 -0700 Subject: [PATCH 04/20] Bound first-access waiting to the observed guest boot contract --- docs/refactoring-plan.md | 10 +++-- src/app/linux-access-preparation.js | 34 +++++++++----- .../providers/hyperv-guest-file-delivery.js | 4 +- src/runtime/ssh-access-probe.js | 8 ++-- test/linux-access-preparation.test.js | 45 ++++++++++++++++++- test/provider-local-nested-lego.test.js | 29 +++++++----- test/ssh-access-probe.test.js | 5 +++ 7 files changed, 106 insertions(+), 29 deletions(-) diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index c3d8ec5e6..28c0d1080 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -78,9 +78,13 @@ The first native Linux consumer received successful native copy results but fail 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. This is diagnostic -evidence, not a successful bootstrap qualification. Windows first access and both -Hello World routes remain outstanding. +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. Windows first +access and both Hello World routes remain outstanding. Remaining recovery and separation work includes bootstrap allocation's legacy token, lifecycle-owned retirement authorization, consolidated capability status, 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/runtime/providers/hyperv-guest-file-delivery.js b/src/runtime/providers/hyperv-guest-file-delivery.js index b1d923f1d..a9029009d 100644 --- a/src/runtime/providers/hyperv-guest-file-delivery.js +++ b/src/runtime/providers/hyperv-guest-file-delivery.js @@ -16,9 +16,9 @@ export class HyperVGuestFileDelivery { 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'); - return copyHyperVGuestFile({ invoke: this.#invoke, location: this.#location.environment(target), family: this.#family, source, destination }); + return copyHyperVGuestFile({ invoke: this.#invoke, location: this.#location.environment(target), family: this.#family, source, destination, timeoutMs, signal }); } } 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/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/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/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 }); } }); From 27755726436485841d6e79ad5920f13410e363bd Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 12:06:40 -0700 Subject: [PATCH 05/20] Derive guest DNS from the active Windows network route --- docs/refactoring-plan.md | 10 +++- .../providers/hyperv-environment-bootstrap.js | 16 +++---- src/runtime/providers/windows-guest-dns.js | 47 +++++++++++++++++++ test/hyperv-environment-bootstrap.test.js | 11 +++++ test/windows-guest-dns.test.js | 29 ++++++++++++ 5 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/runtime/providers/windows-guest-dns.js create mode 100644 test/windows-guest-dns.test.js diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index 28c0d1080..fac3cc3f4 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -83,8 +83,14 @@ 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. Windows first -access and both Hello World routes remain outstanding. +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. Remaining recovery and separation work includes bootstrap allocation's legacy token, lifecycle-owned retirement authorization, consolidated capability status, diff --git a/src/runtime/providers/hyperv-environment-bootstrap.js b/src/runtime/providers/hyperv-environment-bootstrap.js index 4094c5e9a..3c1db959e 100644 --- a/src/runtime/providers/hyperv-environment-bootstrap.js +++ b/src/runtime/providers/hyperv-environment-bootstrap.js @@ -1,8 +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; @@ -118,8 +118,6 @@ export class HyperVEnvironmentBootstrap { #directory; #stateFile; #guardFile; - #lease; - #held = null; #invoke; #locate; #connection; @@ -128,7 +126,7 @@ export class HyperVEnvironmentBootstrap { #wait; #tail = Promise.resolve(); - constructor({ directory, invoke, locate, connection, dnsServers = () => dns.getServers(), now = Date.now, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) { + 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'); @@ -226,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; } @@ -269,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) { @@ -308,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(); 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/test/hyperv-environment-bootstrap.test.js b/test/hyperv-environment-bootstrap.test.js index 0954fc91e..03d2b638b 100644 --- a/test/hyperv-environment-bootstrap.test.js +++ b/test/hyperv-environment-bootstrap.test.js @@ -213,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/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); + } +}); From 4aa0da1152d9a9f9350bb821bd2638b960254dab Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 12:17:15 -0700 Subject: [PATCH 06/20] Keep guest readiness state in its owning user cache --- docs/refactoring-plan.md | 9 +++++++++ src/app/environment-bootstrap.js | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index fac3cc3f4..cd06a3bd1 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -92,6 +92,15 @@ the host's configured router. Full local Windows regression before this DNS repa 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. diff --git a/src/app/environment-bootstrap.js b/src/app/environment-bootstrap.js index f156d88b0..eaa9e13f8 100644 --- a/src/app/environment-bootstrap.js +++ b/src/app/environment-bootstrap.js @@ -160,8 +160,12 @@ 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, From 93909addbb433c8292dad7f2f13f6903d97ea30c Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 12:31:03 -0700 Subject: [PATCH 07/20] Qualify status delivery recovery and bound Windows fixture cleanup --- docs/refactoring-plan.md | 21 +++++++++++++++++++ src/app/runtime.js | 3 ++- src/github/issue-status-reporter.js | 8 ++++++- src/runtime/fault-injector.js | 2 ++ test/issue-status-delivery.test.js | 14 +++++++++++++ test/windows-lifecycle-authority-host.test.js | 12 +++++++++-- 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index cd06a3bd1..4d9bac8cc 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -55,6 +55,27 @@ 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 + 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 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/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/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/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/windows-lifecycle-authority-host.test.js b/test/windows-lifecycle-authority-host.test.js index f701bfa5d..20b42a47c 100644 --- a/test/windows-lifecycle-authority-host.test.js +++ b/test/windows-lifecycle-authority-host.test.js @@ -404,8 +404,16 @@ internal static class IntegrationHarness } 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 }); } }); From a32e7993a8d4227173a0b9a11cdba151ba1ab82f Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 12:48:09 -0700 Subject: [PATCH 08/20] Bind workspace access to its owner and retain lifecycle diagnostics --- docs/refactoring-plan.md | 14 +++++ specs/DB-009-effects-recovery.md | 21 +++++++ src/app/environment-construction-runtime.js | 12 ++-- src/app/environment-lifecycle.js | 2 + src/app/environment-operator.js | 25 +++++++- src/cli.js | 3 +- .../environment-lifecycle-authority.js | 17 ++++-- .../environment-lifecycle-diagnostics.js | 49 ++++++++++++++++ .../environment-lifecycle-state-store.js | 1 + test/environment-construction-runtime.test.js | 57 +++++++++++++++++++ .../environment-lifecycle-diagnostics.test.js | 43 ++++++++++++++ test/environment-operator.test.js | 20 +++++++ 12 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 src/runtime/environment-lifecycle-diagnostics.js create mode 100644 test/environment-lifecycle-diagnostics.test.js diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index 4d9bac8cc..8812964e4 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -76,6 +76,20 @@ 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 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/src/app/environment-construction-runtime.js b/src/app/environment-construction-runtime.js index 8d81d2726..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 }); @@ -63,7 +67,7 @@ export async function createEnvironmentConstructionRuntime({ const recreateAvailable = typeof localFoundation.recreateEnvironment === 'function' && typeof localFoundation.retireSupersededEnvironment === 'function'; 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-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-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/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/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/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/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-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 }); From 25c3c485d91ecce956662347a09f1aea71f67a6d Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 13:20:02 -0700 Subject: [PATCH 09/20] Batch workspace source parts through the existing guest bridge --- docs/vm-stage6-repository-execution.md | 9 ++ src/app/repository-execution.js | 8 +- src/app/repository-execution/byte-channel.js | 8 +- .../repository-execution/source-transfer.js | 56 +++++++++ .../repository-execution/workspace-session.js | 8 +- src/guest/workspace-agent.mjs | 43 ++++++- test/repository-execution-nested-lego.test.js | 21 ++-- test/repository-source-transfer.test.js | 112 ++++++++++++++++++ 8 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 src/app/repository-execution/source-transfer.js create mode 100644 test/repository-source-transfer.test.js diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index c447be655..958e5b114 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -62,6 +62,15 @@ 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. + ## 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/src/app/repository-execution.js b/src/app/repository-execution.js index 1f583f347..beb2afe3e 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -25,6 +25,7 @@ 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'; const BRIDGE_OUTPUT_LIMIT = 3 * 1024 * 1024; const TRANSFER_LIMIT = 16 * 1024 * 1024; @@ -217,7 +218,12 @@ 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: (snapshot, options) => transferRepositorySource({ + snapshot, ...options, + writePack: (value, controls) => bytes.write(value, { class: 'input', path: 'source/parts.gz' }, controls), + unpack: (identity, controls) => runAgent('unpack-source', [{ class: 'input', path: 'source/parts.gz' }, identity], { timeoutMs: 60_000, ...controls }), + 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 }), }, diff --git a/src/app/repository-execution/byte-channel.js b/src/app/repository-execution/byte-channel.js index a11ff416f..039ecf22b 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 }) { + 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/source-transfer.js b/src/app/repository-execution/source-transfer.js new file mode 100644 index 000000000..bc6ea37f3 --- /dev/null +++ b/src/app/repository-execution/source-transfer.js @@ -0,0 +1,56 @@ +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, writePack, unpack, writePart, signal = null, onActivity = null }) { + const parts = snapshot.manifest.entries.filter(entry => entry.type === 'file').flatMap(entry => entry.parts); + let batch = [], size = 0, completed = 0; + const started = Date.now(); + const progress = () => onActivity?.({ activity: `source-transfer ${completed}/${parts.length} parts`, elapsedMs: Date.now() - started, at: new Date().toISOString() }); + 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?.({ + activity: `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; + 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; 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..368e6f26e 100644 --- a/src/app/repository-execution/workspace-session.js +++ b/src/app/repository-execution/workspace-session.js @@ -41,13 +41,7 @@ export class WorkspaceSession { await this.#sourcePort.install(); const observed = await this.#sourcePort.observe(this.#source.manifest.digest, { signal, onActivity }); 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 this.#sourcePort.transfer(this.#source, { signal, onActivity }); await this.#sourcePort.writeManifest(this.#source.manifestBytes()); const applied = await this.#sourcePort.apply({ signal, onActivity }); if (applied.digest !== this.#source.manifest.digest) throw new Error(this.#messages.sourceApplyMismatch); diff --git a/src/guest/workspace-agent.mjs b/src/guest/workspace-agent.mjs index 6d8441768..0d6d7248a 100644 --- a/src/guest/workspace-agent.mjs +++ b/src/guest/workspace-agent.mjs @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, symlink, writeFile, chmod } from 'node:fs/promises'; import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; const TREE_PROTOCOL = 'devbridge/file-tree-v1'; const DELTA_PROTOCOL = 'devbridge/file-tree-delta-v1'; @@ -17,6 +18,46 @@ const MAX_TREE_BYTES = 8 * 1024 * 1024 * 1024; const PART_BYTES = 32 * 1024 * 1024; const MAX_DESCRIPTOR_BYTES = 8 * 1024 * 1024; +async function unpackSource(file, expectedDigest) { + const expected = exactDigest(expectedDigest, 'source pack digest'); + 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 = requireObject(JSON.parse(gunzipSync(compressed, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')), 'source pack'); + onlyKeys(value, new Set(['protocol', 'parts']), 'source pack'); + 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 = requireObject(raw, 'packed source part'); + onlyKeys(part, new Set(['name', 'size', 'digest', 'data']), 'packed source part'); + 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, 'packed source 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`); +} + function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; @@ -62,5 +103,5 @@ function splitNul(text){return Buffer.from(text,'utf8').toString('utf8').split(' async function collect(outputDir,stateFile){const root=await workRoot();const state=await readState(stateFile);if(!state)throw new Error('candidate collection requires an applied source baseline');const verify=(await git(['cat-file','-e',`${state.baselineCommit}^{commit}`],{allowFailure:true}));if(verify.exitCode!==0)throw new Error('guest baseline commit is unavailable');const changed=splitNul((await git(['diff','--name-only','-z',state.baselineCommit,'--'])).stdout);const untracked=splitNul((await git(['ls-files','--others','--exclude-standard','-z'])).stdout);const paths=[...new Set([...changed,...untracked].map((entry)=>normalizePath(entry,'candidate path')))].sort();if(paths.length>MAX_ENTRIES)throw new Error('candidate exceeds entry ceiling');await rm(outputDir,{recursive:true,force:true});await mkdir(outputDir,{recursive:true,mode:0o700});const entries=[];let totalBytes=0;for(const relative of paths){if(relative==='.git'||relative.startsWith('.git/')||relative==='.devbridge'||relative.startsWith('.devbridge/'))throw new Error(`candidate path is reserved: ${relative}`);const candidate=path.join(root,...relative.split('/'));let info;try{info=await lstat(candidate);}catch(error){if(error?.code==='ENOENT'){entries.push({path:relative,action:'delete'});continue;}throw error;}if(info.isDirectory())continue;if(info.isSymbolicLink()){entries.push({path:relative,action:'symlink',target:normalizeTarget(await readlink(candidate),relative,root)});continue;}if(!info.isFile())throw new Error(`candidate contains unsupported file type: ${relative}`);totalBytes+=info.size;if(totalBytes>MAX_TREE_BYTES)throw new Error('candidate exceeds byte ceiling');const handle=await open(candidate,'r');const whole=createHash('sha256');const parts=[];try{for(let offset=0,index=0;offset{process.stderr.write(`${error.name}: ${error.message}\n`);process.exitCode=1;}); diff --git a/test/repository-execution-nested-lego.test.js b/test/repository-execution-nested-lego.test.js index 959af1ee6..2735d53c8 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({ @@ -196,7 +208,7 @@ test('workspace session sequences only its local ports and closes exact ownershi 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'), + transfer: async () => calls.push('part'), writeManifest: async () => calls.push('manifest'), apply: async () => { calls.push('apply-source'); return { digest: 'source-digest' }; }, }, @@ -243,13 +255,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-source-transfer.test.js b/test/repository-source-transfer.test.js new file mode 100644 index 000000000..a6f6d528e --- /dev/null +++ b/test/repository-source-transfer.test.js @@ -0,0 +1,112 @@ +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 hash = value => createHash('sha256').update(value).digest('hex'); +function run(cwd, args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [agent, ...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, ['unpack-source', f.pack, identity]); + 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('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, ['unpack-source', f.pack, item.expected ?? hash(item.bytes)]); + assert.notEqual(result.code, 0); + assert.deepEqual(await readdir(f.input), ['parts.gz']); + } +}); From c74ff7a52597775345aa95b03bf5a55f8eea8d98 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 13:29:08 -0700 Subject: [PATCH 10/20] Keep source packing independent of accepted image payloads --- src/app/repository-execution.js | 19 +++++++- src/guest/source-pack-agent.mjs | 62 +++++++++++++++++++++++++ src/guest/workspace-agent.mjs | 43 +---------------- test/guest-image-payload.test.js | 12 +++++ test/repository-source-transfer.test.js | 9 ++-- 5 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 src/guest/source-pack-agent.mjs diff --git a/src/app/repository-execution.js b/src/app/repository-execution.js index beb2afe3e..2ee2320ac 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -32,6 +32,7 @@ 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'; } @@ -125,6 +126,7 @@ 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({ @@ -156,6 +158,9 @@ 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' }; @@ -220,8 +225,18 @@ export async function createRepositoryExecution({ observe: (digest, options) => runAgent('prepare', [stateLocation, digest], { timeoutMs: 60_000, ...options }), transfer: (snapshot, options) => transferRepositorySource({ snapshot, ...options, - writePack: (value, controls) => bytes.write(value, { class: 'input', path: 'source/parts.gz' }, controls), - unpack: (identity, controls) => runAgent('unpack-source', [{ class: 'input', path: 'source/parts.gz' }, identity], { timeoutMs: 60_000, ...controls }), + writePack: async (value, controls) => { + if (!sourcePackAgentInstalled) { + await bytes.write(sourcePackAgentBytes, sourcePackAgentLocation, { signal: controls.signal }); + sourcePackAgentInstalled = true; + } + return 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), diff --git a/src/guest/source-pack-agent.mjs b/src/guest/source-pack-agent.mjs new file mode 100644 index 000000000..ffe51fb1a --- /dev/null +++ b/src/guest/source-pack-agent.mjs @@ -0,0 +1,62 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, 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`); +} + +try { + const args = process.argv.slice(2); + 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/guest/workspace-agent.mjs b/src/guest/workspace-agent.mjs index 0d6d7248a..6d8441768 100644 --- a/src/guest/workspace-agent.mjs +++ b/src/guest/workspace-agent.mjs @@ -2,7 +2,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, symlink, writeFile, chmod } from 'node:fs/promises'; import path from 'node:path'; -import { gunzipSync } from 'node:zlib'; const TREE_PROTOCOL = 'devbridge/file-tree-v1'; const DELTA_PROTOCOL = 'devbridge/file-tree-delta-v1'; @@ -18,46 +17,6 @@ const MAX_TREE_BYTES = 8 * 1024 * 1024 * 1024; const PART_BYTES = 32 * 1024 * 1024; const MAX_DESCRIPTOR_BYTES = 8 * 1024 * 1024; -async function unpackSource(file, expectedDigest) { - const expected = exactDigest(expectedDigest, 'source pack digest'); - 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 = requireObject(JSON.parse(gunzipSync(compressed, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')), 'source pack'); - onlyKeys(value, new Set(['protocol', 'parts']), 'source pack'); - 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 = requireObject(raw, 'packed source part'); - onlyKeys(part, new Set(['name', 'size', 'digest', 'data']), 'packed source part'); - 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, 'packed source 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`); -} - function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; @@ -103,5 +62,5 @@ function splitNul(text){return Buffer.from(text,'utf8').toString('utf8').split(' async function collect(outputDir,stateFile){const root=await workRoot();const state=await readState(stateFile);if(!state)throw new Error('candidate collection requires an applied source baseline');const verify=(await git(['cat-file','-e',`${state.baselineCommit}^{commit}`],{allowFailure:true}));if(verify.exitCode!==0)throw new Error('guest baseline commit is unavailable');const changed=splitNul((await git(['diff','--name-only','-z',state.baselineCommit,'--'])).stdout);const untracked=splitNul((await git(['ls-files','--others','--exclude-standard','-z'])).stdout);const paths=[...new Set([...changed,...untracked].map((entry)=>normalizePath(entry,'candidate path')))].sort();if(paths.length>MAX_ENTRIES)throw new Error('candidate exceeds entry ceiling');await rm(outputDir,{recursive:true,force:true});await mkdir(outputDir,{recursive:true,mode:0o700});const entries=[];let totalBytes=0;for(const relative of paths){if(relative==='.git'||relative.startsWith('.git/')||relative==='.devbridge'||relative.startsWith('.devbridge/'))throw new Error(`candidate path is reserved: ${relative}`);const candidate=path.join(root,...relative.split('/'));let info;try{info=await lstat(candidate);}catch(error){if(error?.code==='ENOENT'){entries.push({path:relative,action:'delete'});continue;}throw error;}if(info.isDirectory())continue;if(info.isSymbolicLink()){entries.push({path:relative,action:'symlink',target:normalizeTarget(await readlink(candidate),relative,root)});continue;}if(!info.isFile())throw new Error(`candidate contains unsupported file type: ${relative}`);totalBytes+=info.size;if(totalBytes>MAX_TREE_BYTES)throw new Error('candidate exceeds byte ceiling');const handle=await open(candidate,'r');const whole=createHash('sha256');const parts=[];try{for(let offset=0,index=0;offset{process.stderr.write(`${error.name}: ${error.message}\n`);process.exitCode=1;}); 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/repository-source-transfer.test.js b/test/repository-source-transfer.test.js index a6f6d528e..fa8420102 100644 --- a/test/repository-source-transfer.test.js +++ b/test/repository-source-transfer.test.js @@ -11,10 +11,11 @@ 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) { +function run(cwd, args, executable = agent) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [agent, ...args], { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + 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; }); @@ -42,7 +43,7 @@ test('consumer packs many files and actual guest provider applies the exact orig snapshot, writePack: async bytes => { packs += 1; compressedBytes += bytes.length; await writeFile(f.pack, bytes); }, unpack: async identity => { - const result = await run(f.work, ['unpack-source', f.pack, identity]); + const result = await run(f.work, [f.pack, identity], packAgent); assert.equal(result.code, 0, result.err); return JSON.parse(result.out); }, @@ -105,7 +106,7 @@ test('guest rejects forged digest, traversal, duplicates, invalid bytes and deco ]; for (const item of cases) { await writeFile(f.pack, item.bytes); - const result = await run(f.work, ['unpack-source', f.pack, item.expected ?? hash(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']); } From e52840a41026c473285d09cc24ebea8645bfbddc Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 14:10:51 -0700 Subject: [PATCH 11/20] Scope guest activity checks and expose workspace transfer progress --- docs/hello-world-qualification.md | 33 ++++++++++++++- docs/vm-stage6-repository-execution.md | 11 +++++ src/app/environment-activity-host.js | 3 -- src/app/execution-profile-routing.js | 5 ++- src/app/repository-execution/byte-channel.js | 2 +- .../repository-execution/source-transfer.js | 9 ++-- .../repository-execution/workspace-session.js | 26 ++++++++++-- src/run/liveness-projecting-plan-executor.js | 3 ++ src/runtime/environment-foundation.js | 2 +- src/runtime/persistent-environments.js | 10 ++++- .../ordinary-lifecycle.js | 3 +- test/environment-activity-host.test.js | 2 +- test/execution-profile-routing.test.js | 17 ++++++++ .../liveness-projecting-plan-executor.test.js | 14 +++++++ test/persistent-environments.test.js | 24 +++++++++++ test/repository-execution-nested-lego.test.js | 11 ++++- test/repository-source-transfer.test.js | 42 +++++++++++++++++++ 17 files changed, 196 insertions(+), 21 deletions(-) diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index 771ca49be..4bf85bcab 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -53,8 +53,21 @@ 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 | Unproved; the accepted v14 image remains unchanged; the existing rebuild resumed to ready on September 8. GitHub task #523 has entered the normal installed workflow; its compile/test result is still pending. | +| Windows | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Unproved; the accepted v6 image remains unchanged. Fresh bootstrap qualification passed, but the production create operation last reported insufficient host storage and still requires owner continuation. | + +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. Source transfer batching has consumer/provider tests; its native workflow +qualification remains outstanding until exercised by the installed runner. 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 +77,22 @@ Hyper-V evidence. Common code and changed contracts must retain both adapters. ## Failure behavior on the exercised path +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. Native before/after workflow timing and +the six-case milestone remain outstanding; this diagnosis is not compile proof. + 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 diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index 958e5b114..64634600c 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -24,6 +24,13 @@ 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. +Each request still reobserves the selected generation and rejects an unavailable, +unowned, or incompatible attachment. No observations are cached across requests. + ## Local route policy Routes are stored under the control-owned state directory at: @@ -71,6 +78,10 @@ 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. +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/src/app/environment-activity-host.js b/src/app/environment-activity-host.js index da0cdac26..33e1eeac2 100644 --- a/src/app/environment-activity-host.js +++ b/src/app/environment-activity-host.js @@ -78,11 +78,8 @@ 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, access: async (target) => selectedPreparation.connection(await requestFor(target), target), diff --git a/src/app/execution-profile-routing.js b/src/app/execution-profile-routing.js index 5615b32f8..023967818 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; }; diff --git a/src/app/repository-execution/byte-channel.js b/src/app/repository-execution/byte-channel.js index 039ecf22b..797cc4a8e 100644 --- a/src/app/repository-execution/byte-channel.js +++ b/src/app/repository-execution/byte-channel.js @@ -2,7 +2,7 @@ function sourceFrom(value, { signal = null, onProgress = null } = {}) { const bytes = Buffer.from(value); return { async read({ offset, limit }) { - onProgress?.({ offset, total: bytes.length }); + 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 }; diff --git a/src/app/repository-execution/source-transfer.js b/src/app/repository-execution/source-transfer.js index bc6ea37f3..a2f677950 100644 --- a/src/app/repository-execution/source-transfer.js +++ b/src/app/repository-execution/source-transfer.js @@ -17,7 +17,8 @@ export async function transferRepositorySource({ snapshot, writePack, unpack, wr const parts = snapshot.manifest.entries.filter(entry => entry.type === 'file').flatMap(entry => entry.parts); let batch = [], size = 0, completed = 0; const started = Date.now(); - const progress = () => onActivity?.({ activity: `source-transfer ${completed}/${parts.length} parts`, elapsedMs: Date.now() - started, at: new Date().toISOString() }); + 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); @@ -26,7 +27,7 @@ export async function transferRepositorySource({ snapshot, writePack, unpack, wr const bytes = gzipSync(serialized); const identity = digest(bytes); await writePack(bytes, { signal, onProgress: ({ offset, total }) => onActivity?.({ - activity: `source-transfer ${completed}/${parts.length} parts; pack ${offset}/${total} bytes`, + kind: `source-transfer ${completed}/${parts.length} parts; pack ${offset}/${total} bytes`, elapsedMs: Date.now() - started, at: new Date().toISOString(), }) }); active(signal); @@ -34,14 +35,14 @@ export async function transferRepositorySource({ snapshot, writePack, unpack, wr 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; - progress(); + 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; progress(); + completed += 1; await progress(); continue; } if (size + part.size > RAW_BYTES || batch.length === MAX_PARTS) await flush(); diff --git a/src/app/repository-execution/workspace-session.js b/src/app/repository-execution/workspace-session.js index 368e6f26e..b28afb554 100644 --- a/src/app/repository-execution/workspace-session.js +++ b/src/app/repository-execution/workspace-session.js @@ -31,21 +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) { - await this.#sourcePort.transfer(this.#source, { signal, onActivity }); + await report('source-transfer'); + await this.#sourcePort.transfer(this.#source, { signal, onActivity: preparationActivity }); + await report('source-manifest'); await this.#sourcePort.writeManifest(this.#source.manifestBytes()); - const applied = await this.#sourcePort.apply({ signal, onActivity }); + 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/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/environment-foundation.js b/src/runtime/environment-foundation.js index df3147e41..1b362edc4 100644 --- a/src/runtime/environment-foundation.js +++ b/src/runtime/environment-foundation.js @@ -228,7 +228,7 @@ 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 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/persistent-environments.js b/src/runtime/persistent-environments.js index 3662ab27c..93d458d1a 100644 --- a/src/runtime/persistent-environments.js +++ b/src/runtime/persistent-environments.js @@ -146,10 +146,16 @@ export class PersistentEnvironments { return this.#ledger.run(async () => this.#provisioning.ensure(await this.#ledger.read(), normalizeRequest(raw))); } - async list() { + 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(state, binding); + return this.#lifecycle.list(state, binding, selection); }); } 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/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/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/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-environments.test.js b/test/persistent-environments.test.js index cca8f4484..d19bee849 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.test.js @@ -52,6 +52,30 @@ 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('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 })); diff --git a/test/repository-execution-nested-lego.test.js b/test/repository-execution-nested-lego.test.js index 2735d53c8..837ca534f 100644 --- a/test/repository-execution-nested-lego.test.js +++ b/test/repository-execution-nested-lego.test.js @@ -194,6 +194,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('{}'), @@ -207,7 +208,12 @@ 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' }; }, + 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' }; }, @@ -236,7 +242,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 () => {} }); diff --git a/test/repository-source-transfer.test.js b/test/repository-source-transfer.test.js index fa8420102..23349e63b 100644 --- a/test/repository-source-transfer.test.js +++ b/test/repository-source-transfer.test.js @@ -93,6 +93,25 @@ test('cancellation after pack delivery prevents unpack and subsequent effects', }), /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') }; @@ -111,3 +130,26 @@ test('guest rejects forged digest, traversal, duplicates, invalid bytes and deco 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']); +}); From 2db5a8765a88bf1c3110eb145244ad3beb1c42cd Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 14:52:38 -0700 Subject: [PATCH 12/20] docs: require warm guest reuse and bound preparation scope --- docs/architecture.md | 2 +- docs/execution-profile-environments.md | 7 ++++- docs/hello-world-qualification.md | 37 +++++++++++++++++--------- docs/vm-stage5-guest-bootstrap.md | 9 +++++++ docs/vm-stage6-repository-execution.md | 16 +++++++++-- specs/DB-020-vm-execution-boundary.md | 29 +++++++++++++++++++- 6 files changed, 83 insertions(+), 17 deletions(-) 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 4bf85bcab..53b94ae2a 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -53,8 +53,8 @@ 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; the accepted v14 image remains unchanged; the existing rebuild resumed to ready on September 8. GitHub task #523 has entered the normal installed workflow; its compile/test result is still pending. | -| Windows | GitHub admission -> VM CMake compile -> CTest output -> automatic GitHub result | Unproved; the accepted v6 image remains unchanged. Fresh bootstrap qualification passed, but the production create operation last reported insufficient host storage and still requires owner continuation. | +| 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, @@ -66,8 +66,10 @@ 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. Source transfer batching has consumer/provider tests; its native workflow -qualification remains outstanding until exercised by the installed runner. +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 @@ -90,8 +92,19 @@ 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. Native before/after workflow timing and -the six-case milestone remain outstanding; this diagnosis is not compile proof. +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 @@ -108,12 +121,12 @@ 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. +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/vm-stage5-guest-bootstrap.md b/docs/vm-stage5-guest-bootstrap.md index 78fe39001..9cac05ce0 100644 --- a/docs/vm-stage5-guest-bootstrap.md +++ b/docs/vm-stage5-guest-bootstrap.md @@ -33,6 +33,15 @@ 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. +The current `ensure` implementation still invokes provider preparation before it +inspects this generation record. Its unchanged-generation fast path therefore +skips guest `apply`, but does not yet skip host attachment/network/access +preparation. This is an implementation gap, not a requirement to repeat startup +work for every operation. 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 64634600c..da588ea0a 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -28,8 +28,20 @@ Composing a protected activity request reads the foundation identity independent 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. -Each request still reobserves the selected generation and rejects an unavailable, -unowned, or incompatible attachment. No observations are cached across requests. +The current implementation reobserves the selected generation for each request and +rejects an unavailable, unowned, or incompatible attachment. It does not yet reuse +native observations across requests. This describes the current implementation, +not a requirement to repeat native inspection for every transfer frame. DB-020's +running-lifetime contract requires owner-bound readiness reuse while preserving +current route, generation, policy, lease/fence, and request identity checks. + +Current preparation gap: each registered operation and scratch-cleanup session +calls activity preparation and health again. Bootstrap `ensure` invokes provider +preparation before inspecting the existing bootstrap generation, so an already +running guest can receive repeated attachment/network/access preparation. Normal +session close does not stop the VM. Removing this repeated startup work remains +part of the lifecycle/execution ownership refactor; persistent disks alone do not +establish an efficient warm-guest path. ## Local route policy 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; From d78b46c1415c6dbcda4489679f2946213b6a8e00 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 15:26:34 -0700 Subject: [PATCH 13/20] Reuse valid guest preparation and source parts; remove duplicate CI suites --- .github/workflows/ci.yml | 14 +--- docs/hello-world-qualification.md | 31 ++++++++ docs/vm-stage5-guest-bootstrap.md | 14 ++-- docs/vm-stage6-repository-execution.md | 18 +++-- src/app/environment-bootstrap.js | 4 +- .../environment-construction-observation.js | 17 ++-- src/app/repository-execution.js | 35 +++++---- .../repository-execution/source-transfer.js | 17 +++- .../repository-execution/workspace-session.js | 4 +- src/bootstrap/repository-preflight.mjs | 8 +- src/guest/source-pack-agent.mjs | 53 ++++++++++++- src/runtime/environment-bootstrap.js | 18 ++++- test/environment-bootstrap.test.js | 39 ++++++++++ ...vironment-construction-observation.test.js | 23 +++++- test/repository-preflight-options.test.js | 17 +++- test/repository-source-transfer.test.js | 78 +++++++++++++++++++ 16 files changed, 330 insertions(+), 60 deletions(-) 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/hello-world-qualification.md b/docs/hello-world-qualification.md index 53b94ae2a..b0f15f432 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -79,6 +79,37 @@ 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 diff --git a/docs/vm-stage5-guest-bootstrap.md b/docs/vm-stage5-guest-bootstrap.md index 9cac05ce0..40a53fa12 100644 --- a/docs/vm-stage5-guest-bootstrap.md +++ b/docs/vm-stage5-guest-bootstrap.md @@ -35,12 +35,14 @@ Reset/reseed already replaces the Stage-3 writable layer and changes the exact e DB-020 now explicitly requires a running guest to be reused between jobs and expensive preparation to follow startup, material change, or failure recovery. -The current `ensure` implementation still invokes provider preparation before it -inspects this generation record. Its unchanged-generation fast path therefore -skips guest `apply`, but does not yet skip host attachment/network/access -preparation. This is an implementation gap, not a requirement to repeat startup -work for every operation. A durable generation record alone does not prove that a -guest has not rebooted or that a capability observation is still usable. +`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 diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index da588ea0a..fba67258d 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -36,12 +36,11 @@ running-lifetime contract requires owner-bound readiness reuse while preserving current route, generation, policy, lease/fence, and request identity checks. Current preparation gap: each registered operation and scratch-cleanup session -calls activity preparation and health again. Bootstrap `ensure` invokes provider -preparation before inspecting the existing bootstrap generation, so an already -running guest can receive repeated attachment/network/access preparation. Normal -session close does not stop the VM. Removing this repeated startup work remains -part of the lifecycle/execution ownership refactor; persistent disks alone do not -establish an efficient warm-guest path. +calls activity preparation and health again. Bootstrap `ensure` now skips provider +preparation for a ready exact generation, but repeated observation and per-frame +connection setup still remain. Normal session close does not stop the VM. Removing +unnecessary observation and connection setup remains part of the ownership +refactor; persistent disks alone do not establish an efficient warm-guest path. ## Local route policy @@ -90,6 +89,13 @@ 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. diff --git a/src/app/environment-bootstrap.js b/src/app/environment-bootstrap.js index eaa9e13f8..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' @@ -169,7 +169,7 @@ export async function createEnvironmentBootstrap({ input: JSON.stringify(frame), timeoutMs: 30_000, maxOutputBytes: 256 * 1024, - }, { pollIntervalMs: 500 }); + }, { pollIntervalMs: 500, signal }); return parseBootstrapOutput(outcome); }; 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/repository-execution.js b/src/app/repository-execution.js index 2ee2320ac..9eec5b794 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -223,22 +223,27 @@ export async function createRepositoryExecution({ snapshot, install: () => bytes.write(agentBytes, agentLocation), observe: (digest, options) => runAgent('prepare', [stateLocation, digest], { timeoutMs: 60_000, ...options }), - transfer: (snapshot, options) => transferRepositorySource({ - snapshot, ...options, - writePack: async (value, controls) => { - if (!sourcePackAgentInstalled) { - await bytes.write(sourcePackAgentBytes, sourcePackAgentLocation, { signal: controls.signal }); - sourcePackAgentInstalled = true; - } - return bytes.write(value, sourcePackLocation, controls); - }, - unpack: async (identity, controls) => parseAgentResult(await channel.execute(target, { - program: 'node', arguments: [sourcePackAgentLocation, sourcePackLocation, identity], + 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: 16 * 1024, - }, controls), 'source pack unpacking'), - writePart: (part, read) => bytes.stream({ read }, { class: 'input', path: `source/${part.name}` }, { maxBytes: Math.max(1, part.size) }), - }), + 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 }), }, diff --git a/src/app/repository-execution/source-transfer.js b/src/app/repository-execution/source-transfer.js index a2f677950..e23042b85 100644 --- a/src/app/repository-execution/source-transfer.js +++ b/src/app/repository-execution/source-transfer.js @@ -13,8 +13,21 @@ function active(signal) { // 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, writePack, unpack, writePart, signal = null, onActivity = null }) { - const parts = snapshot.manifest.entries.filter(entry => entry.type === 'file').flatMap(entry => entry.parts); +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() }); diff --git a/src/app/repository-execution/workspace-session.js b/src/app/repository-execution/workspace-session.js index b28afb554..82042acb9 100644 --- a/src/app/repository-execution/workspace-session.js +++ b/src/app/repository-execution/workspace-session.js @@ -57,10 +57,10 @@ export class WorkspaceSession { await report('source-check'); const observed = await this.#sourcePort.observe(this.#source.manifest.digest, { signal, onActivity: preparationActivity }); if (observed.appliedDigest !== this.#source.manifest.digest) { - await report('source-transfer'); - await this.#sourcePort.transfer(this.#source, { signal, onActivity: preparationActivity }); await report('source-manifest'); await this.#sourcePort.writeManifest(this.#source.manifestBytes()); + 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); diff --git a/src/bootstrap/repository-preflight.mjs b/src/bootstrap/repository-preflight.mjs index dc8bee44e..024466cc2 100644 --- a/src/bootstrap/repository-preflight.mjs +++ b/src/bootstrap/repository-preflight.mjs @@ -698,13 +698,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 +714,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 +723,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 +773,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/guest/source-pack-agent.mjs b/src/guest/source-pack-agent.mjs index ffe51fb1a..75ca8c657 100644 --- a/src/guest/source-pack-agent.mjs +++ b/src/guest/source-pack-agent.mjs @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { lstat, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { gunzipSync } from 'node:zlib'; @@ -52,10 +52,57 @@ async function unpackSource(file, expectedDigest) { 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 !== 2) throw new Error('source unpacking requires pack path and digest'); - await unpackSource(...args); + 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/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/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/repository-preflight-options.test.js b/test/repository-preflight-options.test.js index 7d3a58783..6b3d46abf 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,6 +62,19 @@ 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', () => { for (const args of [['--ci-qualification'], ['--ci-qualification', '--bound-targeted-test-concurrency'], ['--bound-targeted-test-concurrency', '--ci-qualification']]) { diff --git a/test/repository-source-transfer.test.js b/test/repository-source-transfer.test.js index 23349e63b..fbdd433d2 100644 --- a/test/repository-source-transfer.test.js +++ b/test/repository-source-transfer.test.js @@ -153,3 +153,81 @@ test('partial guest staging can resume the same pack without changing repository 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/); +}); From 8f619f5510a0edd2471d2d5c23f4a406d4cf2db7 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 15:31:03 -0700 Subject: [PATCH 14/20] Check CI prerequisite and coverage behavior instead of obsolete step geometry --- test/public-repository.test.js | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) 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); }); From a052290c40588c28e73516ee71b696b95a5664fe Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 15:55:32 -0700 Subject: [PATCH 15/20] Reuse bound activity workers and guest connections across warm requests --- docs/hello-world-qualification.md | 21 ++++ docs/vm-stage4-environment-bridge.md | 7 +- docs/vm-stage6-repository-execution.md | 32 ++++-- src/app/environment-activity-host.js | 13 ++- src/app/environment-activity-runtime.js | 31 ++++-- src/app/environment-bridge.js | 10 +- src/app/execution-profile-routing.js | 8 ++ src/bootstrap/repository-preflight.mjs | 3 + .../windows-lifecycle-authority-worker.mjs | 44 +++++++- src/runtime/command-channel.js | 104 ++++++++++++++++++ src/runtime/environment-foundation.js | 1 + src/runtime/persistent-environments.js | 25 +++++ .../providers/guest-bridge-command-session.js | 94 ++++++++++++++++ .../providers/hyperv-environment-bridge.js | 84 ++++++++++---- src/setup/windows-lifecycle-authority-host.cs | 72 ++++++++++-- test/command-channel.test.js | 54 +++++++++ test/environment-activity-runtime.test.js | 24 ++++ test/hyperv-environment-bridge.test.js | 81 ++++++++++++++ test/persistent-environments.test.js | 16 +++ test/repository-preflight-options.test.js | 15 ++- test/windows-lifecycle-authority-host.test.js | 28 ++++- ...windows-lifecycle-authority-worker.test.js | 40 ++++++- 22 files changed, 744 insertions(+), 63 deletions(-) create mode 100644 src/runtime/command-channel.js create mode 100644 src/runtime/providers/guest-bridge-command-session.js create mode 100644 test/command-channel.test.js diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index b0f15f432..878670166 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -152,6 +152,27 @@ desktop access, SSH, a debug flag or a later log request a production prerequisi ## Work order and ownership +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 and replacing +it after cancellation/configuration access. Preflight's three fixed inventory-count +assertions were replaced with actual invocation/inventory checks; their focused +tests pass. Native connection timings and both 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. + 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 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-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index fba67258d..8a2bb9832 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -28,19 +28,31 @@ Composing a protected activity request reads the foundation identity independent 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 current implementation reobserves the selected generation for each request and -rejects an unavailable, unowned, or incompatible attachment. It does not yet reuse -native observations across requests. This describes the current implementation, -not a requirement to repeat native inspection for every transfer frame. DB-020's -running-lifetime contract requires owner-bound readiness reuse while preserving -current route, generation, policy, lease/fence, and request identity checks. +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. Unreconciled selected lifecycle +operations block dispatch. 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, idle +expiry, cancellation and protected authority changes discard reusable resources. +No cached observation substitutes for current task, lease/fence or result checks. + +The Windows service retains one serialized activity worker for at most 4096 +requests, with a 60-second idle timeout. 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 mutation/configuration invalidates retained activity resources. +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, but repeated observation and per-frame -connection setup still remain. Normal session close does not stop the VM. Removing -unnecessary observation and connection setup remains part of the ownership -refactor; persistent disks alone do not establish an efficient warm-guest path. +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 diff --git a/src/app/environment-activity-host.js b/src/app/environment-activity-host.js index 33e1eeac2..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'); } @@ -82,6 +84,14 @@ export async function createProtectedEnvironmentActivity({ stateDirectory: authority, 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), }); @@ -98,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-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/execution-profile-routing.js b/src/app/execution-profile-routing.js index 023967818..1a537f2e4 100644 --- a/src/app/execution-profile-routing.js +++ b/src/app/execution-profile-routing.js @@ -142,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/bootstrap/repository-preflight.mjs b/src/bootstrap/repository-preflight.mjs index 024466cc2..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', diff --git a/src/entry/windows-lifecycle-authority-worker.mjs b/src/entry/windows-lifecycle-authority-worker.mjs index 1dea0da73..24b49e702 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 = 60_000, maxRequests = 4096 } = {}) { + let buffer = Buffer.alloc(0), timer = null, activity = null, count = 0; + const arm = () => { clearTimeout(timer); 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 (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/runtime/command-channel.js b/src/runtime/command-channel.js new file mode 100644 index 000000000..a84b9ecd1 --- /dev/null +++ b/src/runtime/command-channel.js @@ -0,0 +1,104 @@ +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); + 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); + 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-foundation.js b/src/runtime/environment-foundation.js index 1b362edc4..a9c03270e 100644 --- a/src/runtime/environment-foundation.js +++ b/src/runtime/environment-foundation.js @@ -229,6 +229,7 @@ export class EnvironmentFoundation { async ensureEnvironment(input) { return this.#lifecycle.ensure(input); } 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/persistent-environments.js b/src/runtime/persistent-environments.js index 93d458d1a..1a58f56d0 100644 --- a/src/runtime/persistent-environments.js +++ b/src/runtime/persistent-environments.js @@ -166,6 +166,31 @@ export class PersistentEnvironments { }); } + // 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'); + if (Object.values(state.operations).some(operation => operation.state !== 'reconciled' + && (operation.slot === entry.slot || operation.identity === entry.current.identity + || operation.oldIdentity === entry.current.identity || operation.newIdentity === entry.current.identity))) { + throw new Error('environment has an unreconciled lifecycle operation'); + } + } + return Object.freeze({ revision: state.revision, records: entries.map(publicRecord) }); + }); + } + async #transition(kind, identity, options = {}) { return this.#ledger.run(async () => { const binding = await this.#effects.binding(); 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-bridge.js b/src/runtime/providers/hyperv-environment-bridge.js index 1a93d5aa0..54f7aa4d1 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: 60_000 }); + 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/setup/windows-lifecycle-authority-host.cs b/src/setup/windows-lifecycle-authority-host.cs index 4d498a68b..a0980bfab 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 (!reusable && !String.Equals(access, "read", 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,12 +727,30 @@ 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 diff --git a/test/command-channel.test.js b/test/command-channel.test.js new file mode 100644 index 000000000..6547a2744 --- /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(); 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/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/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/persistent-environments.test.js b/test/persistent-environments.test.js index d19bee849..1711233b1 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.test.js @@ -76,6 +76,22 @@ test('selected environment observation does not inspect an unrelated unavailable await assert.rejects(registry.list(), /unrelated provider unavailable/); }); +test('committed record queries expose identity without native inspection and reject unfinished mutations', 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/); + await assert.rejects(registry.records(), /unreconciled lifecycle/); + 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 })); diff --git a/test/repository-preflight-options.test.js b/test/repository-preflight-options.test.js index 6b3d46abf..305b1f9e3 100644 --- a/test/repository-preflight-options.test.js +++ b/test/repository-preflight-options.test.js @@ -76,6 +76,8 @@ test('static preflight retains artifact, syntax and inventory checks without rep }); 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); @@ -84,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); @@ -107,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); @@ -190,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/windows-lifecycle-authority-host.test.js b/test/windows-lifecycle-authority-host.test.js index 20b42a47c..4bd0a9b76 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,6 +406,18 @@ 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, 3, 'configuration authority must invalidate retained activity resources'); child.stdin.end('\n'); assert.equal(await waitForExit(child), 0); child = null; 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, From 1a96e55176ce07403feab8486f4e4013ad5e92b5 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 16:02:41 -0700 Subject: [PATCH 16/20] Keep committed environment identity reads independent of historical recovery intent --- docs/vm-stage6-repository-execution.md | 5 +++-- src/runtime/persistent-environments.js | 5 ----- test/persistent-environments.test.js | 6 ++++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index 8a2bb9832..23cbda568 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -30,8 +30,9 @@ Selecting a physical route queries the committed subject/profile selection befor 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. Unreconciled selected lifecycle -operations block dispatch. Opening a Hyper-V connection reobserves native +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, idle expiry, cancellation and protected authority changes discard reusable resources. diff --git a/src/runtime/persistent-environments.js b/src/runtime/persistent-environments.js index 1a58f56d0..8e5b707cd 100644 --- a/src/runtime/persistent-environments.js +++ b/src/runtime/persistent-environments.js @@ -181,11 +181,6 @@ export class PersistentEnvironments { && (identity == null || entry.current.identity === identity)); for (const entry of entries) { if (entry.binding !== binding) throw new Error('environment attachment identity changed'); - if (Object.values(state.operations).some(operation => operation.state !== 'reconciled' - && (operation.slot === entry.slot || operation.identity === entry.current.identity - || operation.oldIdentity === entry.current.identity || operation.newIdentity === entry.current.identity))) { - throw new Error('environment has an unreconciled lifecycle operation'); - } } return Object.freeze({ revision: state.revision, records: entries.map(publicRecord) }); }); diff --git a/test/persistent-environments.test.js b/test/persistent-environments.test.js index 1711233b1..70686d405 100644 --- a/test/persistent-environments.test.js +++ b/test/persistent-environments.test.js @@ -76,7 +76,7 @@ test('selected environment observation does not inspect an unrelated unavailable await assert.rejects(registry.list(), /unrelated provider unavailable/); }); -test('committed record queries expose identity without native inspection and reject unfinished mutations', async t => { +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(); @@ -88,7 +88,9 @@ test('committed record queries expose identity without native inspection and rej 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/); - await assert.rejects(registry.records(), /unreconciled lifecycle/); + 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, []); }); From d51f04c5f66f4a9aabf727f5a85db96abecec80c Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 16:10:00 -0700 Subject: [PATCH 17/20] Reuse verified access material within its protecting account --- docs/hello-world-qualification.md | 19 +++++++++++++++ .../windows-protected-access-material.js | 15 ++++++++++-- .../windows-protected-access-material.test.js | 23 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index 878670166..496f84724 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -173,6 +173,25 @@ 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. + 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 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/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)}`; From 83600dbe061f44c1f68ac3460b4c8e59fe7caff9 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 16:24:04 -0700 Subject: [PATCH 18/20] Retain healthy activity resources for the service lifetime --- docs/hello-world-qualification.md | 12 ++++++++++-- docs/vm-stage6-repository-execution.md | 14 +++++++++----- src/app/environment-construction-preparation.js | 1 + src/entry/windows-lifecycle-authority-worker.mjs | 6 +++--- src/runtime/command-channel.js | 3 ++- src/runtime/providers/hyperv-environment-bridge.js | 2 +- src/setup/windows-lifecycle-authority-host.cs | 2 +- test/command-channel.test.js | 2 +- test/windows-lifecycle-authority-host.test.js | 2 +- 9 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index 496f84724..3607741c1 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -157,8 +157,8 @@ 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 and replacing -it after cancellation/configuration access. Preflight's three fixed inventory-count +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. Native connection timings and both installed workflow timings remain outstanding. This does not qualify native KVM or claim the latency milestone done. @@ -192,6 +192,14 @@ 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. + 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 diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index 23cbda568..31bb130f8 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -34,15 +34,19 @@ 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, idle -expiry, cancellation and protected authority changes discard reusable resources. +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. -The Windows service retains one serialized activity worker for at most 4096 -requests, with a 60-second idle timeout. It releases the service gate between +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 mutation/configuration invalidates retained activity resources. +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. diff --git a/src/app/environment-construction-preparation.js b/src/app/environment-construction-preparation.js index 780c79935..0bac42170 100644 --- a/src/app/environment-construction-preparation.js +++ b/src/app/environment-construction-preparation.js @@ -120,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/entry/windows-lifecycle-authority-worker.mjs b/src/entry/windows-lifecycle-authority-worker.mjs index 24b49e702..6a6dba2b4 100644 --- a/src/entry/windows-lifecycle-authority-worker.mjs +++ b/src/entry/windows-lifecycle-authority-worker.mjs @@ -128,9 +128,9 @@ async function readSingleRequest(input, maxWireBytes) { } export async function runWindowsActivityAuthorityStream({ input, output, activityFactory, - idleMs = 60_000, maxRequests = 4096 } = {}) { + idleMs = 0, maxRequests = 0 } = {}) { let buffer = Buffer.alloc(0), timer = null, activity = null, count = 0; - const arm = () => { clearTimeout(timer); timer = setTimeout(() => input.destroy(new Error('activity worker idle lifetime ended')), idleMs); }; + 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) { @@ -151,7 +151,7 @@ export async function runWindowsActivityAuthorityStream({ input, output, activit if (Buffer.byteLength(wire) > ACTIVITY_MAX_RESULT_WIRE_BYTES) throw new Error('activity worker response exceeded its bound'); output.write(wire); count += 1; - if (count >= maxRequests) return; + if (maxRequests > 0 && count >= maxRequests) return; arm(); } if (buffer.length) throw new Error('activity worker request was interrupted'); diff --git a/src/runtime/command-channel.js b/src/runtime/command-channel.js index a84b9ecd1..059b11896 100644 --- a/src/runtime/command-channel.js +++ b/src/runtime/command-channel.js @@ -15,7 +15,7 @@ export function openJsonCommandChannel({ executable, arguments: args = [], input 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); - limit(idleMs, 'command channel idle 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); @@ -37,6 +37,7 @@ export function openJsonCommandChannel({ executable, arguments: args = [], input 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?.(); }; diff --git a/src/runtime/providers/hyperv-environment-bridge.js b/src/runtime/providers/hyperv-environment-bridge.js index 54f7aa4d1..b284fadd7 100644 --- a/src/runtime/providers/hyperv-environment-bridge.js +++ b/src/runtime/providers/hyperv-environment-bridge.js @@ -193,7 +193,7 @@ export class HyperVEnvironmentBridge { initialization = { target: frame.target }; } this.#channel = this.#openChannel({ executable, arguments: args, inputLimit: 64 * 1024, - outputLimit: MAX_RESPONSE_BYTES, timeoutMs: 120_000, idleMs: 60_000 }); + 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; diff --git a/src/setup/windows-lifecycle-authority-host.cs b/src/setup/windows-lifecycle-authority-host.cs index a0980bfab..c553a8474 100644 --- a/src/setup/windows-lifecycle-authority-host.cs +++ b/src/setup/windows-lifecycle-authority-host.cs @@ -659,7 +659,7 @@ private WorkerResponse InvokeWorker(string access, byte[] request, int maxRespon { if (stopping) return null; bool reusable = String.Equals(access, "activity", StringComparison.Ordinal); - if (!reusable && !String.Equals(access, "read", StringComparison.Ordinal)) StopActivityWorker(); + if (String.Equals(access, "mutation", StringComparison.Ordinal)) StopActivityWorker(); if (reusable && activityWorker != null) { if (activityWorker.HasExited) StopActivityWorker(); diff --git a/test/command-channel.test.js b/test/command-channel.test.js index 6547a2744..f5846663c 100644 --- a/test/command-channel.test.js +++ b/test/command-channel.test.js @@ -6,7 +6,7 @@ const echo = `const readline = require('node:readline'); let count = 0; readline 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(); t.after(() => current.close()); + 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: '☃' }); diff --git a/test/windows-lifecycle-authority-host.test.js b/test/windows-lifecycle-authority-host.test.js index 4bd0a9b76..716ddbf82 100644 --- a/test/windows-lifecycle-authority-host.test.js +++ b/test/windows-lifecycle-authority-host.test.js @@ -417,7 +417,7 @@ internal static class IntegrationHarness 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, 3, 'configuration authority must invalidate retained activity resources'); + 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; From 3489c99f3e49d5053c31fa1d98358c25a27a4847 Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 16:38:59 -0700 Subject: [PATCH 19/20] Observe only the selected execution route after startup --- docs/hello-world-qualification.md | 18 +++++++++++-- docs/vm-stage6-repository-execution.md | 5 ++++ src/app/repository-execution.js | 7 +++-- src/app/repository-execution/route-access.js | 2 +- test/app-repository-execution.test.js | 27 +++++++++++++++++++ test/repository-execution-nested-lego.test.js | 11 ++++++++ 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index 3607741c1..bfcdd5e2f 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -160,8 +160,8 @@ 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. Native connection timings and both installed workflow timings remain -outstanding. This does not qualify native KVM or claim the latency milestone done. +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 @@ -200,6 +200,20 @@ 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. + 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 diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index 31bb130f8..d460dce50 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -38,6 +38,11 @@ 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 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. diff --git a/src/app/repository-execution.js b/src/app/repository-execution.js index 9eec5b794..c2f6cb5fa 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -26,6 +26,7 @@ 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; @@ -54,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) }), @@ -133,7 +136,7 @@ export async function createRepositoryExecution({ policy, identify: resolveSubject, select: environmentActivityRouteForSubject, - list: () => state.listEnvironments(), + list: (selection) => state.listEnvironments(selection), root: rootFor, canonicalize: (value) => realpath(path.resolve(value)), inspect: lstat, 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/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/repository-execution-nested-lego.test.js b/test/repository-execution-nested-lego.test.js index 837ca534f..d9aa0b8a9 100644 --- a/test/repository-execution-nested-lego.test.js +++ b/test/repository-execution-nested-lego.test.js @@ -178,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', From fe13026f32582897bf8932d6866bcae8dd6ded7a Mon Sep 17 00:00:00 2001 From: iteathen Date: Tue, 8 Sep 2026 17:07:46 -0700 Subject: [PATCH 20/20] Recover interrupted workflow state and shorten new scratch paths --- docs/hello-world-qualification.md | 25 ++++++ docs/vm-stage6-repository-execution.md | 8 ++ src/app/repository-execution.js | 27 +++++- .../operation-materializer.js | 4 +- src/guest/resource-agent.mjs | 19 +++- src/run/controller-plan-executor.js | 23 +++++ src/state/json-record-file.js | 25 ++++-- src/state/json-state-store.js | 46 ++++------ test/controller-plan.test.js | 34 +++++++ test/json-state-store.test.js | 88 +++++++++++++++++++ test/resource-agent.test.js | 26 ++++++ 11 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 test/json-state-store.test.js create mode 100644 test/resource-agent.test.js diff --git a/docs/hello-world-qualification.md b/docs/hello-world-qualification.md index bfcdd5e2f..2ca1828fb 100644 --- a/docs/hello-world-qualification.md +++ b/docs/hello-world-qualification.md @@ -214,6 +214,31 @@ 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 diff --git a/docs/vm-stage6-repository-execution.md b/docs/vm-stage6-repository-execution.md index d460dce50..98514643b 100644 --- a/docs/vm-stage6-repository-execution.md +++ b/docs/vm-stage6-repository-execution.md @@ -43,6 +43,14 @@ operation then requests only its selected subject/profile observation through th 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. diff --git a/src/app/repository-execution.js b/src/app/repository-execution.js index c2f6cb5fa..9c82ab5c1 100644 --- a/src/app/repository-execution.js +++ b/src/app/repository-execution.js @@ -167,8 +167,10 @@ export async function createRepositoryExecution({ 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), @@ -179,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, @@ -303,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/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/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/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/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/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/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/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/); +});