From f133b2f849a09c42613ea70db36b5f04dbef8dec Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 6 Aug 2026 13:43:06 +0200 Subject: [PATCH 1/2] fix(cloud-agent-next): scrub transient lifecycle parts on session restore Drop leftover CLI snapshot-progress parts (kilocode.lifecycle=transient) from export JSON before kilo import so cold restore cannot poison later LLM turns via invalid providerOptions. --- .../wrapper/src/restore-session.test.ts | 66 +++++++++++++++++++ .../wrapper/src/restore-session.ts | 44 +++++++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/services/cloud-agent-next/wrapper/src/restore-session.test.ts b/services/cloud-agent-next/wrapper/src/restore-session.test.ts index cfb7395349..eb7c46a52e 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -80,6 +80,18 @@ function writeMockKilo(binDir: string, exitCode: number): void { fs.writeFileSync(kiloPath, script, { mode: 0o755 }); } +/** Captures the snapshot path passed to `kilo import` by copying it before exit. */ +function writeCapturingMockKilo(binDir: string, capturePath: string): void { + const script = `#!/bin/sh +if [ "$1" = "import" ] && [ -n "$2" ]; then + cp "$2" "${capturePath}" +fi +exit 0 +`; + const kiloPath = path.join(binDir, 'kilo'); + fs.writeFileSync(kiloPath, script, { mode: 0o755 }); +} + function writeSlowMockKilo(binDir: string): void { const script = '#!/bin/sh\nsleep 1\nexit 0\n'; const kiloPath = path.join(binDir, 'kilo'); @@ -428,6 +440,60 @@ describe('restoreSession', () => { // ---- Happy paths ---- + it('strips transient lifecycle parts from the snapshot before kilo import', async () => { + const capturePath = path.join(tmpDir, 'import-input.json'); + writeCapturingMockKilo(binDir, capturePath); + + const snapshot = JSON.stringify({ + info: snapshotInfo(), + messages: [ + { + info: { role: 'assistant', id: 'msg_1' }, + parts: [ + { + type: 'text', + text: 'Initializing snapshot…', + metadata: { 'kilocode.lifecycle': 'transient' }, + }, + { + type: 'text', + text: 'Real assistant reply', + }, + { + type: 'text', + text: 'Keep durable metadata', + metadata: { 'kilocode.lifecycle': 'durable' }, + }, + ], + }, + { + info: { role: 'user', id: 'msg_2' }, + parts: [{ type: 'text', text: 'hello' }], + }, + ], + }); + mockFetchOk(snapshot); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(true); + expect(fs.existsSync(capturePath)).toBe(true); + + const imported = JSON.parse(fs.readFileSync(capturePath, 'utf-8')) as { + messages: Array<{ parts: Array<{ text?: string; metadata?: Record }> }>; + }; + expect(imported.messages[0]?.parts).toEqual([ + { type: 'text', text: 'Real assistant reply' }, + { + type: 'text', + text: 'Keep durable metadata', + metadata: { 'kilocode.lifecycle': 'durable' }, + }, + ]); + expect(imported.messages[1]?.parts).toEqual([{ type: 'text', text: 'hello' }]); + expect(JSON.stringify(imported)).not.toContain('"kilocode.lifecycle":"transient"'); + }); + it('downloads snapshot, imports, and applies diffs', async () => { const snapshot = makeSnapshot([ { file: 'src/index.ts', after: "console.log('hello');", status: 'modified' }, diff --git a/services/cloud-agent-next/wrapper/src/restore-session.ts b/services/cloud-agent-next/wrapper/src/restore-session.ts index 1472d45bdb..fd5ed4af74 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -46,6 +46,12 @@ export type RestoreSessionOptions = { const KILO_IMPORT_TIMEOUT_MS = 120_000; const JQ_SANITIZE_TOKEN_COUNTS_FILTER = 'walk(if type == "object" and ((.tokens? | type) == "object") then .tokens |= walk(if type == "number" and . < 0 then 0 else . end) else . end)'; +// Drop leftover CLI UI progress parts (metadata.kilocode.lifecycle == "transient"). +// These leak into durable session history when snapshot progress cleanup fails; on +// restore, toModelMessages copies part.metadata into providerOptions and AI SDK +// rejects string values under providerOptions.kilocode.lifecycle. +const JQ_SANITIZE_TRANSIENT_PARTS_FILTER = + 'if (.messages? | type) == "array" then .messages |= map(if type == "object" and (.parts? | type) == "array" then .parts |= map(select((type != "object") or ((.metadata["kilocode.lifecycle"]? // null) != "transient"))) else . end) else . end'; // --------------------------------------------------------------------------- // Helpers @@ -378,14 +384,16 @@ function tokenSanitizationTempPath(snapshotPath: string): string { ); } -async function sanitizeSnapshotTokenCountsWithJq( +async function sanitizeSnapshotWithJq( snapshotPath: string, + filter: string, + logLabel: string, signal?: AbortSignal ): Promise { const tempPath = tokenSanitizationTempPath(snapshotPath); try { signal?.throwIfAborted(); - const proc = Bun.spawn(['jq', '-c', JQ_SANITIZE_TOKEN_COUNTS_FILTER, snapshotPath], { + const proc = Bun.spawn(['jq', '-c', filter, snapshotPath], { stdout: 'pipe', stderr: 'ignore', signal, @@ -395,14 +403,14 @@ async function sanitizeSnapshotTokenCountsWithJq( await writeOutput; signal?.throwIfAborted(); if (exitCode !== 0) { - log(`snapshot_token_sanitization_jq_unavailable exitCode=${exitCode}`); + log(`snapshot_${logLabel}_jq_unavailable exitCode=${exitCode}`); return false; } fs.renameSync(tempPath, snapshotPath); return true; } catch { signal?.throwIfAborted(); - log('snapshot_token_sanitization_jq_unavailable'); + log(`snapshot_${logLabel}_jq_unavailable`); return false; } finally { fs.rmSync(tempPath, { force: true }); @@ -413,13 +421,38 @@ async function sanitizeSnapshotTokenCounts( snapshotPath: string, signal?: AbortSignal ): Promise { - if (await sanitizeSnapshotTokenCountsWithJq(snapshotPath, signal)) { + if ( + await sanitizeSnapshotWithJq( + snapshotPath, + JQ_SANITIZE_TOKEN_COUNTS_FILTER, + 'token_sanitization', + signal + ) + ) { log('snapshot token counts sanitized'); return; } log('snapshot token count sanitization skipped'); } +async function sanitizeSnapshotTransientParts( + snapshotPath: string, + signal?: AbortSignal +): Promise { + if ( + await sanitizeSnapshotWithJq( + snapshotPath, + JQ_SANITIZE_TRANSIENT_PARTS_FILTER, + 'transient_parts_sanitization', + signal + ) + ) { + log('snapshot transient parts sanitized'); + return; + } + log('snapshot transient parts sanitization skipped'); +} + // jq filter that extracts diffs from the snapshot JSON using last-write-wins // deduplication by file path. Runs as a subprocess so the full parsed snapshot // is never loaded into the main process's heap — jq's C-native parser uses @@ -638,6 +671,7 @@ export async function restoreSession( try { await sanitizeSnapshotTokenCounts(tmpPath, options.signal); + await sanitizeSnapshotTransientParts(tmpPath, options.signal); // ---- Step 2: Run kilo import ---- const importStartedAt = Date.now(); From 583adb09e54e740b54e419c27b56daa9e2d920f5 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 6 Aug 2026 13:58:13 +0200 Subject: [PATCH 2/2] fix(cloud-agent-next): total single-pass snapshot sanitization - Guard the transient-parts jq filter with a top-level type check so non-object snapshots pass through unchanged instead of yielding empty output that sanitizeSnapshotWithJq would rename (0 bytes) over a user-supplied --file snapshot - Compose token-count and transient-part sanitization into one jq invocation so large exports are read+rewritten once per restore - Fix the part-shape typing in the transient-parts test and add a regression test for non-object --file passthrough --- .../wrapper/src/restore-session.test.ts | 23 +++++++++- .../wrapper/src/restore-session.ts | 45 +++++-------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/services/cloud-agent-next/wrapper/src/restore-session.test.ts b/services/cloud-agent-next/wrapper/src/restore-session.test.ts index eb7c46a52e..4757b2e6e0 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -480,7 +480,9 @@ describe('restoreSession', () => { expect(fs.existsSync(capturePath)).toBe(true); const imported = JSON.parse(fs.readFileSync(capturePath, 'utf-8')) as { - messages: Array<{ parts: Array<{ text?: string; metadata?: Record }> }>; + messages: Array<{ + parts: Array<{ type?: string; text?: string; metadata?: Record }>; + }>; }; expect(imported.messages[0]?.parts).toEqual([ { type: 'text', text: 'Real assistant reply' }, @@ -494,6 +496,25 @@ describe('restoreSession', () => { expect(JSON.stringify(imported)).not.toContain('"kilocode.lifecycle":"transient"'); }); + it('passes a non-object provided snapshot through sanitization unchanged', async () => { + const capturePath = path.join(tmpDir, 'import-input.json'); + writeCapturingMockKilo(binDir, capturePath); + + // Valid JSON but not a snapshot object. The --file path only logs metadata + // validation, so sanitization must leave the file intact rather than let a + // non-total jq filter truncate it to 0 bytes before kilo import. + const provided = '[1,2,3]'; + const providedPath = path.join(tmpDir, 'provided.json'); + fs.writeFileSync(providedPath, provided); + + const result = await restoreSession(SESSION_ID, workspace, providedPath); + + expect(result.ok).toBe(true); + expect(fs.existsSync(capturePath)).toBe(true); + // jq re-serializes the file, so compare values rather than bytes. + expect(JSON.parse(fs.readFileSync(capturePath, 'utf-8'))).toEqual(JSON.parse(provided)); + }); + it('downloads snapshot, imports, and applies diffs', async () => { const snapshot = makeSnapshot([ { file: 'src/index.ts', after: "console.log('hello');", status: 'modified' }, diff --git a/services/cloud-agent-next/wrapper/src/restore-session.ts b/services/cloud-agent-next/wrapper/src/restore-session.ts index fd5ed4af74..f16234df1f 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -50,8 +50,14 @@ const JQ_SANITIZE_TOKEN_COUNTS_FILTER = // These leak into durable session history when snapshot progress cleanup fails; on // restore, toModelMessages copies part.metadata into providerOptions and AI SDK // rejects string values under providerOptions.kilocode.lifecycle. +// The type guard keeps the filter total: non-object snapshots pass through +// unchanged instead of producing empty output that sanitizeSnapshotWithJq would +// mistake for success and rename (0 bytes) over a user-supplied --file snapshot. const JQ_SANITIZE_TRANSIENT_PARTS_FILTER = - 'if (.messages? | type) == "array" then .messages |= map(if type == "object" and (.parts? | type) == "array" then .parts |= map(select((type != "object") or ((.metadata["kilocode.lifecycle"]? // null) != "transient"))) else . end) else . end'; + 'if type == "object" and (.messages | type) == "array" then .messages |= map(if type == "object" and (.parts | type) == "array" then .parts |= map(select((type != "object") or ((.metadata["kilocode.lifecycle"]? // null) != "transient"))) else . end) else . end'; +// Both sanitizations run in a single jq pass so the snapshot is read+rewritten +// once per restore — exports can be very large. +const JQ_SANITIZE_SNAPSHOT_FILTER = `${JQ_SANITIZE_TOKEN_COUNTS_FILTER} | ${JQ_SANITIZE_TRANSIENT_PARTS_FILTER}`; // --------------------------------------------------------------------------- // Helpers @@ -417,40 +423,14 @@ async function sanitizeSnapshotWithJq( } } -async function sanitizeSnapshotTokenCounts( - snapshotPath: string, - signal?: AbortSignal -): Promise { - if ( - await sanitizeSnapshotWithJq( - snapshotPath, - JQ_SANITIZE_TOKEN_COUNTS_FILTER, - 'token_sanitization', - signal - ) - ) { - log('snapshot token counts sanitized'); - return; - } - log('snapshot token count sanitization skipped'); -} - -async function sanitizeSnapshotTransientParts( - snapshotPath: string, - signal?: AbortSignal -): Promise { +async function sanitizeSnapshot(snapshotPath: string, signal?: AbortSignal): Promise { if ( - await sanitizeSnapshotWithJq( - snapshotPath, - JQ_SANITIZE_TRANSIENT_PARTS_FILTER, - 'transient_parts_sanitization', - signal - ) + await sanitizeSnapshotWithJq(snapshotPath, JQ_SANITIZE_SNAPSHOT_FILTER, 'sanitization', signal) ) { - log('snapshot transient parts sanitized'); + log('snapshot sanitized'); return; } - log('snapshot transient parts sanitization skipped'); + log('snapshot sanitization skipped'); } // jq filter that extracts diffs from the snapshot JSON using last-write-wins @@ -670,8 +650,7 @@ export async function restoreSession( } try { - await sanitizeSnapshotTokenCounts(tmpPath, options.signal); - await sanitizeSnapshotTransientParts(tmpPath, options.signal); + await sanitizeSnapshot(tmpPath, options.signal); // ---- Step 2: Run kilo import ---- const importStartedAt = Date.now();