fix(web): make session export atomic

This commit is contained in:
qer 2026-07-14 06:15:25 +08:00
parent 3631a0f555
commit 14530835e8
7 changed files with 552 additions and 68 deletions

View file

@ -291,7 +291,6 @@ describe('useWorkspaceState — exportSession', () => {
let revokeObjectURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
apiMock.exportSession.mockReset();
clearTrace();
anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
@ -307,8 +306,6 @@ describe('useWorkspaceState — exportSession', () => {
afterEach(() => {
clearTrace();
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@ -326,7 +323,6 @@ describe('useWorkspaceState — exportSession', () => {
const workspace = useWorkspaceState(createState(), createDeps());
await workspace.exportSession();
vi.runAllTimers();
const webLog = apiMock.exportSession.mock.calls[0]?.[1] as string;
expect(webLog).toContain('prompt:start');
@ -337,7 +333,10 @@ describe('useWorkspaceState — exportSession', () => {
expect(append).toHaveBeenCalledWith(anchor);
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.remove).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
});
it('keeps one request targeted at the session selected when export started', async () => {
@ -355,6 +354,9 @@ describe('useWorkspaceState — exportSession', () => {
const second = workspace.exportSession();
resolveExport({ blob: new Blob(['zip']), fileName: 'sess_1.zip' });
await Promise.all([first, second]);
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
expect(apiMock.exportSession).toHaveBeenCalledTimes(1);
expect(apiMock.exportSession).toHaveBeenCalledWith('sess_1', expect.any(String));
@ -369,10 +371,12 @@ describe('useWorkspaceState — exportSession', () => {
const workspace = useWorkspaceState(createState(), deps);
await workspace.exportSession();
vi.runAllTimers();
expect(anchor.remove).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
await vi.waitFor(() => {
expect(revokeObjectURL).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
});
expect(deps.pushOperationFailure).toHaveBeenCalledWith(
'exportSession',
expect.any(Error),

View file

@ -8,6 +8,7 @@ export const SessionExportErrors = {
codes: {
SESSION_EXPORT_NOT_FOUND: 'session.export_not_found',
SESSION_EXPORT_MISSING_VERSION: 'session.export_missing_version',
SESSION_EXPORT_OUTPUT_CONFLICT: 'session.export_output_conflict',
SESSION_EXPORT_TOO_LARGE: 'session.export_too_large',
},
} as const satisfies ErrorDomain;

View file

@ -8,38 +8,50 @@
import { open, type FileHandle } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { resolve } from 'pathe';
export interface ZipSource {
readonly stream: Readable;
readonly size: number;
readonly mtime: Date;
readonly mode: number;
readonly identity: ZipSourceIdentity;
readonly sourcePath?: string;
close(): Promise<void>;
}
export interface ZipSourceIdentity {
readonly device: bigint;
readonly inode: bigint;
}
export async function openZipSource(source: string, signal?: AbortSignal): Promise<ZipSource> {
const handle = await open(source, 'r');
let stream: Readable | undefined;
try {
signal?.throwIfAborted();
const file = await handle.stat();
const file = await handle.stat({ bigint: true });
if (!file.isFile()) throw new Error(`not a file: ${source}`);
const size = Number(file.size);
if (!Number.isSafeInteger(size)) throw new Error(`file is too large to export: ${source}`);
signal?.throwIfAborted();
stream =
file.size === 0
size === 0
? Readable.from([])
: handle.createReadStream({
autoClose: false,
start: 0,
end: file.size - 1,
end: size - 1,
signal,
});
let closing: Promise<void> | undefined;
return {
stream,
size: file.size,
size,
mtime: file.mtime,
mode: file.mode,
mode: Number(file.mode),
identity: { device: file.dev, inode: file.ino },
sourcePath: resolve(source),
close: () => {
closing ??= closeZipSource(stream!, handle);
return closing;

View file

@ -6,7 +6,7 @@
* packages diagnostic files through the local zip writer. Bound at App scope.
*/
import { resolve } from 'pathe';
import { join, resolve } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
@ -31,6 +31,7 @@ import {
import { scanSessionWire } from './wire-scan';
import {
type ExtraZipEntry,
type SessionZipEntry,
collectFilesRecursive,
writeExportZip,
} from './zip';
@ -164,49 +165,58 @@ export async function exportSessionDirectory(input: {
}): Promise<ExportSessionResult> {
input.signal?.throwIfAborted();
const sessionDir = input.summary.sessionDir;
const sessionFiles = await collectFilesRecursive(sessionDir);
if (sessionFiles.length === 0) {
throw new Error2(
ErrorCodes.SESSION_EXPORT_NOT_FOUND,
`Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`,
{ details: { sessionId: input.summary.id, sessionDir } },
);
}
const sessionScan = await scanSessionWire(sessionDir, input.signal);
const hasSessionLog = sessionFiles.some((f) =>
f.endsWith(`/${SESSION_LOG_REL}`) || f.endsWith(`\\${SESSION_LOG_REL.replaceAll('/', '\\')}`),
);
const bundledWebLog = input.webLog !== undefined;
const baseManifest = buildExportManifest({
summary: input.summary,
now: new Date(),
version: input.request.version,
sessionScan,
sessionLogPath: hasSessionLog ? SESSION_LOG_REL : undefined,
webLogPath: bundledWebLog ? WEB_LOG_REL : undefined,
installSource: input.request.installSource,
shellEnv: input.request.shellEnv,
});
const outputPath =
input.request.outputPath !== undefined
? resolve(input.request.outputPath)
: resolve(`${input.summary.id}.zip`);
const extras: ExtraZipEntry[] = [];
if (input.webLog !== undefined) {
extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL });
}
const sessionLogPath = join(sessionDir, SESSION_LOG_REL);
let sessionLogSource: ZipSource | undefined;
let sessionLogSourceTransferred = false;
let globalSource: ZipSource | undefined;
let globalSourceTransferred = false;
try {
sessionLogSource = await openOptionalZipSource(sessionLogPath, input.signal);
if (input.request.includeGlobalLog === true && input.globalLogPath !== undefined) {
globalSource = await openOptionalZipSource(input.globalLogPath, input.signal);
if (globalSource !== undefined) {
extras.push({ source: globalSource, target: GLOBAL_LOG_REL });
}
}
const sessionFiles = await collectFilesRecursive(sessionDir);
if (sessionFiles.length === 0 && sessionLogSource === undefined) {
throw new Error2(
ErrorCodes.SESSION_EXPORT_NOT_FOUND,
`Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`,
{ details: { sessionId: input.summary.id, sessionDir } },
);
}
const sessionScan = await scanSessionWire(sessionDir, input.signal);
const stableSessionLog = sessionLogSource;
const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter(
(file) => file !== sessionLogPath,
);
if (stableSessionLog !== undefined) {
selectedSessionFiles.push({ path: sessionLogPath, source: stableSessionLog });
selectedSessionFiles.sort((left, right) =>
sessionZipEntryPath(left).localeCompare(sessionZipEntryPath(right)),
);
}
const bundledWebLog = input.webLog !== undefined;
const baseManifest = buildExportManifest({
summary: input.summary,
now: new Date(),
version: input.request.version,
sessionScan,
sessionLogPath: stableSessionLog === undefined ? undefined : SESSION_LOG_REL,
webLogPath: bundledWebLog ? WEB_LOG_REL : undefined,
installSource: input.request.installSource,
shellEnv: input.request.shellEnv,
});
const outputPath =
input.request.outputPath !== undefined
? resolve(input.request.outputPath)
: resolve(`${input.summary.id}.zip`);
const extras: ExtraZipEntry[] = [];
if (input.webLog !== undefined) {
extras.push({ data: Buffer.from(input.webLog, 'utf8'), target: WEB_LOG_REL });
}
if (globalSource !== undefined) {
extras.push({ source: globalSource, target: GLOBAL_LOG_REL });
}
const manifest =
globalSource === undefined
@ -217,11 +227,12 @@ export async function exportSessionDirectory(input: {
outputPath,
manifest,
sessionDir,
sessionFiles,
sessionFiles: selectedSessionFiles,
extraEntries: extras,
signal: input.signal,
maxArchiveBytes: input.maxArchiveBytes,
});
sessionLogSourceTransferred = sessionLogSource !== undefined;
globalSourceTransferred = globalSource !== undefined;
const entries = await writing;
@ -232,24 +243,41 @@ export async function exportSessionDirectory(input: {
manifest,
};
} finally {
if (sessionLogSource !== undefined && !sessionLogSourceTransferred) {
await sessionLogSource.close().catch(() => {});
}
if (globalSource !== undefined && !globalSourceTransferred) {
await globalSource.close().catch(() => {});
}
}
}
function sessionZipEntryPath(entry: SessionZipEntry): string {
return typeof entry === 'string' ? entry : entry.path;
}
async function openOptionalZipSource(
path: string,
signal: AbortSignal | undefined,
): Promise<ZipSource | undefined> {
try {
return await openZipSource(path, signal);
} catch {
} catch (error) {
signal?.throwIfAborted();
return undefined;
if (isMissingPath(error)) return undefined;
throw error;
}
}
function isMissingPath(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}
registerScopedService(
LifecycleScope.App,
ISessionExportService,

View file

@ -7,16 +7,20 @@
*/
import { createWriteStream } from 'node:fs';
import { mkdir, readdir } from 'node:fs/promises';
import { mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { dirname, join, relative } from 'pathe';
import { dirname, join, relative, resolve } from 'pathe';
import { ZipFile, type ReadStreamOptions } from 'yazl';
import { ErrorCodes, Error2 } from '#/errors';
import { openZipSource, type ZipSource } from './file-source';
import {
openZipSource,
type ZipSource,
type ZipSourceIdentity,
} from './file-source';
import type { ExportSessionManifest } from './sessionExport';
export async function collectFilesRecursive(root: string): Promise<string[]> {
@ -36,18 +40,23 @@ export type ExtraZipEntry =
| { readonly source: ZipSource; readonly target: string }
| { readonly data: Buffer; readonly target: string };
export type SessionZipEntry = string | { readonly path: string; readonly source: ZipSource };
export async function writeExportZip(args: {
readonly outputPath: string;
readonly manifest: ExportSessionManifest;
readonly sessionDir: string;
readonly sessionFiles: readonly string[];
readonly sessionFiles: readonly SessionZipEntry[];
readonly extraEntries?: readonly ExtraZipEntry[];
readonly signal?: AbortSignal;
readonly maxArchiveBytes?: number;
}): Promise<readonly string[]> {
const unusedSources = new Set(
(args.extraEntries ?? []).flatMap((entry) => ('source' in entry ? [entry.source] : [])),
);
const unusedSources = new Set<ZipSource>([
...args.sessionFiles.flatMap((entry) => (typeof entry === 'string' ? [] : [entry.source])),
...(args.extraEntries ?? []).flatMap((entry) =>
'source' in entry ? [entry.source] : [],
),
]);
const pendingOpens = new Set<Promise<void>>();
let activeSource: ZipSource | undefined;
let releaseActive: (() => void) | undefined;
@ -57,6 +66,7 @@ export async function writeExportZip(args: {
let stopped: Error | undefined;
let failure: { readonly error: unknown } | undefined;
let onAbort: (() => void) | undefined;
let tempDir: string | undefined;
const getStopError = (): Error | undefined => stopped;
const stop = (error: Error): void => {
@ -73,8 +83,18 @@ export async function writeExportZip(args: {
};
try {
const conflictingSource = await findConflictingSource(args);
if (conflictingSource !== undefined) {
throw new Error2(
ErrorCodes.SESSION_EXPORT_OUTPUT_CONFLICT,
`Session export output conflicts with selected source "${conflictingSource}".`,
{ details: { outputPath: args.outputPath, source: conflictingSource } },
);
}
await mkdir(dirname(args.outputPath), { recursive: true });
args.signal?.throwIfAborted();
tempDir = await mkdtemp(join(dirname(args.outputPath), '.kimi-session-export-'));
const tempOutputPath = join(tempDir, 'archive.zip');
const zip = new ZipFile() as LazyZipFile;
output = zip.outputStream as unknown as Readable;
@ -89,7 +109,7 @@ export async function writeExportZip(args: {
};
args.signal?.addEventListener('abort', onAbort, { once: true });
const destination = createWriteStream(args.outputPath);
const destination = createWriteStream(tempOutputPath, { flags: 'wx' });
writing =
args.maxArchiveBytes === undefined
? pipeline(output, destination, { signal: args.signal })
@ -152,9 +172,18 @@ export async function writeExportZip(args: {
zip.addBuffer(Buffer.from(JSON.stringify(args.manifest, null, 2), 'utf8'), 'manifest.json');
for (const source of args.sessionFiles) {
const target = relative(args.sessionDir, source).split(/[\\/]/).join('/');
addLazySource(target, {}, () => openZipSource(source, args.signal));
for (const entry of args.sessionFiles) {
const sourcePath = sessionEntryPath(entry);
const target = relative(args.sessionDir, sourcePath).split(/[\\/]/).join('/');
if (typeof entry === 'string') {
addLazySource(target, {}, () => openZipSource(entry, args.signal));
} else {
addLazySource(
target,
{ size: entry.source.size, mtime: entry.source.mtime, mode: entry.source.mode },
async () => entry.source,
);
}
}
for (const extra of args.extraEntries ?? []) {
@ -171,6 +200,14 @@ export async function writeExportZip(args: {
zip.end();
await writing;
await Promise.allSettled(pendingOpens);
await closing;
if (onAbort !== undefined) {
args.signal?.removeEventListener('abort', onAbort);
onAbort = undefined;
}
args.signal?.throwIfAborted();
await rename(tempOutputPath, args.outputPath);
} catch (error) {
failure = { error };
stop(asError(error));
@ -186,14 +223,21 @@ export async function writeExportZip(args: {
} catch (error) {
failure ??= { error };
}
if (tempDir !== undefined) {
try {
await rm(tempDir, { recursive: true, force: true });
} catch (error) {
failure ??= { error };
}
}
}
if (failure !== undefined) throw failure.error;
if (stopped !== undefined) throw stopped;
return [
'manifest.json',
...args.sessionFiles.map((source) =>
relative(args.sessionDir, source).split(/[\\/]/).join('/'),
...args.sessionFiles.map((entry) =>
relative(args.sessionDir, sessionEntryPath(entry)).split(/[\\/]/).join('/'),
),
...(args.extraEntries ?? []).map((entry) => entry.target),
];
@ -239,6 +283,72 @@ function createArchiveLimit(maxArchiveBytes: number): Transform {
});
}
async function findConflictingSource(args: {
readonly outputPath: string;
readonly sessionFiles: readonly SessionZipEntry[];
readonly extraEntries?: readonly ExtraZipEntry[];
readonly signal?: AbortSignal;
}): Promise<string | undefined> {
args.signal?.throwIfAborted();
const outputPath = resolve(args.outputPath);
for (const entry of args.sessionFiles) {
const sourcePath = sessionEntryPath(entry);
if (resolve(sourcePath) === outputPath) return sourcePath;
}
for (const entry of args.extraEntries ?? []) {
if (
'source' in entry &&
entry.source.sourcePath !== undefined &&
resolve(entry.source.sourcePath) === outputPath
) {
return entry.target;
}
}
const output = await statExisting(outputPath);
if (output === undefined) return undefined;
for (const entry of args.sessionFiles) {
args.signal?.throwIfAborted();
const input =
typeof entry === 'string' ? await statExisting(entry) : entry.source.identity;
if (input !== undefined && sameFile(output, input)) return sessionEntryPath(entry);
}
for (const entry of args.extraEntries ?? []) {
args.signal?.throwIfAborted();
if ('source' in entry && sameFile(output, entry.source.identity)) return entry.target;
}
return undefined;
}
async function statExisting(
path: string,
): Promise<ZipSourceIdentity | undefined> {
try {
const file = await stat(path, { bigint: true });
return { device: file.dev, inode: file.ino };
} catch (error) {
if (!isMissingPath(error)) throw error;
return undefined;
}
}
function sameFile(
left: ZipSourceIdentity,
right: ZipSourceIdentity,
): boolean {
return (
left.inode !== 0n &&
right.inode !== 0n &&
left.device === right.device &&
left.inode === right.inode
);
}
function sessionEntryPath(entry: SessionZipEntry): string {
return typeof entry === 'string' ? entry : entry.path;
}
function isMissingPath(error: unknown): boolean {
return (
typeof error === 'object' &&

View file

@ -1,8 +1,22 @@
import { appendFile, mkdir, mkdtemp, stat, unlink, writeFile } from 'node:fs/promises';
import {
appendFile,
type FileHandle,
link,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
stat,
symlink,
unlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { Readable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'pathe';
import { open as openZip } from 'yauzl';
@ -44,6 +58,22 @@ import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/se
import { stubBootstrap } from '../bootstrap/stubs';
import { stubLog } from '../../_base/log/stubs';
const fsOpenHook = vi.hoisted(() => ({
afterOpen: undefined as ((path: string, handle: FileHandle) => Promise<void>) | undefined,
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
await fsOpenHook.afterOpen?.(String(args[0]), handle);
return handle;
},
};
});
const noopDisposable: IDisposable = { dispose: () => {} };
const noopEvent = () => noopDisposable;
@ -115,6 +145,146 @@ describe('sessionExport', () => {
});
});
it('keeps the session log bound when it rotates as wire scanning starts', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_rotating_log');
const logPath = join(sessionDir, 'logs', 'kimi-code.log');
const rotatedPath = `${logPath}.1`;
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const outputPath = join(tmp, 'rotating-log.zip');
const log = Buffer.from('session log before rotation\n', 'utf8');
await mkdir(join(sessionDir, 'logs'), { recursive: true });
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
await writeFile(logPath, log);
await writeFile(wirePath, `${JSON.stringify({ type: 'metadata', time: 1_700_000_000 })}\n`);
let rotated = false;
fsOpenHook.afterOpen = async (path) => {
if (!rotated && path === wirePath) {
await rename(logPath, rotatedPath);
rotated = true;
}
};
try {
const result = await exportSessionDirectory({
request: {
sessionId: 'ses_rotating_log',
outputPath,
version: '1.0.0-test',
},
summary: {
id: 'ses_rotating_log',
sessionDir,
},
});
expect(rotated).toBe(true);
expect(result.manifest.sessionLogPath).toBe('logs/kimi-code.log');
expect(result.entries).toContain('logs/kimi-code.log');
await expect(readZipEntry(outputPath, 'logs/kimi-code.log')).resolves.toEqual(log);
} finally {
fsOpenHook.afterOpen = undefined;
}
});
it('keeps the global log bound when it rotates as wire scanning starts', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_rotating_global');
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const globalLogPath = join(tmp, 'logs', 'kimi-code.log');
const rotatedPath = `${globalLogPath}.1`;
const outputPath = join(tmp, 'rotating-global.zip');
const log = Buffer.from('global log before rotation\n', 'utf8');
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
await mkdir(join(tmp, 'logs'), { recursive: true });
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
await writeFile(wirePath, `${JSON.stringify({ type: 'metadata', time: 1_700_000_000 })}\n`);
await writeFile(globalLogPath, log);
let rotated = false;
fsOpenHook.afterOpen = async (path) => {
if (!rotated && path === wirePath) {
await rename(globalLogPath, rotatedPath);
rotated = true;
}
};
try {
const result = await exportSessionDirectory({
request: {
sessionId: 'ses_rotating_global',
outputPath,
includeGlobalLog: true,
version: '1.0.0-test',
},
summary: {
id: 'ses_rotating_global',
sessionDir,
},
globalLogPath,
});
expect(rotated).toBe(true);
expect(result.manifest.globalLogPath).toBe('logs/global/kimi-code.log');
expect(result.entries).toContain('logs/global/kimi-code.log');
await expect(readZipEntry(outputPath, 'logs/global/kimi-code.log')).resolves.toEqual(log);
} finally {
fsOpenHook.afterOpen = undefined;
}
});
it('closes pre-opened logs when manifest creation fails before writer ownership', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_invalid_manifest');
const sessionLogPath = join(sessionDir, 'logs', 'kimi-code.log');
const globalLogPath = join(tmp, 'logs', 'kimi-code.log');
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const outputPath = join(tmp, 'invalid-manifest.zip');
await mkdir(join(sessionDir, 'logs'), { recursive: true });
await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true });
await mkdir(join(tmp, 'logs'), { recursive: true });
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf8');
await writeFile(sessionLogPath, 'session log\n', 'utf8');
await writeFile(globalLogPath, 'global log\n', 'utf8');
await writeFile(
wirePath,
`${JSON.stringify({ type: 'metadata', time: 9_000_000_000_000_001 })}\n`,
);
const logHandles: FileHandle[] = [];
fsOpenHook.afterOpen = async (path, handle) => {
if (path === sessionLogPath || path === globalLogPath) logHandles.push(handle);
};
try {
await expect(
exportSessionDirectory({
request: {
sessionId: 'ses_invalid_manifest',
outputPath,
includeGlobalLog: true,
version: '1.0.0-test',
},
summary: {
id: 'ses_invalid_manifest',
sessionDir,
},
globalLogPath,
}),
).rejects.toBeInstanceOf(RangeError);
} finally {
fsOpenHook.afterOpen = undefined;
}
expect(logHandles).toHaveLength(2);
for (const handle of logHandles) {
await expect(handle.stat()).rejects.toMatchObject({ code: 'EBADF' });
}
await expect(stat(outputPath)).rejects.toMatchObject({ code: 'ENOENT' });
expect((await readdir(tmp)).filter((entry) => entry.startsWith('.kimi-session-export-'))).toEqual(
[],
);
});
it('omits the optional global log when the configured file is missing', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_unreadable_global');
@ -165,6 +335,110 @@ describe('sessionExport', () => {
);
});
it('rejects an output path that is also a selected session file without modifying it', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const outputPath = join(tmp, 'state.json');
const original = Buffer.from('{"state":"preserved"}\n', 'utf8');
await writeFile(outputPath, original);
await expect(
exportSessionDirectory({
request: {
sessionId: 'ses_output_conflict',
outputPath,
version: '1.0.0-test',
},
summary: {
id: 'ses_output_conflict',
sessionDir: tmp,
},
}),
).rejects.toMatchObject({
name: 'Error2',
code: 'session.export_output_conflict',
details: { outputPath, source: outputPath },
});
await expect(readFile(outputPath)).resolves.toEqual(original);
});
it('rejects a hard-linked output path without modifying the selected session file', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sourcePath = join(tmp, 'state.json');
const outputPath = join(tmp, 'export.zip');
const original = Buffer.from('{"state":"preserved"}\n', 'utf8');
await writeFile(sourcePath, original);
await link(sourcePath, outputPath);
await expect(
writeExportZip({
outputPath,
manifest: testManifest('ses_hard_link_conflict'),
sessionDir: tmp,
sessionFiles: [sourcePath],
}),
).rejects.toMatchObject({ code: 'session.export_output_conflict' });
await expect(readFile(sourcePath)).resolves.toEqual(original);
});
it('closes a pre-opened source when it conflicts with the output path', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const outputPath = join(tmp, 'global.log');
const original = Buffer.from('global log\n', 'utf8');
await writeFile(outputPath, original);
const opened = await openZipSource(outputPath);
let closeCalls = 0;
const source: ZipSource = {
...opened,
close: async () => {
closeCalls += 1;
await opened.close();
},
};
await expect(
writeExportZip({
outputPath,
manifest: testManifest('ses_extra_conflict'),
sessionDir: tmp,
sessionFiles: [],
extraEntries: [{ source, target: 'logs/global/kimi-code.log' }],
}),
).rejects.toMatchObject({ code: 'session.export_output_conflict' });
expect(closeCalls).toBe(1);
await expect(readFile(outputPath)).resolves.toEqual(original);
});
it('archives a bound session log after its path is rotated', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const logPath = join(tmp, 'logs', 'kimi-code.log');
const rotatedPath = `${logPath}.1`;
const outputPath = join(tmp, 'rotated-log.zip');
const original = Buffer.from('before rotation\n', 'utf8');
await mkdir(join(tmp, 'logs'), { recursive: true });
await writeFile(logPath, original);
const opened = await openZipSource(logPath);
let closeCalls = 0;
const source: ZipSource = {
...opened,
close: async () => {
closeCalls += 1;
await opened.close();
},
};
await rename(logPath, rotatedPath);
await expect(
writeExportZip({
outputPath,
manifest: testManifest('ses_rotated_log'),
sessionDir: tmp,
sessionFiles: [{ path: logPath, source }],
}),
).resolves.toContain('logs/kimi-code.log');
await expect(readZipEntry(outputPath, 'logs/kimi-code.log')).resolves.toEqual(original);
expect(closeCalls).toBe(1);
});
it('includes a bounded Web log in the exported archive', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_web_log');
@ -210,6 +484,7 @@ describe('sessionExport', () => {
sessionFiles: [removedPath],
}),
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readdir(tmp)).resolves.toEqual([]);
});
it('archives the opened file size when the source is appended during compression', async () => {
@ -260,6 +535,7 @@ describe('sessionExport', () => {
size: 7,
mtime: new Date(0),
mode: 0o600,
identity: { device: -1n, inode: -1n },
close: async () => {
if (closed) return;
closed = true;
@ -283,6 +559,57 @@ describe('sessionExport', () => {
expect(stream.destroyed).toBe(true);
expect(closeCalls).toBe(1);
allowRead.resolve();
await expect(readdir(tmp)).resolves.toEqual([]);
});
it('does not follow an output symlink swapped during compression', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
const statePath = join(tmp, 'state.json');
const safeTarget = join(tmp, 'safe-output');
const outputPath = join(tmp, 'export.zip');
const state = Buffer.from('{"state":"preserved"}\n', 'utf8');
await writeFile(statePath, state);
await writeFile(safeTarget, 'safe\n', 'utf8');
await symlink(safeTarget, outputPath);
const readStarted = deferred();
const allowRead = deferred();
const payload = Buffer.from('payload', 'utf8');
const stream = Readable.from(
(async function* (): AsyncGenerator<Buffer> {
readStarted.resolve();
await allowRead.promise;
yield payload;
})(),
);
const source: ZipSource = {
stream,
size: payload.length,
mtime: new Date(0),
mode: 0o600,
identity: { device: -1n, inode: -1n },
close: async () => {
stream.destroy();
},
};
const writing = writeExportZip({
outputPath,
manifest: testManifest('ses_symlink_swap'),
sessionDir: tmp,
sessionFiles: [],
extraEntries: [{ source, target: 'controlled.bin' }],
});
await readStarted.promise;
await unlink(outputPath);
await symlink(statePath, outputPath);
allowRead.resolve();
await writing;
await expect(readFile(statePath)).resolves.toEqual(state);
await expect(readFile(safeTarget, 'utf8')).resolves.toBe('safe\n');
expect((await lstat(outputPath)).isSymbolicLink()).toBe(false);
await expect(readZipEntry(outputPath, 'controlled.bin')).resolves.toEqual(payload);
expect((await readdir(tmp)).toSorted()).toEqual(['export.zip', 'safe-output', 'state.json']);
});
it('rejects with a coded error when compressed output exceeds the configured limit', async () => {

View file

@ -220,6 +220,7 @@ export type KimiErrorCode =
| 'session.undo_unavailable'
| 'session.export_not_found'
| 'session.export_missing_version'
| 'session.export_output_conflict'
| 'session.export_too_large'
| 'session.closed'
| 'session.permission_mode_invalid'
@ -1121,6 +1122,7 @@ export const kimiErrorCodeSchema = z.enum([
'session.undo_unavailable',
'session.export_not_found',
'session.export_missing_version',
'session.export_output_conflict',
'session.export_too_large',
'session.closed',
'session.permission_mode_invalid',