Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/windows-test-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'` |
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/src/__tests__/quiescent-session-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 4 additions & 8 deletions packages/runtime/src/quiescent-session-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 67 additions & 3 deletions packages/storage/src/__tests__/production-session-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand All @@ -242,6 +304,7 @@ async function createFixture(): Promise<{
readonly cleanupStateRoot?: string;
readonly limits?: SessionBundleLimits;
readonly confirmationAuthority?: SessionSnapshotWorkspaceConfirmationAuthority;
readonly bundleFileService?: SessionBundleFileService;
}) => ReturnType<typeof createFileProductionSessionSnapshotService>;
close(): Promise<void>;
}> {
Expand Down Expand Up @@ -289,6 +352,7 @@ async function createFixture(): Promise<{
quiescence: immediateQuiescence,
limits: overrides.limits ?? limits,
confirmationAuthority: overrides.confirmationAuthority,
bundleFileService: overrides.bundleFileService,
}),
async close(): Promise<void> {
await owner.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 45 additions & 8 deletions packages/storage/src/production-session-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,33 @@ 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<SessionSnapshotStagingCleanupRecovery>;
prepare(input: {
readonly confirmationGrantId?: string;
readonly signal?: AbortSignal;
readonly deadlineAt?: number;
}): Promise<PreparedSessionBundleHandle>;
pack(input: PackQuiescentSessionBundleInput): Promise<SessionBundleArtifact>;
pack(input: PackQuiescentSessionBundleInput): Promise<ProductionSessionBundleArtifact>;
}

/**
Expand Down Expand Up @@ -315,7 +334,7 @@ export async function createFileProductionSessionSnapshotService(
return Object.freeze({
recover: () => stagingCleanup.recover(),
prepare,
async pack(input: PackQuiescentSessionBundleInput): Promise<SessionBundleArtifact> {
async pack(input: PackQuiescentSessionBundleInput): Promise<ProductionSessionBundleArtifact> {
const prepared = await prepare(input);
let artifact: SessionBundleArtifact | undefined;
let primaryFailure: unknown;
Expand All @@ -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;
Expand Down Expand Up @@ -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 ||
Expand Down
12 changes: 7 additions & 5 deletions packages/storage/src/quiescent-session-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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' };
}
Expand All @@ -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)! };
}
Expand Down