From fb9d3354d6d0248961eac50cd0024d0ebf3c13b3 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 1 Jun 2026 12:42:32 +0200 Subject: [PATCH] fix(cloud-agent-next): diagnose kilo import failures --- services/cloud-agent-next/DEBUG.md | 2 + .../wrapper/src/restore-session.test.ts | 378 +++++++++++++++++- .../wrapper/src/restore-session.ts | 227 ++++++++++- 3 files changed, 597 insertions(+), 10 deletions(-) diff --git a/services/cloud-agent-next/DEBUG.md b/services/cloud-agent-next/DEBUG.md index 7994895e3d..b1ecb4c9c0 100644 --- a/services/cloud-agent-next/DEBUG.md +++ b/services/cloud-agent-next/DEBUG.md @@ -102,6 +102,7 @@ High-value wrapper landmarks: - `bootstrap snapshot restore starting` - `restore-session: snapshot metadata validated` - `restore-session: kilo import finished` +- `restore-session: kilo import diagnostics` - `post-bootstrap kilo session lookup begin` - `post-bootstrap kilo session lookup end` - `session/ready complete` @@ -113,6 +114,7 @@ For stuck import/debugging, confirm all of these: - import input source (`provided` vs `downloaded`) - expected Kilo session ID vs snapshot `info.id` - import exit code +- bounded, sanitized stdout/stderr previews from `restore-session: kilo import diagnostics` when import exits non-zero - `HOME` and workspace path used by import - post-import `getSession()` result 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 b59805f265..e7e405139f 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -50,8 +50,32 @@ function mockFetchStatus(status: number, body = ''): void { globalThis.fetch = asFetch(() => Promise.resolve(new Response(body, { status }))); } -function writeMockKilo(binDir: string, exitCode: number): void { - const script = `#!/bin/sh\nexit ${exitCode}\n`; +type MockKiloOptions = { + exitCode: number; + stdout?: string; + stderr?: string; + backgroundSleepSeconds?: number; + sleepSeconds?: number; +}; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function writeMockKilo(binDir: string, options: MockKiloOptions): void { + const script = [ + '#!/bin/sh', + options.stdout === undefined ? undefined : `printf '%s' ${shellQuote(options.stdout)}`, + options.stderr === undefined ? undefined : `printf '%s' ${shellQuote(options.stderr)} >&2`, + options.backgroundSleepSeconds === undefined + ? undefined + : `sleep ${options.backgroundSleepSeconds} &`, + options.sleepSeconds === undefined ? undefined : `exec sleep ${options.sleepSeconds}`, + `exit ${options.exitCode}`, + '', + ] + .filter(line => line !== undefined) + .join('\n'); const kiloPath = path.join(binDir, 'kilo'); fs.writeFileSync(kiloPath, script, { mode: 0o755 }); } @@ -64,6 +88,7 @@ describe('restoreSession', () => { let tmpDir: string; let workspace: string; let binDir: string; + let wrapperLogPath: string; let savedEnv: Record; let originalFetch: typeof globalThis.fetch; @@ -74,22 +99,27 @@ describe('restoreSession', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'restore-test-')); workspace = path.join(tmpDir, 'workspace'); binDir = path.join(tmpDir, 'bin'); + wrapperLogPath = path.join(tmpDir, 'wrapper.log'); fs.mkdirSync(workspace, { recursive: true }); fs.mkdirSync(binDir, { recursive: true }); - writeMockKilo(binDir, 0); + writeMockKilo(binDir, { exitCode: 0 }); savedEnv = { + IMPORT_API_KEY: process.env.IMPORT_API_KEY, + IMPORT_SECRET: process.env.IMPORT_SECRET, KILO_SESSION_INGEST_URL: process.env.KILO_SESSION_INGEST_URL, KILOCODE_TOKEN: process.env.KILOCODE_TOKEN, KILOCODE_TOKEN_FILE: process.env.KILOCODE_TOKEN_FILE, PATH: process.env.PATH, + WRAPPER_LOG_PATH: process.env.WRAPPER_LOG_PATH, }; process.env.KILO_SESSION_INGEST_URL = 'http://localhost:9999'; process.env.KILOCODE_TOKEN = 'test-token'; delete process.env.KILOCODE_TOKEN_FILE; process.env.PATH = `${binDir}:${process.env.PATH}`; + process.env.WRAPPER_LOG_PATH = wrapperLogPath; originalFetch = globalThis.fetch; }); @@ -293,22 +323,358 @@ describe('restoreSession', () => { // ---- Import failures ---- - it('returns import error when kilo import fails', async () => { + it('logs failed import stderr diagnostics while preserving the generic result', async () => { const snapshot = makeSnapshot([{ file: 'src/index.ts', after: 'content', status: 'modified' }]); mockFetchOk(snapshot); - writeMockKilo(binDir, 1); + writeMockKilo(binDir, { exitCode: 1, stderr: 'recognizable stderr diagnostic' }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result).toEqual({ + ok: false, + error: 'kilo import failed exitCode=1', + code: null, + step: 'import', + }); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('kilo import finished outcome=error exitCode=1'); + expect(wrapperLog).toContain( + 'kilo import diagnostics stream=stderr totalBytes=30 retainedBytes=30 truncated=false preview="recognizable stderr diagnostic"' + ); + }); + + it('logs failed import stdout diagnostics', async () => { + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stdout: 'recognizable stdout diagnostic' }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain( + 'kilo import diagnostics stream=stdout totalBytes=30 retainedBytes=30 truncated=false preview="recognizable stdout diagnostic"' + ); + }); + + it('logs byte counts when a failed import has no diagnostic output', async () => { + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1 }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('kilo import diagnostics stdoutBytes=0 stderrBytes=0'); + }); + + it('redacts secrets and ANSI escapes from failed import diagnostics', async () => { + const kilocodeToken = 'configured-kilocode-token'; + const bearerToken = 'bearer-token-not-env'; + const basicToken = 'basic-token-not-env'; + const gitToken = 'git-token-not-env'; + const urlPassword = 'url-password-not-env'; + const tokenOnlyUrlSecret = 'token-only-url-secret-not-env'; + const signedUrlSecret = 'signed-url-secret-not-env'; + const abbreviatedSignedUrlSecret = 'azure-sas-secret-not-env'; + const assignmentSecret = 'correct horse; battery & staple@example.com'; + const privateKeyMaterial = 'private-key-material-not-env'; + const unfinishedPrivateKeyMaterial = 'unfinished-private-key-material-not-env'; + const jsonApiKey = 'json-api-key-not-env'; + const flagToken = 'flag-token-not-env'; + const envSecret = 'environment-secret'; + process.env.KILOCODE_TOKEN = kilocodeToken; + process.env.IMPORT_API_KEY = envSecret; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { + exitCode: 1, + stderr: [ + '\u001b[31mvisible diagnostic\u001b[0m', + `token=${kilocodeToken}`, + `authorization=Bearer ${bearerToken}`, + `basic=Basic ${basicToken}`, + `git=https://x-access-token:${gitToken}@github.com/org/repo.git`, + `url=https://user:${urlPassword}@example.com/path`, + `tokenOnlyUrl=https://${tokenOnlyUrlSecret}@github.com/org/repo.git`, + `signedUrl=https://example.com/export?X-Amz-Signature=${signedUrlSecret}`, + `abbreviatedSignedUrl=https://example.blob.core.windows.net/file?sv=1&sig=${abbreviatedSignedUrlSecret}`, + `password="${assignmentSecret}"`, + `SSH_PRIVATE_KEY=-----BEGIN OPENSSH PRIVATE KEY-----\n${privateKeyMaterial}\n-----END OPENSSH PRIVATE KEY-----`, + `{"apiKey":"${jsonApiKey}"}`, + `--token ${flagToken}`, + `\u009d0;terminal-title\u009cvisible after c1 controls`, + `env=${envSecret}`, + `UNFINISHED_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n${unfinishedPrivateKeyMaterial}`, + ].join('\n'), + }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result).toEqual({ + ok: false, + error: 'kilo import failed exitCode=1', + code: null, + step: 'import', + }); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('[REDACTED]'); + expect(wrapperLog).toContain('visible diagnostic'); + expect(wrapperLog).toContain( + 'abbreviatedSignedUrl=https://example.blob.core.windows.net/file?[REDACTED]' + ); + for (const secret of [ + kilocodeToken, + bearerToken, + basicToken, + gitToken, + urlPassword, + tokenOnlyUrlSecret, + signedUrlSecret, + abbreviatedSignedUrlSecret, + assignmentSecret, + privateKeyMaterial, + unfinishedPrivateKeyMaterial, + jsonApiKey, + flagToken, + envSecret, + ]) { + expect(wrapperLog).not.toContain(secret); + } + expect(wrapperLog).not.toContain('horse'); + expect(wrapperLog).not.toContain('battery'); + expect(wrapperLog).not.toContain('staple@example.com'); + expect(wrapperLog).not.toContain('\\u001b'); + expect(wrapperLog).not.toContain('\u009d'); + expect(wrapperLog).not.toContain('\u009c'); + }); + + it('redacts longer environment secrets before overlapping shorter values', async () => { + const shortSecret = 'overlap-secret'; + const longSecret = `${shortSecret}-suffix`; + process.env.IMPORT_SECRET = shortSecret; + process.env.IMPORT_API_KEY = longSecret; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: `secret=${longSecret}` }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('secret=[REDACTED]'); + expect(wrapperLog).not.toContain('-suffix'); + }); + + it('bounds diagnostics after secret redaction expands output', async () => { + process.env.IMPORT_SECRET = 'x'; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: 'x'.repeat(4_096) }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + const diagnosticLine = wrapperLog + .split('\n') + .find(line => line.includes('kilo import diagnostics stream=stderr')); + if (!diagnosticLine) throw new Error('missing stderr diagnostic line'); + expect(diagnosticLine).toContain('[REDACTED]'); + expect(diagnosticLine.length).toBeLessThan(5_000); + }); + + it('does not replace secrets inside redaction markers', async () => { + process.env.IMPORT_API_KEY = 'x'; + process.env.IMPORT_SECRET = 'A'; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: 'x' }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('preview="[REDACTED]"'); + }); + + it('redacts token-file credentials from failed import diagnostics', async () => { + const tokenPath = path.join(tmpDir, 'restore-token'); + const fileToken = 'token-file-secret'; + fs.writeFileSync(tokenPath, `${fileToken}\n`); + delete process.env.KILOCODE_TOKEN; + process.env.KILOCODE_TOKEN_FILE = tokenPath; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: `token=${fileToken}` }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('token=[REDACTED]'); + expect(wrapperLog).not.toContain(fileToken); + }); + + it('preserves import failure diagnostics when an optional token file cannot be read', async () => { + process.env.KILOCODE_TOKEN_FILE = path.join(tmpDir, 'missing-token'); + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: 'diagnostic survives token-file read failure' }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result).toEqual({ + ok: false, + error: 'kilo import failed exitCode=1', + code: null, + step: 'import', + }); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('diagnostic survives token-file read failure'); + }); + + it('drains and truncates large failed import output from both streams', async () => { + const retainedTail = 'retained-tail-sentinel'; + const largeOutput = `${'x'.repeat(256 * 1_024)}\n${retainedTail}`; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stdout: largeOutput, stderr: largeOutput }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(false); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain( + `kilo import diagnostics stream=stdout totalBytes=${largeOutput.length} retainedBytes=0 truncated=true` + ); + expect(wrapperLog).toContain( + `kilo import diagnostics stream=stderr totalBytes=${largeOutput.length} retainedBytes=0 truncated=true` + ); + expect(wrapperLog).not.toContain(retainedTail); + expect(wrapperLog).toContain('preview="[REDACTED]"'); + expect(wrapperLog.length).toBeLessThan(20_000); + }); + + it('terminates imports that exceed the diagnostic output budget', async () => { + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { + exitCode: 0, + stdout: 'x'.repeat(1_048_577), + sleepSeconds: 2, + }); const result = await restoreSession(SESSION_ID, workspace); expect(result.ok).toBe(false); if (!result.ok) { + expect(result.error).toContain('kilo import failed'); expect(result.step).toBe('import'); + } + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('terminationReason=excessive-output'); + }); + + it('terminates imports when combined stream output exceeds the diagnostic budget', async () => { + const streamOutput = 'x'.repeat(600 * 1_024); + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { + exitCode: 0, + stdout: streamOutput, + stderr: streamOutput, + sleepSeconds: 2, + }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(false); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('terminationReason=excessive-output'); + }); + + it('terminates imports that exceed the import deadline', async () => { + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 0, sleepSeconds: 2 }); + + const result = await restoreSession(SESSION_ID, workspace, undefined, { importTimeoutMs: 50 }); + + expect(result.ok).toBe(false); + if (!result.ok) { expect(result.error).toContain('kilo import failed'); + expect(result.step).toBe('import'); } + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('terminationReason=timeout'); + }); + + it('terminates pipe-owning descendants when an import exceeds its deadline', async () => { + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 0, backgroundSleepSeconds: 2, sleepSeconds: 2 }); + const startedAt = Date.now(); + + const result = await restoreSession(SESSION_ID, workspace, undefined, { importTimeoutMs: 50 }); + + expect(result.ok).toBe(false); + expect(Date.now() - startedAt).toBeLessThan(1_000); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('terminationReason=timeout'); + }); + + it('redacts a known secret suffix crossing the retained-tail boundary', async () => { + const crossingToken = 'cross-boundary-secret-value'; + const retainedTokenSuffix = crossingToken.slice(8); + const retainedTail = `${retainedTokenSuffix}${'y'.repeat(4_096 - retainedTokenSuffix.length)}`; + process.env.KILOCODE_TOKEN = crossingToken; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { + exitCode: 1, + stderr: `${'x'.repeat(4_096)}${crossingToken.slice(0, 8)}${retainedTail}`, + }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('preview="[REDACTED]"'); + expect(wrapperLog).not.toContain(retainedTokenSuffix); + }); + + it('redacts an unknown credential suffix crossing the retained-tail boundary', async () => { + const bearerToken = `unknown-bearer-${'z'.repeat(512)}-recognizable-suffix`; + const retainedTokenSuffix = bearerToken.slice(8); + const retainedTail = `${retainedTokenSuffix}${'y'.repeat(4_096 - retainedTokenSuffix.length)}`; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { + exitCode: 1, + stderr: `${'x'.repeat(4_096)}Bearer ${bearerToken.slice(0, 8)}${retainedTail}`, + }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('preview="[REDACTED]"'); + expect(wrapperLog).not.toContain('-recognizable-suffix'); + }); + + it('redacts multiline credential tails whose opening marker was truncated', async () => { + const privateKeyTailLine = 'private-key-tail-line-not-env'; + const retainedTail = + `partial-private-key-line\n${privateKeyTailLine}\n-----END PRIVATE KEY-----`.padEnd( + 4_096, + 'y' + ); + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 1, stderr: `${'x'.repeat(4_096)}${retainedTail}` }); + + await restoreSession(SESSION_ID, workspace); + + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('preview="[REDACTED]"'); + expect(wrapperLog).not.toContain(privateKeyTailLine); }); // ---- Happy paths ---- + it('drains but does not log successful import output', async () => { + const stdout = `${'x'.repeat(256 * 1_024)}successful stdout sentinel`; + const stderr = `${'y'.repeat(256 * 1_024)}successful stderr sentinel`; + mockFetchOk(makeSnapshot([])); + writeMockKilo(binDir, { exitCode: 0, stdout, stderr }); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(true); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).not.toContain('successful stdout sentinel'); + expect(wrapperLog).not.toContain('successful stderr sentinel'); + expect(wrapperLog).not.toContain('kilo import diagnostics'); + }); + it('downloads snapshot, imports, and applies diffs', async () => { const snapshot = makeSnapshot([ { file: 'src/index.ts', after: "console.log('hello');", status: 'modified' }, @@ -458,7 +824,7 @@ describe('restoreSession', () => { it('cleans up temp file on import failure', async () => { mockFetchOk(makeSnapshot([{ file: 'a.txt', after: 'content', status: 'modified' }])); - writeMockKilo(binDir, 1); + writeMockKilo(binDir, { exitCode: 1 }); const result = await restoreSession(SESSION_ID, workspace); expect(result.ok).toBe(false); diff --git a/services/cloud-agent-next/wrapper/src/restore-session.ts b/services/cloud-agent-next/wrapper/src/restore-session.ts index 0dd2abb641..e9d825d48d 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -15,6 +15,10 @@ export type RestoreResult = } | { ok: false; error: string; code: number | null; step: 'download' | 'import' | 'diffs' }; +type RestoreSessionOptions = { + importTimeoutMs?: number; +}; + type SnapshotDiff = { file: string; after: string; @@ -31,6 +35,187 @@ function log(msg: string): void { logToFile(message); } +const IMPORT_DIAGNOSTIC_MAX_BYTES = 4_096; +const IMPORT_DIAGNOSTIC_MAX_OBSERVED_BYTES = 1_048_576; +const IMPORT_TIMEOUT_MS = 300_000; +const REDACTED = '[REDACTED]'; +const REDACTION_SENTINEL = String.fromCharCode(0); +const SENSITIVE_NAME_PATTERN = + 'TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTHORIZATION|COOKIE|API[_-]?KEY|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|SIGNATURE'; +const SENSITIVE_ENV_NAME = new RegExp(SENSITIVE_NAME_PATTERN, 'i'); +const SENSITIVE_DIAGNOSTIC_ASSIGNMENT = new RegExp( + `(["']?\\b[\\w.-]*(?:${SENSITIVE_NAME_PATTERN})[\\w.-]*["']?\\s*[:=]\\s*)[^\\r\\n]*`, + 'gi' +); +const SENSITIVE_DIAGNOSTIC_FLAG = new RegExp( + `(^|\\s)(--?[\\w.-]*(?:${SENSITIVE_NAME_PATTERN})[\\w.-]*)(?:[=\\s]+)[^\\r\\n]*`, + 'gim' +); +const PRIVATE_KEY_BLOCK = + /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?(?:-----END(?: [A-Z0-9]+)* PRIVATE KEY-----|$)/gi; +type ImportTerminationReason = 'timeout' | 'excessive-output'; +const ANSI_ESCAPE = String.fromCharCode(0x1b); +const ANSI_BELL = String.fromCharCode(0x07); +const ANSI_CONTROL_SEQUENCE_INTRODUCER = String.fromCharCode(0x9b); +const ANSI_ESCAPE_SEQUENCE = new RegExp( + `${ANSI_ESCAPE}(?:\\][^${ANSI_BELL}]*(?:${ANSI_BELL}|${ANSI_ESCAPE}\\\\)|\\[[0-?]*[ -/]*[@-~]|[@-_])|${ANSI_CONTROL_SEQUENCE_INTRODUCER}[0-?]*[ -/]*[@-~]`, + 'g' +); + +type BoundedOutputTail = { + preview: string; + totalBytes: number; + retainedBytes: number; + truncated: boolean; +}; + +type BoundedOutputTailOptions = { + observeBytes: (bytes: number) => void; +}; + +async function readBoundedOutputTail( + stream: ReadableStream, + maxBytes: number, + options?: BoundedOutputTailOptions +): Promise { + const reader = stream.getReader(); + let retained = new Uint8Array(); + let totalBytes = 0; + let truncated = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.byteLength; + options?.observeBytes(value.byteLength); + if (truncated) continue; + if (totalBytes > maxBytes) { + truncated = true; + retained = new Uint8Array(); + continue; + } + + const nextRetained = new Uint8Array(retained.byteLength + value.byteLength); + nextRetained.set(retained); + nextRetained.set(value, retained.byteLength); + retained = nextRetained; + } + + return { + preview: truncated ? REDACTED : new TextDecoder().decode(retained), + totalBytes, + retainedBytes: retained.byteLength, + truncated, + }; +} + +function collectImportDiagnosticSecrets(): string[] { + const secrets = new Set(); + const token = process.env.KILOCODE_TOKEN; + if (token) secrets.add(token); + + for (const [name, value] of Object.entries(process.env)) { + if (value && SENSITIVE_ENV_NAME.test(name)) secrets.add(value); + } + + const tokenFile = process.env.KILOCODE_TOKEN_FILE; + if (tokenFile) { + try { + const fileToken = fs.readFileSync(tokenFile, 'utf8').replace(/[\r\n]+$/, ''); + if (fileToken) secrets.add(fileToken); + } catch { + // Best-effort redaction must not replace the original import failure. + } + } + + return Array.from(secrets).sort((left, right) => right.length - left.length); +} + +function stripUnsafeControlCharacters(diagnostic: string): string { + let sanitized = ''; + for (const character of diagnostic) { + const codePoint = character.codePointAt(0); + if ( + codePoint !== undefined && + ((codePoint >= 0x00 && codePoint <= 0x08) || + codePoint === 0x0b || + codePoint === 0x0c || + (codePoint >= 0x0e && codePoint <= 0x1f) || + (codePoint >= 0x7f && codePoint <= 0x9f)) + ) { + continue; + } + sanitized += character; + } + return sanitized; +} + +function redactKnownImportDiagnosticSecrets(diagnostic: string, secrets: string[]): string { + let sanitized = diagnostic; + for (const secret of secrets) { + sanitized = sanitized.replaceAll(secret, REDACTION_SENTINEL); + } + return sanitized.replaceAll(REDACTION_SENTINEL, REDACTED); +} + +function retainSanitizedImportDiagnosticTail(diagnostic: string): string { + const encoder = new TextEncoder(); + const encoded = encoder.encode(diagnostic); + if (encoded.byteLength <= IMPORT_DIAGNOSTIC_MAX_BYTES) return diagnostic; + + const markerBytes = encoder.encode(REDACTED).byteLength; + const retainedByteLimit = IMPORT_DIAGNOSTIC_MAX_BYTES - markerBytes; + let retained = new TextDecoder().decode(encoded.slice(-retainedByteLimit)); + while (encoder.encode(`${REDACTED}${retained}`).byteLength > IMPORT_DIAGNOSTIC_MAX_BYTES) { + retained = retained.slice(1); + } + return `${REDACTED}${retained}`; +} + +function sanitizeImportDiagnostic(diagnostic: string, truncated: boolean): string { + const secrets = collectImportDiagnosticSecrets(); + let sanitized = stripUnsafeControlCharacters(diagnostic.replace(ANSI_ESCAPE_SEQUENCE, '')); + + if (truncated) { + sanitized = REDACTED; + } + + sanitized = redactKnownImportDiagnosticSecrets(sanitized, secrets) + .replace(PRIVATE_KEY_BLOCK, REDACTED) + .replace(/\b(Bearer|Basic)\s+[^\s"',;]+/gi, `$1 ${REDACTED}`) + .replace(/\b(oauth2|x-access-token|x-token-auth):[^@\s]+@/gi, `$1:${REDACTED}@`) + .replace(/([a-z][a-z\d+.-]*:\/\/)[^/\s@]+@/gi, `$1${REDACTED}@`) + .replace(/([a-z][a-z\d+.-]*:\/\/[^\s"'?#]+)\?[^\s"']*/gi, `$1?${REDACTED}`) + .replace(/([a-z][a-z\d+.-]*:\/\/[^\s"'#]+)#[^\s"']*/gi, `$1#${REDACTED}`) + .replace(SENSITIVE_DIAGNOSTIC_ASSIGNMENT, `$1${REDACTED}`) + .replace(SENSITIVE_DIAGNOSTIC_FLAG, `$1$2 ${REDACTED}`); + + return retainSanitizedImportDiagnosticTail(sanitized); +} + +function logImportDiagnostics(stdout: BoundedOutputTail, stderr: BoundedOutputTail): void { + const streams = [ + { name: 'stdout', output: stdout }, + { name: 'stderr', output: stderr }, + ]; + let loggedPreview = false; + + for (const { name, output } of streams) { + if (!output.preview) continue; + loggedPreview = true; + log( + `kilo import diagnostics stream=${name} totalBytes=${output.totalBytes} retainedBytes=${output.retainedBytes} truncated=${output.truncated} preview=${JSON.stringify(sanitizeImportDiagnostic(output.preview, output.truncated))}` + ); + } + + if (!loggedPreview) { + log( + `kilo import diagnostics stdoutBytes=${stdout.totalBytes} stderrBytes=${stderr.totalBytes}` + ); + } +} + function fail( error: string, code: number | null, @@ -411,7 +596,8 @@ async function extractDiffsWithBun(snapshotPath: string): Promise { const tmpPath = filePath ?? `/tmp/kilo-session-export-${kiloSessionId}.json`; const downloaded = !filePath; @@ -511,14 +697,47 @@ export async function restoreSession( stderr: 'pipe', cwd: workspacePath, env: process.env, + detached: true, }); - const exitCode = await importProc.exited; + let importTerminationReason: ImportTerminationReason | undefined; + const terminateImport = (reason: ImportTerminationReason): void => { + if (importTerminationReason) return; + importTerminationReason = reason; + try { + process.kill(-importProc.pid, 'SIGKILL'); + } catch { + try { + importProc.kill(9); + } catch { + // Process may have exited between the limit check and termination. + } + } + }; + const importTimeout = setTimeout( + () => terminateImport('timeout'), + options.importTimeoutMs ?? IMPORT_TIMEOUT_MS + ); + let observedDiagnosticBytes = 0; + const diagnosticDrainOptions = { + observeBytes: (bytes: number) => { + observedDiagnosticBytes += bytes; + if (observedDiagnosticBytes > IMPORT_DIAGNOSTIC_MAX_OBSERVED_BYTES) { + terminateImport('excessive-output'); + } + }, + }; + const [exitCode, stdout, stderr] = await Promise.all([ + importProc.exited, + readBoundedOutputTail(importProc.stdout, IMPORT_DIAGNOSTIC_MAX_BYTES, diagnosticDrainOptions), + readBoundedOutputTail(importProc.stderr, IMPORT_DIAGNOSTIC_MAX_BYTES, diagnosticDrainOptions), + ]).finally(() => clearTimeout(importTimeout)); const importElapsedMs = Date.now() - importStartedAt; - if (exitCode !== 0) { + if (exitCode !== 0 || importTerminationReason) { log( - `kilo import finished outcome=error exitCode=${exitCode} kiloSessionId=${kiloSessionId} input=${downloaded ? 'downloaded' : 'provided'} cwd=${workspacePath} home=${process.env.HOME ?? '(unset)'} elapsedMs=${importElapsedMs}` + `kilo import finished outcome=error exitCode=${exitCode} kiloSessionId=${kiloSessionId} input=${downloaded ? 'downloaded' : 'provided'} cwd=${workspacePath} home=${process.env.HOME ?? '(unset)'} elapsedMs=${importElapsedMs} terminationReason=${importTerminationReason ?? '(none)'}` ); + logImportDiagnostics(stdout, stderr); return fail(`kilo import failed exitCode=${exitCode}`, null, 'import'); } log(