diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index b530c6553d..0a23530b0b 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 27 | -| portable-candidate | 11 | +| portable-candidate | 12 | | platform-contract | 31 | -Total Windows-excluded declarations: **69** +Total Windows-excluded declarations: **70** ## Inventory @@ -78,6 +78,7 @@ Total Windows-excluded declarations: **69** | portable-candidate | `packages/storage/src/__tests__/managed-dependency-environment.test.ts` isolates published POSIX content from a producer-retained writable handle | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/operational-state-store.test.ts` does not classify a SQLite write failure as a migration blocker | `process.platform === 'win32' ? 'POSIX permissions are required to make the SQLite database read-only' : false` | | platform-contract | `packages/storage/src/__tests__/pet-pack-store.test.ts` detects sprite sheets redirected outside the installed pack | `process.platform === 'win32' ? 'Windows file-symlink permissions are not guaranteed in CI' : false` | +| portable-candidate | `packages/storage/src/__tests__/production-session-snapshot.test.ts` rejects a POSIX-only workspace name with a bounded portability diagnostic | `process.platform === 'win32'` | | portable-candidate | `packages/storage/src/__tests__/quiescent-session-snapshot.test.ts` requires a private staging parent on POSIX | `process.platform === 'win32'` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` preserves unexpected marker I/O failures at the public authority boundary | `process.platform === 'win32' ? 'POSIX permissions are required to make the marker unreadable' : typeof process.getuid === 'function' && process.getuid() === 0` | | platform-contract | `packages/storage/src/__tests__/root-authority.test.ts` rejects FIFO marker paths without blocking root resolution | `process.platform === 'win32'` | diff --git a/packages/runtime/src/__tests__/quiescent-session-snapshot.test.ts b/packages/runtime/src/__tests__/quiescent-session-snapshot.test.ts index a944b4857f..334b55a5a0 100644 --- a/packages/runtime/src/__tests__/quiescent-session-snapshot.test.ts +++ b/packages/runtime/src/__tests__/quiescent-session-snapshot.test.ts @@ -99,6 +99,31 @@ test('does not invoke snapshot work when Host eligibility rejects the Session', assert.equal(invoked, false); }); +test('preserves an operation AbortError for coordinator cancellation normalization', async () => { + const authority = createRuntimeSessionSnapshotQuiescenceAuthority( + { + async runSessionQuiescentMutation(_sessionIds, operation) { + return await operation(); + }, + }, + { + assertSnapshotEligible() {}, + }, + ); + const controller = new AbortController(); + + await assert.rejects( + authority.runQuiescent( + { makaSessionId: 'session-1', cancellation: { signal: controller.signal } }, + async () => { + controller.abort(); + controller.signal.throwIfAborted(); + }, + ), + (error) => error instanceof Error && error.name === 'AbortError', + ); +}); + test('actual Runtime Kernel serializes an admitted mutation before snapshot work', async () => { const kernel = new RuntimeKernel({} as never); let releaseMutation!: () => void; diff --git a/packages/runtime/src/quiescent-session-snapshot.ts b/packages/runtime/src/quiescent-session-snapshot.ts index 437b8a12cb..553d012f19 100644 --- a/packages/runtime/src/quiescent-session-snapshot.ts +++ b/packages/runtime/src/quiescent-session-snapshot.ts @@ -101,14 +101,10 @@ export function createRuntimeSessionSnapshotQuiescenceAuthority( }, ); } - throw new SessionSnapshotError( - 'io_failure', - 'Unable to enter the Session snapshot boundary', - { - cause: error, - details: { phase: 'admission' }, - }, - ); + // The coordinator owns normalization of failures from the admitted + // operation. In particular, AbortError must remain visible so it is + // reported as snapshot_cancelled rather than an admission I/O error. + throw error; } }, } as SessionSnapshotQuiescenceAuthority; diff --git a/packages/storage/src/__tests__/production-session-snapshot.test.ts b/packages/storage/src/__tests__/production-session-snapshot.test.ts index 18dcf51bca..5250d30c47 100644 --- a/packages/storage/src/__tests__/production-session-snapshot.test.ts +++ b/packages/storage/src/__tests__/production-session-snapshot.test.ts @@ -18,9 +18,9 @@ */ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { test } from 'node:test'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { acquireProcessLifetimeOwner } from '../process-lifetime-owner.js'; @@ -34,7 +34,7 @@ import { type SessionSnapshotWorkspaceConfirmationAuthority, } from '../quiescent-session-snapshot.js'; import type { PackQuiescentSessionBundleInput } from '../production-session-snapshot.js'; -import type { SessionBundleLimits } from '../session-bundle-contract.js'; +import type { SessionBundleFileService, SessionBundleLimits } from '../session-bundle-contract.js'; import { createSessionBundleFileService } from '../session-bundle-file-service.js'; import { createSessionStore } from '../session-store.js'; @@ -73,6 +73,7 @@ test('prepares real Session state and a policy-filtered workspace, then packs an const service = await fixture.createService(); const archivePath = join(fixture.root, 'bundle.tar.zst'); const artifact = await service.pack({ destination: archivePath }); + assert.deepEqual(artifact.snapshotCleanup, { state: 'released' }); const hydratedRoot = join(fixture.root, 'hydrated'); const hydrated = await createSessionBundleFileService().hydrate({ @@ -227,6 +228,67 @@ test('reserves state entries before admitting workspace entries', async () => { } }); +test('returns the written Bundle and reports recoverable staging cleanup failure', async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'README.md'), 'durable artifact\n'); + const codec = createSessionBundleFileService(); + const bundleFileService: SessionBundleFileService = { + async pack(input) { + const artifact = await codec.pack(input); + const snapshotRoot = dirname(input.snapshot.stateRoot); + await rename(snapshotRoot, `${snapshotRoot}.displaced`); + await mkdir(snapshotRoot, { mode: 0o700 }); + await writeFile(join(snapshotRoot, 'unrelated.txt'), 'do not remove\n'); + return artifact; + }, + inspect: codec.inspect.bind(codec), + hydrate: codec.hydrate.bind(codec), + cleanupHydrationStaging: codec.cleanupHydrationStaging.bind(codec), + }; + const service = await fixture.createService({ bundleFileService }); + const archivePath = join(fixture.root, 'bundle-with-pending-cleanup.tar.zst'); + + const artifact = await service.pack({ destination: archivePath }); + + assert.equal((await readFile(archivePath)).byteLength > 0, true); + const inspection = await codec.inspect({ + source: { path: archivePath, expectedArchiveDigest: artifact.archiveDigest }, + limits, + }); + assert.equal(inspection.verified, true); + assert.equal(artifact.snapshotCleanup.state, 'pending_recovery'); + if (artifact.snapshotCleanup.state === 'pending_recovery') { + assert.equal(artifact.snapshotCleanup.error.code, 'cleanup_failed'); + assert.deepEqual(artifact.snapshotCleanup.error.details, { phase: 'cleanup' }); + } + } finally { + await fixture.close(); + } +}); + +test('rejects a POSIX-only workspace name with a bounded portability diagnostic', { + skip: process.platform === 'win32', +}, async () => { + const fixture = await createFixture(); + try { + await writeFile(join(fixture.workspaceRoot, 'name.'), 'not portable\n'); + const service = await fixture.createService(); + await assert.rejects(service.prepare({}), (error) => { + assert.ok(error instanceof SessionSnapshotError); + assert.equal(error.code, 'unsafe_source'); + assert.deepEqual(error.details, { + phase: 'workspace', + policyCategory: 'unsupported_portable_path', + observed: 1, + }); + return true; + }); + } finally { + await fixture.close(); + } +}); + async function createFixture(): Promise<{ readonly root: string; readonly stateRoot: string; @@ -242,6 +304,7 @@ async function createFixture(): Promise<{ readonly cleanupStateRoot?: string; readonly limits?: SessionBundleLimits; readonly confirmationAuthority?: SessionSnapshotWorkspaceConfirmationAuthority; + readonly bundleFileService?: SessionBundleFileService; }) => ReturnType; close(): Promise; }> { @@ -289,6 +352,7 @@ async function createFixture(): Promise<{ quiescence: immediateQuiescence, limits: overrides.limits ?? limits, confirmationAuthority: overrides.confirmationAuthority, + bundleFileService: overrides.bundleFileService, }), async close(): Promise { await owner.close(); diff --git a/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts b/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts index 11a763f231..0a5ff08694 100644 --- a/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts +++ b/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts @@ -815,11 +815,11 @@ test('V1 workspace policy includes portable inputs, excludes rebuildable data, a ], ['../escape', 'file', { kind: 'reject', category: 'unsafe_path' }], ['a\\b', 'file', { kind: 'reject', category: 'unsafe_path' }], - ['CON', 'file', { kind: 'reject', category: 'unsafe_path' }], - ['nested/LPT1.txt', 'file', { kind: 'reject', category: 'unsafe_path' }], - ['foo:bar', 'file', { kind: 'reject', category: 'unsafe_path' }], - ['name.', 'file', { kind: 'reject', category: 'unsafe_path' }], - ['name ', 'directory', { kind: 'reject', category: 'unsafe_path' }], + ['CON', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['nested/LPT1.txt', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['foo:bar', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['name.', 'file', { kind: 'reject', category: 'unsupported_portable_path' }], + ['name ', 'directory', { kind: 'reject', category: 'unsupported_portable_path' }], ] as const; for (const [relativePath, kind, expected] of cases) { diff --git a/packages/storage/src/production-session-snapshot.ts b/packages/storage/src/production-session-snapshot.ts index dfedcf0273..42f2fa4212 100644 --- a/packages/storage/src/production-session-snapshot.ts +++ b/packages/storage/src/production-session-snapshot.ts @@ -221,6 +221,25 @@ export interface PackQuiescentSessionBundleInput { readonly deadlineAt?: number; } +/** + * Cleanup of the private staging copy after the immutable Bundle is written. + * A pending cleanup does not invalidate the already-written Bundle; callers + * should record the failure. Its persisted lease becomes eligible for recovery + * after the current process lifetime ends. + */ +export type SessionSnapshotPackCleanup = + | { readonly state: 'released' } + | { readonly state: 'pending_recovery'; readonly error: SessionSnapshotError }; + +/** + * A production Bundle artifact plus the status of its separate private-staging + * cleanup. The artifact fields remain available at the top level so existing + * consumers can use it as an ordinary SessionBundleArtifact. + */ +export interface ProductionSessionBundleArtifact extends SessionBundleArtifact { + readonly snapshotCleanup: SessionSnapshotPackCleanup; +} + export interface FileProductionSessionSnapshotService { recover(): Promise; prepare(input: { @@ -228,7 +247,7 @@ export interface FileProductionSessionSnapshotService { readonly signal?: AbortSignal; readonly deadlineAt?: number; }): Promise; - pack(input: PackQuiescentSessionBundleInput): Promise; + pack(input: PackQuiescentSessionBundleInput): Promise; } /** @@ -315,7 +334,7 @@ export async function createFileProductionSessionSnapshotService( return Object.freeze({ recover: () => stagingCleanup.recover(), prepare, - async pack(input: PackQuiescentSessionBundleInput): Promise { + async pack(input: PackQuiescentSessionBundleInput): Promise { const prepared = await prepare(input); let artifact: SessionBundleArtifact | undefined; let primaryFailure: unknown; @@ -334,24 +353,34 @@ export async function createFileProductionSessionSnapshotService( } catch (error) { primaryFailure = error; } + let cleanup: SessionSnapshotPackCleanup = { state: 'released' }; try { await prepared.release(); } catch (cleanupFailure) { + const error = normalizePackCleanupFailure(cleanupFailure); if (primaryFailure !== undefined) { throw new AggregateError( - [primaryFailure, cleanupFailure], + [primaryFailure, error], 'Session Bundle packing failed and snapshot cleanup also failed', ); } - throw cleanupFailure; + cleanup = { state: 'pending_recovery', error }; } if (primaryFailure !== undefined) throw primaryFailure; if (!artifact) throw new Error('Session Bundle packing completed without an artifact'); - return artifact; + return Object.freeze({ ...artifact, snapshotCleanup: Object.freeze(cleanup) }); }, }); } +function normalizePackCleanupFailure(error: unknown): SessionSnapshotError { + if (error instanceof SessionSnapshotError && error.code === 'cleanup_failed') return error; + return new SessionSnapshotError('cleanup_failed', 'Session snapshot cleanup failed', { + cause: error, + details: { phase: 'cleanup' }, + }); +} + type WorkspaceCopyBudget = { includedEntries: number; excludedEntries: number; @@ -616,9 +645,17 @@ function assertWorkspacePathBudget( ): void { const archivePath = `workspace/${relativePath}${kind === 'directory' ? '/' : ''}`; if (!isSessionBundleUstarPathV1(archivePath)) { - throw new SessionSnapshotError('unsafe_source', 'Workspace contains an unsafe path', { - details: { phase: 'workspace', policyCategory: 'unsafe_path' }, - }); + throw new SessionSnapshotError( + 'unsafe_source', + 'Workspace path is not portable in Session Bundles', + { + details: { + phase: 'workspace', + policyCategory: 'unsupported_portable_path', + observed: 1, + }, + }, + ); } if ( Buffer.byteLength(archivePath, 'utf8') > limits.maxPathBytes || diff --git a/packages/storage/src/quiescent-session-snapshot.ts b/packages/storage/src/quiescent-session-snapshot.ts index 26fe4efb37..f13c684f59 100644 --- a/packages/storage/src/quiescent-session-snapshot.ts +++ b/packages/storage/src/quiescent-session-snapshot.ts @@ -58,6 +58,7 @@ export type SessionSnapshotWorkspaceConfirmationCategory = 'suspected_secret_pat export type SessionSnapshotWorkspaceRejectionCategory = | 'known_secret_file' | 'unsafe_path' + | 'unsupported_portable_path' | 'unsupported_entry'; export type SessionSnapshotWorkspacePolicyDecision = @@ -834,11 +835,12 @@ class OwnedPreparedSessionBundleHandle implements PreparedSessionBundleHandle { } } -function decodeWorkspaceEntry( - entry: SessionSnapshotWorkspaceEntry, -): +function decodeWorkspaceEntry(entry: SessionSnapshotWorkspaceEntry): | { readonly kind: 'valid'; readonly segments: readonly string[]; readonly basename: string } - | { readonly kind: 'reject'; readonly category: 'unsafe_path' | 'unsupported_entry' } { + | { + readonly kind: 'reject'; + readonly category: 'unsafe_path' | 'unsupported_portable_path' | 'unsupported_entry'; + } { if (entry.kind !== 'file' && entry.kind !== 'directory') { return { kind: 'reject', category: 'unsupported_entry' }; } @@ -858,7 +860,7 @@ function decodeWorkspaceEntry( } const bundlePath = `workspace/${entry.relativePath}${entry.kind === 'directory' ? '/' : ''}`; if (!isSessionBundleUstarPathV1(bundlePath)) { - return { kind: 'reject', category: 'unsafe_path' }; + return { kind: 'reject', category: 'unsupported_portable_path' }; } return { kind: 'valid', segments, basename: segments.at(-1)! }; }