blob: e2dce38a29fa80a1b1efb0cc371040a1299ae8e2 [file]
import {
copyFile,
lstat,
mkdir,
readFile,
readdir,
realpath,
rm,
rename,
writeFile,
} from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import type { RuntimeEvent } from '@maka/core';
import type { ArtifactRecord } from '@maka/core/artifacts';
import {
ARTIFACT_PUBLICATION_STAGING_PATTERN,
ARTIFACT_PURGE_INTENT_FILE,
ARTIFACT_WRITER_LOCK_FILE,
isCanonicalArtifactRecoveryTempName,
} from './artifact-storage-layout.js';
import { decodeArtifactMetadata } from './artifact-metadata-codec.js';
import { withArtifactWriterLock } from './artifact-writer-lock.js';
import { exportLegacySessionTree } from './session-metadata-maintenance.js';
import { SQLITE_SESSION_METADATA_DATABASE_NAME } from './session-store.js';
import { createSqliteRuntimeStore } from './sqlite-runtime-store.js';
/**
* The first bundle slice is deliberately an uncompressed state-tree export.
* Compression, manifests, and activation inputs belong to later bundle work;
* this module owns the trust boundary that all of those consumers must use.
*/
export const SESSION_BUNDLE_STATE_ENTRIES = ['sessions', 'artifacts', 'runtime.sqlite'] as const;
export const SESSION_BUNDLE_PORTABLE_SESSION_DIRECTORIES = [
'deep-research',
'projections',
'runs',
'shell-runs',
'turn-admissions',
] as const;
export const SESSION_BUNDLE_PORTABLE_SESSION_FILES = [
'agent-mailbox.jsonl',
'plan-events.jsonl',
'plans.json',
'task-events.jsonl',
'tasks.json',
] as const;
export const SESSION_BUNDLE_PROTECTED_ENTRIES = [
'.maka-storage-root.json',
'.maka_cli_claude_device_id',
'credentials.json',
'llm-connections.json',
'settings.json',
'automations.json',
'mcp.json',
'skills',
'memory',
'daily-reviews',
'logs',
'log',
'activation.json',
'activation-input.json',
'.maka',
ARTIFACT_WRITER_LOCK_FILE,
SQLITE_SESSION_METADATA_DATABASE_NAME,
`${SQLITE_SESSION_METADATA_DATABASE_NAME}-wal`,
`${SQLITE_SESSION_METADATA_DATABASE_NAME}-shm`,
`${SQLITE_SESSION_METADATA_DATABASE_NAME}-journal`,
'runtime.sqlite-wal',
'runtime.sqlite-shm',
'runtime.sqlite-journal',
] as const;
const allowedEntries = new Set<string>(SESSION_BUNDLE_STATE_ENTRIES);
const protectedEntries = new Set<string>(SESSION_BUNDLE_PROTECTED_ENTRIES);
const portableSessionDirectories = new Set<string>(SESSION_BUNDLE_PORTABLE_SESSION_DIRECTORIES);
const portableSessionFiles = new Set<string>(SESSION_BUNDLE_PORTABLE_SESSION_FILES);
export type SessionBundleExportErrorCode =
| 'invalid_root'
| 'overlapping_roots'
| 'symlink'
| 'path_escape'
| 'unknown_entry'
| 'unsupported_entry'
| 'destination_not_empty';
export class SessionBundleExportError extends Error {
constructor(
readonly code: SessionBundleExportErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'SessionBundleExportError';
}
}
export interface SessionBundleRootLayoutInput {
stateRoot: string;
configRoot: string;
/** Legacy callers intentionally use one shared root. */
allowShared?: boolean;
}
export interface SessionBundleExportPlanEntry {
relativePath: string;
kind: 'file' | 'directory';
source:
| 'copy'
| 'selected_session_metadata'
| 'filtered_artifact_metadata'
| 'filtered_runtime_sqlite';
}
export interface SessionBundleExportPlan {
stateRoot: string;
configRoot: string;
destinationRoot: string;
sessionId: string;
includedEntries: string[];
excludedEntries: string[];
entries: SessionBundleExportPlanEntry[];
}
export interface SessionBundleExportInput extends SessionBundleRootLayoutInput {
destinationRoot: string;
sessionId: string;
}
interface ArtifactMetadataSnapshot {
artifactRootExists: boolean;
metadataExists: boolean;
canonicalText: string;
selectedRecords: ArtifactRecord[];
filteredText: string;
}
interface PreparedSessionBundleExport {
plan: SessionBundleExportPlan;
artifactMetadata: ArtifactMetadataSnapshot;
requestedDestinationRoot: string;
stateRootIdentity: SessionBundleRootIdentity;
}
interface SessionBundleRoots {
stateRoot: string;
stateRootIdentity: SessionBundleRootIdentity;
configRoot: string;
destinationRoot: string;
}
interface SessionBundleRootIdentity {
dev: bigint;
ino: bigint;
}
/**
* Validate the state/config split. Identical roots are accepted only for the
* legacy compatibility path; nested roots are never safe because one root
* could silently contain the other root's protected material.
*/
export async function assertSessionBundleRootLayout(
input: SessionBundleRootLayoutInput,
): Promise<void> {
const stateRoot = await canonicalizeExistingOrMissingRoot(input.stateRoot, 'state');
const configRoot = await canonicalizeExistingOrMissingRoot(input.configRoot, 'config');
assertRootsDoNotOverlap(stateRoot, configRoot, input.allowShared === true);
}
/**
* Build an auditable export plan. Only the three session-owned top-level
* entries are eligible. Known host/config entries are recorded as excluded;
* any new top-level entry fails closed until it is explicitly classified.
*/
export async function planSessionBundleExport(
input: SessionBundleExportInput,
): Promise<SessionBundleExportPlan> {
return (await prepareSessionBundleExport(input)).plan;
}
async function prepareSessionBundleExport(
input: SessionBundleExportInput,
): Promise<PreparedSessionBundleExport> {
const { stateRoot, stateRootIdentity, configRoot, destinationRoot } =
await resolveSessionBundleRoots(input);
const includedEntries: string[] = [];
const excludedEntries: string[] = [];
const entries: SessionBundleExportPlanEntry[] = [];
const artifactMetadata = await readArtifactMetadataSnapshot(stateRoot, input.sessionId);
let sessionsClassified = false;
let artifactsClassified = false;
const topLevelEntries = await readdir(stateRoot, { withFileTypes: true });
for (const entry of topLevelEntries.sort((a, b) => a.name.localeCompare(b.name))) {
const sourcePath = resolve(stateRoot, entry.name);
assertNoSymlink(entry.isSymbolicLink(), entry.name);
if (entry.name === 'sessions') {
sessionsClassified = true;
await planSelectedSessionTree(
sourcePath,
input.sessionId,
stateRoot,
excludedEntries,
entries,
);
includedEntries.push(entry.name);
continue;
}
if (entry.name === 'artifacts') {
artifactsClassified = true;
await planSelectedArtifactTree(
sourcePath,
input.sessionId,
stateRoot,
excludedEntries,
entries,
artifactMetadata,
);
includedEntries.push(entry.name);
continue;
}
if (allowedEntries.has(entry.name)) {
includedEntries.push(entry.name);
if (entry.name === 'runtime.sqlite') {
await assertFile(sourcePath, 'runtime SQLite', stateRoot, entry.name);
entries.push({
relativePath: entry.name,
kind: 'file',
source: 'filtered_runtime_sqlite',
});
} else {
await inspectTree(sourcePath, entry.name, stateRoot, entries, 'copy');
}
continue;
}
if (isKnownProtectedEntry(entry.name)) {
excludedEntries.push(entry.name);
continue;
}
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified top-level entry: ${entry.name}`,
);
}
if (!sessionsClassified) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle state root has no sessions tree: ${stateRoot}`,
);
}
if (artifactsClassified !== artifactMetadata.artifactRootExists) {
throw artifactMetadataChanged();
}
return {
plan: {
stateRoot,
configRoot,
destinationRoot,
sessionId: input.sessionId,
includedEntries,
excludedEntries,
entries,
},
artifactMetadata,
requestedDestinationRoot: input.destinationRoot,
stateRootIdentity,
};
}
async function resolveSessionBundleRoots(
input: SessionBundleExportInput,
): Promise<SessionBundleRoots> {
assertSafeSessionId(input.sessionId);
const stateRoot = await canonicalizeExistingRoot(input.stateRoot, 'state');
const stateRootIdentity = await readSessionBundleRootIdentity(stateRoot);
const configRoot = await canonicalizeExistingOrMissingRoot(input.configRoot, 'config');
assertRootsDoNotOverlap(stateRoot, configRoot, input.allowShared === true);
const destinationRoot = await canonicalizeExistingOrMissingRoot(
input.destinationRoot,
'destination',
);
assertRootsDoNotOverlap(stateRoot, destinationRoot, false);
assertRootsDoNotOverlap(configRoot, destinationRoot, false);
return { stateRoot, stateRootIdentity, configRoot, destinationRoot };
}
/**
* Copy the planned state tree into an empty destination. This intentionally
* does not create an archive; callers can add compression around this stable,
* checked tree contract later without changing the policy.
*/
export async function exportSessionBundleState(
input: SessionBundleExportInput,
): Promise<SessionBundleExportPlan> {
const preflight = await resolveSessionBundleRoots(input);
// A session_file reference is committed only after its Artifact create
// returns. Keep Artifact mutations fenced through session projection so a
// create cannot land between the Artifact snapshot and a copied reference.
return withArtifactWriterLock(preflight.stateRoot, async () => {
await assertSessionBundleRootIdentity(preflight.stateRoot, preflight.stateRootIdentity);
const current = await prepareSessionBundleExport(input);
if (
current.plan.stateRoot !== preflight.stateRoot ||
!sameSessionBundleRootIdentity(current.stateRootIdentity, preflight.stateRootIdentity)
) {
throw new SessionBundleExportError(
'invalid_root',
'Session bundle state root changed before Artifact snapshot export',
);
}
await ensureEmptyDestination(current.requestedDestinationRoot, current.plan.destinationRoot);
await assertSessionBundleRootIdentity(preflight.stateRoot, preflight.stateRootIdentity);
await exportPreparedArtifactState(current);
await assertSessionBundleRootIdentity(preflight.stateRoot, preflight.stateRootIdentity);
await exportPreparedNonArtifactState(current.plan);
await assertSessionBundleRootIdentity(preflight.stateRoot, preflight.stateRootIdentity);
return current.plan;
});
}
async function exportPreparedArtifactState(prepared: PreparedSessionBundleExport): Promise<void> {
const { plan, artifactMetadata } = prepared;
const artifactEntries = plan.entries.filter((entry) => isArtifactEntry(entry));
const directories = artifactEntries.filter((entry) => entry.kind === 'directory');
const files = artifactEntries.filter((entry) => entry.kind === 'file' && entry.source === 'copy');
for (const entry of directories) {
await mkdir(resolve(plan.destinationRoot, entry.relativePath), { recursive: true });
}
for (const entry of files) {
const sourcePath = resolve(plan.stateRoot, entry.relativePath);
const destinationPath = resolve(plan.destinationRoot, entry.relativePath);
await mkdir(dirname(destinationPath), { recursive: true });
await copyCheckedFile(sourcePath, destinationPath, plan.stateRoot, entry.relativePath);
}
for (const entry of artifactEntries) {
if (entry.source === 'filtered_artifact_metadata') {
await exportFilteredArtifactMetadata(plan, entry, artifactMetadata);
}
}
await assertNoCanonicalArtifactTransactionResidue(plan.stateRoot);
await assertArtifactMetadataSnapshotUnchanged(plan.stateRoot, artifactMetadata);
}
async function exportPreparedNonArtifactState(plan: SessionBundleExportPlan): Promise<void> {
const entries = plan.entries.filter((entry) => !isArtifactEntry(entry));
const directories = entries.filter((entry) => entry.kind === 'directory');
const files = entries.filter((entry) => entry.kind === 'file' && entry.source === 'copy');
for (const entry of directories) {
await mkdir(resolve(plan.destinationRoot, entry.relativePath), { recursive: true });
}
for (const entry of files) {
const sourcePath = resolve(plan.stateRoot, entry.relativePath);
const destinationPath = resolve(plan.destinationRoot, entry.relativePath);
await mkdir(dirname(destinationPath), { recursive: true });
await copyCheckedFile(sourcePath, destinationPath, plan.stateRoot, entry.relativePath);
}
for (const entry of entries) {
if (entry.source === 'selected_session_metadata') {
await exportSelectedSessionMetadata(plan);
} else if (entry.source === 'filtered_runtime_sqlite') {
await exportFilteredRuntimeSqlite(plan, entry);
}
}
}
function isArtifactEntry(entry: SessionBundleExportPlanEntry): boolean {
return entry.relativePath === 'artifacts' || entry.relativePath.startsWith('artifacts/');
}
async function planSelectedSessionTree(
sourcePath: string,
sessionId: string,
stateRoot: string,
excludedEntries: string[],
entries: SessionBundleExportPlanEntry[],
): Promise<void> {
await assertDirectory(sourcePath, 'sessions root', stateRoot, 'sessions');
entries.push({ relativePath: 'sessions', kind: 'directory', source: 'copy' });
let selectedSessionFound = false;
for (const entry of (await readdir(sourcePath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name),
)) {
const childPath = resolve(sourcePath, entry.name);
assertNoSymlink(entry.isSymbolicLink(), `sessions/${entry.name}`);
if (entry.name === sessionId) {
selectedSessionFound = true;
await assertDirectory(childPath, `session ${sessionId}`, stateRoot, `sessions/${sessionId}`);
const transcriptPath = resolve(childPath, 'session.jsonl');
await assertFile(
transcriptPath,
`session ${sessionId} transcript`,
stateRoot,
`sessions/${sessionId}/session.jsonl`,
);
const children = await readdir(childPath, { withFileTypes: true });
for (const child of children) {
const relativePath = `sessions/${sessionId}/${child.name}`;
assertNoSymlink(child.isSymbolicLink(), relativePath);
if (child.name === 'session.jsonl') continue;
const portablePath = resolve(childPath, child.name);
if (portableSessionDirectories.has(child.name)) {
await assertDirectory(
portablePath,
`portable session entry ${child.name}`,
stateRoot,
relativePath,
);
await inspectTree(portablePath, relativePath, stateRoot, entries, 'copy');
continue;
}
if (portableSessionFiles.has(child.name)) {
await assertFile(
portablePath,
`portable session entry ${child.name}`,
stateRoot,
relativePath,
);
entries.push({ relativePath, kind: 'file', source: 'copy' });
continue;
}
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified selected-session entry: ${relativePath}`,
);
}
entries.push({
relativePath: `sessions/${sessionId}`,
kind: 'directory',
source: 'copy',
});
entries.push({
relativePath: `sessions/${sessionId}/session.jsonl`,
kind: 'file',
source: 'selected_session_metadata',
});
continue;
}
if (entry.isDirectory() && isSafeSessionId(entry.name)) {
excludedEntries.push(`sessions/${entry.name}`);
continue;
}
if (isKnownProtectedEntry(entry.name)) {
excludedEntries.push(`sessions/${entry.name}`);
continue;
}
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified sessions entry: ${entry.name}`,
);
}
if (!selectedSessionFound) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle session does not exist: ${sessionId}`,
);
}
}
async function exportSelectedSessionMetadata(plan: SessionBundleExportPlan): Promise<void> {
const stagingRoot = `${plan.destinationRoot}.selected-session-${randomUUID()}`;
try {
await exportLegacySessionTree({
workspaceRoot: plan.stateRoot,
destinationRoot: stagingRoot,
sessionIds: [plan.sessionId],
});
const relativePath = `sessions/${plan.sessionId}/session.jsonl`;
const sourcePath = resolve(stagingRoot, relativePath);
const destinationPath = resolve(plan.destinationRoot, relativePath);
await mkdir(dirname(destinationPath), { recursive: true });
await copyCheckedFile(sourcePath, destinationPath, stagingRoot, relativePath);
} finally {
await rm(stagingRoot, { recursive: true, force: true }).catch(() => {});
}
}
async function planSelectedArtifactTree(
sourcePath: string,
sessionId: string,
stateRoot: string,
excludedEntries: string[],
entries: SessionBundleExportPlanEntry[],
artifactMetadata: ArtifactMetadataSnapshot,
): Promise<void> {
if (!artifactMetadata.artifactRootExists) throw artifactMetadataChanged();
await assertDirectory(sourcePath, 'artifacts root', stateRoot, 'artifacts');
entries.push({ relativePath: 'artifacts', kind: 'directory', source: 'copy' });
let metadataClassified = false;
let selectedSessionDirectoryClassified = false;
for (const entry of (await readdir(sourcePath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name),
)) {
const childPath = resolve(sourcePath, entry.name);
assertNoSymlink(entry.isSymbolicLink(), `artifacts/${entry.name}`);
if (entry.name === sessionId) {
selectedSessionDirectoryClassified = true;
await assertDirectory(
childPath,
`artifacts for session ${sessionId}`,
stateRoot,
`artifacts/${sessionId}`,
);
await planSelectedArtifactSessionTree(
childPath,
sessionId,
stateRoot,
entries,
artifactMetadata.selectedRecords,
);
continue;
}
if (entry.name === 'metadata.jsonl') {
metadataClassified = true;
if (!artifactMetadata.metadataExists) throw artifactMetadataChanged();
entries.push({
relativePath: 'artifacts/metadata.jsonl',
kind: 'file',
source: 'filtered_artifact_metadata',
});
continue;
}
if (
entry.name === ARTIFACT_PURGE_INTENT_FILE ||
isCanonicalArtifactRecoveryTempName(entry.name)
) {
throwArtifactRecoveryRequired(`artifacts/${entry.name}`);
}
if (entry.isDirectory() && isSafeSessionId(entry.name)) {
await assertNoArtifactPublicationResidue(childPath, entry.name);
excludedEntries.push(`artifacts/${entry.name}`);
continue;
}
if (isKnownProtectedEntry(entry.name)) {
excludedEntries.push(`artifacts/${entry.name}`);
continue;
}
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified artifacts entry: ${entry.name}`,
);
}
if (metadataClassified !== artifactMetadata.metadataExists) throw artifactMetadataChanged();
if (!selectedSessionDirectoryClassified && artifactMetadata.selectedRecords.length > 0) {
throw new SessionBundleExportError(
'invalid_root',
`Artifact metadata references a missing selected-session payload directory: artifacts/${sessionId}`,
);
}
}
async function planSelectedArtifactSessionTree(
sourcePath: string,
sessionId: string,
stateRoot: string,
entries: SessionBundleExportPlanEntry[],
selectedRecords: readonly ArtifactRecord[],
): Promise<void> {
const sessionRelativePath = `artifacts/${sessionId}`;
const expectedByName = new Map(
selectedRecords.map((record) => [basename(record.relativePath), record] as const),
);
const foundExpectedNames = new Set<string>();
for (const entry of (await readdir(sourcePath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name),
)) {
const relativePath = `${sessionRelativePath}/${entry.name}`;
assertNoSymlink(entry.isSymbolicLink(), relativePath);
assertArtifactPublicationEntryIsPortable(entry.name, relativePath);
const expected = expectedByName.get(entry.name);
if (expected) {
await assertFile(
resolve(sourcePath, entry.name),
`artifact payload ${expected.id}`,
stateRoot,
relativePath,
);
foundExpectedNames.add(entry.name);
continue;
}
if (entry.isFile()) {
throwArtifactRecoveryRequired(relativePath);
}
if (entry.isDirectory()) {
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified selected-session artifact entry: ${relativePath}`,
);
}
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle export cannot classify selected-session artifact entry: ${relativePath}`,
);
}
for (const [name, record] of expectedByName) {
if (foundExpectedNames.has(name)) continue;
throw new SessionBundleExportError(
'invalid_root',
`Artifact metadata references a missing payload: artifacts/${record.relativePath}`,
);
}
if (selectedRecords.length === 0) return;
entries.push({ relativePath: sessionRelativePath, kind: 'directory', source: 'copy' });
for (const record of selectedRecords) {
entries.push({
relativePath: `artifacts/${record.relativePath}`,
kind: 'file',
source: 'copy',
});
}
}
async function assertNoArtifactPublicationResidue(
sessionArtifactRoot: string,
sessionId: string,
): Promise<void> {
for (const entry of await readdir(sessionArtifactRoot, { withFileTypes: true })) {
if (!entry.name.startsWith('.artifact-publish.')) continue;
const relativePath = `artifacts/${sessionId}/${entry.name}`;
assertNoSymlink(entry.isSymbolicLink(), relativePath);
assertArtifactPublicationEntryIsPortable(entry.name, relativePath);
}
}
async function assertNoCanonicalArtifactTransactionResidue(stateRoot: string): Promise<void> {
const artifactRoot = resolve(stateRoot, 'artifacts');
let rootEntries;
try {
rootEntries = await readdir(artifactRoot, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
for (const entry of rootEntries) {
const relativePath = `artifacts/${entry.name}`;
assertNoSymlink(entry.isSymbolicLink(), relativePath);
if (
entry.name === ARTIFACT_PURGE_INTENT_FILE ||
isCanonicalArtifactRecoveryTempName(entry.name)
) {
throwArtifactRecoveryRequired(relativePath);
}
if (entry.isDirectory() && isSafeSessionId(entry.name)) {
await assertNoArtifactPublicationResidue(resolve(artifactRoot, entry.name), entry.name);
}
}
}
function assertArtifactPublicationEntryIsPortable(entryName: string, relativePath: string): void {
if (ARTIFACT_PUBLICATION_STAGING_PATTERN.test(entryName)) {
throwArtifactRecoveryRequired(relativePath);
}
if (entryName.startsWith('.artifact-publish.')) {
throw new SessionBundleExportError(
'unknown_entry',
`Session bundle export encountered an unclassified artifact entry: ${relativePath}`,
);
}
}
function throwArtifactRecoveryRequired(relativePath: string): never {
throw new SessionBundleExportError(
'unsupported_entry',
`Artifact write authority recovery is required before session bundle export: ${relativePath}`,
);
}
async function readArtifactMetadataSnapshot(
stateRoot: string,
sessionId: string,
): Promise<ArtifactMetadataSnapshot> {
const source = await readArtifactMetadataSource(stateRoot);
let records: ArtifactRecord[];
try {
records = decodeArtifactMetadata(source.canonicalText);
} catch (error) {
throw new SessionBundleExportError(
'unsupported_entry',
'Artifact metadata cannot be reopened by the Artifact store',
{ cause: error },
);
}
const selectedRecords = records.filter((record) => record.sessionId === sessionId);
return {
...source,
selectedRecords,
filteredText:
selectedRecords.length > 0
? `${selectedRecords.map((record) => JSON.stringify(record)).join('\n')}\n`
: '',
};
}
async function readArtifactMetadataSource(
stateRoot: string,
): Promise<
Pick<ArtifactMetadataSnapshot, 'artifactRootExists' | 'metadataExists' | 'canonicalText'>
> {
const artifactRoot = resolve(stateRoot, 'artifacts');
let artifactRootMetadata;
try {
artifactRootMetadata = await lstat(artifactRoot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { artifactRootExists: false, metadataExists: false, canonicalText: '' };
}
throw error;
}
assertNoSymlink(artifactRootMetadata.isSymbolicLink(), 'artifacts');
await assertCanonicalPathInside(artifactRoot, stateRoot, 'artifacts');
if (!artifactRootMetadata.isDirectory()) {
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle artifacts root is not a directory: ${artifactRoot}`,
);
}
const metadataPath = resolve(artifactRoot, 'metadata.jsonl');
let metadata;
try {
metadata = await lstat(metadataPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { artifactRootExists: true, metadataExists: false, canonicalText: '' };
}
throw error;
}
assertNoSymlink(metadata.isSymbolicLink(), 'artifacts/metadata.jsonl');
await assertCanonicalPathInside(metadataPath, stateRoot, 'artifacts/metadata.jsonl');
if (!metadata.isFile()) {
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle artifact metadata is not a regular file: ${metadataPath}`,
);
}
return {
artifactRootExists: true,
metadataExists: true,
canonicalText: await readFile(metadataPath, 'utf8'),
};
}
async function assertArtifactMetadataSnapshotUnchanged(
stateRoot: string,
snapshot: ArtifactMetadataSnapshot,
): Promise<void> {
const current = await readArtifactMetadataSource(stateRoot);
if (
current.artifactRootExists !== snapshot.artifactRootExists ||
current.metadataExists !== snapshot.metadataExists ||
current.canonicalText !== snapshot.canonicalText
) {
throw artifactMetadataChanged();
}
}
function artifactMetadataChanged(): SessionBundleExportError {
return new SessionBundleExportError(
'unsupported_entry',
'Artifact metadata changed during session bundle export',
);
}
async function assertDirectory(
path: string,
role: string,
stateRoot: string,
relativePath: string,
): Promise<void> {
let metadata;
try {
metadata = await lstat(path);
} catch (error) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} does not exist: ${path}`,
{ cause: error },
);
}
assertNoSymlink(metadata.isSymbolicLink(), relativePath);
await assertCanonicalPathInside(path, stateRoot, relativePath);
if (!metadata.isDirectory()) {
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle ${role} is not a directory: ${path}`,
);
}
}
async function assertFile(
path: string,
role: string,
stateRoot: string,
relativePath: string,
): Promise<void> {
let metadata;
try {
metadata = await lstat(path);
} catch (error) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} does not exist: ${path}`,
{ cause: error },
);
}
assertNoSymlink(metadata.isSymbolicLink(), relativePath);
await assertCanonicalPathInside(path, stateRoot, relativePath);
if (!metadata.isFile()) {
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle ${role} is not a regular file: ${path}`,
);
}
}
async function inspectTree(
sourcePath: string,
relativePath: string,
stateRoot: string,
entries: SessionBundleExportPlanEntry[],
source: SessionBundleExportPlanEntry['source'],
): Promise<void> {
const metadata = await lstat(sourcePath);
assertNoSymlink(metadata.isSymbolicLink(), relativePath);
await assertCanonicalPathInside(sourcePath, stateRoot, relativePath);
if (metadata.isDirectory()) {
entries.push({ relativePath, kind: 'directory', source: 'copy' });
for (const child of (await readdir(sourcePath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name),
)) {
await inspectTree(
resolve(sourcePath, child.name),
`${relativePath}/${child.name}`,
stateRoot,
entries,
'copy',
);
}
return;
}
if (metadata.isFile()) {
entries.push({ relativePath, kind: 'file', source });
return;
}
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle export cannot include special file: ${relativePath}`,
);
}
async function copyCheckedFile(
sourcePath: string,
destinationPath: string,
stateRoot: string,
relativePath: string,
): Promise<void> {
const metadata = await lstat(sourcePath);
assertNoSymlink(metadata.isSymbolicLink(), relativePath);
await assertCanonicalPathInside(sourcePath, stateRoot, relativePath);
if (!metadata.isFile()) {
throw new SessionBundleExportError(
'unsupported_entry',
`Session bundle export source changed before copy: ${relativePath}`,
);
}
await copyFile(sourcePath, destinationPath);
}
async function exportFilteredArtifactMetadata(
plan: SessionBundleExportPlan,
entry: SessionBundleExportPlanEntry,
snapshot: ArtifactMetadataSnapshot,
): Promise<void> {
const destinationPath = resolve(plan.destinationRoot, entry.relativePath);
await mkdir(dirname(destinationPath), { recursive: true });
await writeFile(destinationPath, snapshot.filteredText);
}
async function exportFilteredRuntimeSqlite(
plan: SessionBundleExportPlan,
entry: SessionBundleExportPlanEntry,
): Promise<void> {
const sourcePath = resolve(plan.stateRoot, entry.relativePath);
const destinationPath = resolve(plan.destinationRoot, entry.relativePath);
await assertFile(sourcePath, 'runtime SQLite', plan.stateRoot, entry.relativePath);
const source = createSqliteRuntimeStore(sourcePath);
const filteredPath = `${destinationPath}.filtered`;
try {
const destination = createSqliteRuntimeStore(filteredPath);
try {
const eventsByRun = new Map<
string,
{ sessionId: string; runId: string; events: RuntimeEvent[] }
>();
for (const event of await source.readSessionRuntimeEvents(plan.sessionId)) {
const batch = eventsByRun.get(event.runId) ?? {
sessionId: plan.sessionId,
runId: event.runId,
events: [],
};
batch.events.push(event);
eventsByRun.set(event.runId, batch);
}
for (const batch of eventsByRun.values()) await destination.importRuntimeEventsBatch(batch);
await destination.rebuildToolProjectionsFromRuntimeEvents();
} finally {
destination.close();
}
} finally {
source.close();
}
await rename(filteredPath, destinationPath);
}
async function ensureEmptyDestination(requestedPath: string, canonicalPath: string): Promise<void> {
let metadata;
try {
metadata = await lstat(resolve(requestedPath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
await mkdir(resolve(requestedPath), { recursive: true });
return;
}
assertNoSymlink(metadata.isSymbolicLink(), 'destination root');
if (!metadata.isDirectory()) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle destination is not a directory: ${requestedPath}`,
);
}
const entries = await readdir(canonicalPath);
if (entries.length > 0) {
throw new SessionBundleExportError(
'destination_not_empty',
`Session bundle export destination must be empty: ${requestedPath}`,
);
}
}
async function canonicalizeExistingRoot(path: string, role: string): Promise<string> {
const requestedPath = resolve(path);
let metadata;
try {
metadata = await lstat(requestedPath);
} catch (error) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} root does not exist: ${requestedPath}`,
{ cause: error },
);
}
assertNoSymlink(metadata.isSymbolicLink(), `${role} root`);
if (!metadata.isDirectory()) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} root is not a directory: ${requestedPath}`,
);
}
return realpath(requestedPath);
}
async function readSessionBundleRootIdentity(
stateRoot: string,
): Promise<SessionBundleRootIdentity> {
let metadata;
try {
metadata = await lstat(stateRoot, { bigint: true });
} catch (error) {
throw new SessionBundleExportError(
'invalid_root',
`Unable to inspect session bundle state root identity: ${stateRoot}`,
{ cause: error },
);
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle state root identity is not a directory: ${stateRoot}`,
);
}
return { dev: metadata.dev, ino: metadata.ino };
}
async function assertSessionBundleRootIdentity(
stateRoot: string,
expected: SessionBundleRootIdentity,
): Promise<void> {
const actual = await readSessionBundleRootIdentity(stateRoot);
if (!sameSessionBundleRootIdentity(actual, expected)) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle state root changed during export: ${stateRoot}`,
);
}
}
function sameSessionBundleRootIdentity(
left: SessionBundleRootIdentity,
right: SessionBundleRootIdentity,
): boolean {
return left.dev === right.dev && left.ino === right.ino;
}
async function canonicalizeExistingOrMissingRoot(path: string, role: string): Promise<string> {
const requestedPath = resolve(path);
let metadata;
try {
metadata = await lstat(requestedPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new SessionBundleExportError(
'invalid_root',
`Unable to inspect session bundle ${role} root: ${requestedPath}`,
{ cause: error },
);
}
const { parent, suffix } = await nearestExistingDirectoryWithSuffix(requestedPath, role);
return resolve(parent, ...suffix);
}
assertNoSymlink(metadata.isSymbolicLink(), `${role} root`);
if (!metadata.isDirectory()) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} root is not a directory: ${requestedPath}`,
);
}
return realpath(requestedPath);
}
async function nearestExistingDirectoryWithSuffix(
path: string,
role: string,
): Promise<{ parent: string; suffix: string[] }> {
let candidate = resolve(path);
const suffix: string[] = [];
while (true) {
try {
const metadata = await lstat(candidate);
assertNoSymlink(metadata.isSymbolicLink(), `${role} root parent`);
if (!metadata.isDirectory()) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle ${role} root parent is not a directory: ${candidate}`,
);
}
return { parent: await realpath(candidate), suffix: suffix.reverse() };
} catch (error) {
if (error instanceof SessionBundleExportError) throw error;
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new SessionBundleExportError(
'invalid_root',
`Unable to resolve session bundle ${role} root parent: ${candidate}`,
{ cause: error },
);
}
const parent = dirname(candidate);
if (parent === candidate) {
throw new SessionBundleExportError(
'invalid_root',
`Unable to resolve session bundle ${role} root parent: ${candidate}`,
);
}
suffix.push(basename(candidate));
candidate = parent;
}
}
}
function assertRootsDoNotOverlap(left: string, right: string, allowSame: boolean): void {
if (left === right) {
if (allowSame) return;
throw new SessionBundleExportError(
'overlapping_roots',
`Session bundle roots overlap at ${left}`,
);
}
if (isPathInside(left, right) || isPathInside(right, left)) {
throw new SessionBundleExportError(
'overlapping_roots',
`Session bundle roots overlap unsafely: ${left} and ${right}`,
);
}
}
async function assertCanonicalPathInside(
path: string,
root: string,
relativePath: string,
): Promise<void> {
let canonicalPath: string;
try {
canonicalPath = await realpath(path);
} catch (error) {
throw new SessionBundleExportError(
'path_escape',
`Unable to resolve exported path: ${relativePath}`,
{ cause: error },
);
}
if (!isPathInside(root, canonicalPath)) {
throw new SessionBundleExportError(
'path_escape',
`Exported path escapes the state root: ${relativePath}`,
);
}
}
function assertNoSymlink(isSymlink: boolean, relativePath: string): void {
if (isSymlink) {
throw new SessionBundleExportError(
'symlink',
`Session bundle export rejects symlinks: ${relativePath}`,
);
}
}
function isKnownProtectedEntry(name: string): boolean {
return (
protectedEntries.has(name) ||
name === 'tmp' ||
name === 'activation-input' ||
name.endsWith('.log')
);
}
function assertSafeSessionId(sessionId: string): void {
if (!isSafeSessionId(sessionId)) {
throw new SessionBundleExportError('invalid_root', `Invalid session id: ${sessionId}`);
}
}
function isSafeSessionId(sessionId: string): boolean {
return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId);
}
export function isArtifactPathForSession(relativePath: string, sessionId: string): boolean {
const parts = relativePath.split(/[\\/]+/);
return (
parts.length >= 2 &&
parts[0] === sessionId &&
parts.every((part) => part.length > 0 && part !== '.' && part !== '..')
);
}
function isPathInside(root: string, candidate: string): boolean {
const path = relative(root, candidate);
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
}