diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 625761b06..9502a94c9 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -291,7 +291,6 @@ describe('useWorkspaceState — exportSession', () => { let revokeObjectURL: ReturnType; 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), diff --git a/packages/agent-core-v2/src/app/sessionExport/errors.ts b/packages/agent-core-v2/src/app/sessionExport/errors.ts index 48e764285..43d3a9ed8 100644 --- a/packages/agent-core-v2/src/app/sessionExport/errors.ts +++ b/packages/agent-core-v2/src/app/sessionExport/errors.ts @@ -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; diff --git a/packages/agent-core-v2/src/app/sessionExport/file-source.ts b/packages/agent-core-v2/src/app/sessionExport/file-source.ts index 584796748..0d68d95d2 100644 --- a/packages/agent-core-v2/src/app/sessionExport/file-source.ts +++ b/packages/agent-core-v2/src/app/sessionExport/file-source.ts @@ -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; } +export interface ZipSourceIdentity { + readonly device: bigint; + readonly inode: bigint; +} + export async function openZipSource(source: string, signal?: AbortSignal): Promise { 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 | 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; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 8b6e411ae..95c85f798 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -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 { 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 { 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, diff --git a/packages/agent-core-v2/src/app/sessionExport/zip.ts b/packages/agent-core-v2/src/app/sessionExport/zip.ts index ace5ada09..0a1168ccd 100644 --- a/packages/agent-core-v2/src/app/sessionExport/zip.ts +++ b/packages/agent-core-v2/src/app/sessionExport/zip.ts @@ -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 { @@ -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 { - const unusedSources = new Set( - (args.extraEntries ?? []).flatMap((entry) => ('source' in entry ? [entry.source] : [])), - ); + const unusedSources = new Set([ + ...args.sessionFiles.flatMap((entry) => (typeof entry === 'string' ? [] : [entry.source])), + ...(args.extraEntries ?? []).flatMap((entry) => + 'source' in entry ? [entry.source] : [], + ), + ]); const pendingOpens = new Set>(); 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 { + 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 { + 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' && diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 4318bc010..ec78f6111 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -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) | undefined, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + 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 { + 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 () => { diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 3c158310c..b729a370a 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -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',