diff --git a/services/cloud-agent-next/DEBUG.md b/services/cloud-agent-next/DEBUG.md index 7994895e3..b1ecb4c9c 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/test/unit/wrapper/utils.test.ts b/services/cloud-agent-next/test/unit/wrapper/utils.test.ts index 6ddf9e69e..2d4b6a90a 100644 --- a/services/cloud-agent-next/test/unit/wrapper/utils.test.ts +++ b/services/cloud-agent-next/test/unit/wrapper/utils.test.ts @@ -23,6 +23,32 @@ describe('runProcess', () => { expect(result).toEqual({ stdout: 'hello\n', stderr: '', exitCode: 0 }); }); + + it('rejects invalid bounded output limits before spawning', async () => { + await expect( + runProcess('missing-command', [], { maxCapturedOutputBytes: Number.NaN }) + ).rejects.toThrow('maxCapturedOutputBytes must be a non-negative safe integer'); + await expect( + runProcess('missing-command', [], { + maxCapturedOutputBytes: 1, + maxObservedOutputBytes: -1, + }) + ).rejects.toThrow('maxObservedOutputBytes must be a non-negative safe integer'); + }); + + it('accepts a zero-byte capture limit', async () => { + const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("x")'], { + maxCapturedOutputBytes: 0, + }); + + expect(result.stdout).toBe(''); + expect(result.boundedOutput.stdout).toEqual({ + preview: '', + totalBytes: 1, + retainedBytes: 0, + truncated: true, + }); + }); }); describe('git', () => { 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 b20f972df..9c1ef3cec 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -50,14 +50,34 @@ 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; + 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.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 }); } function writeSlowMockKilo(binDir: string): void { - const script = '#!/bin/sh\nsleep 1\nexit 0\n'; + const script = "#!/bin/sh\nprintf '%s' 'recognizable timeout diagnostic' >&2\nsleep 1\nexit 0\n"; const kiloPath = path.join(binDir, 'kilo'); fs.writeFileSync(kiloPath, script, { mode: 0o755 }); } @@ -83,6 +103,7 @@ describe('restoreSession', () => { let tmpDir: string; let workspace: string; let binDir: string; + let wrapperLogPath: string; let savedEnv: Record; let originalFetch: typeof globalThis.fetch; @@ -93,22 +114,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; }); @@ -312,25 +338,319 @@ 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}`; + process.env.IMPORT_SECRET = 'A'; + 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.step).toBe('import'); 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('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); }); it('terminates and returns import error when kilo import exceeds its deadline', async () => { mockFetchOk(makeSnapshot([])); writeSlowMockKilo(binDir); - const result = await restoreSession(SESSION_ID, workspace, undefined, { importTimeoutMs: 50 }); + const result = await restoreSession(SESSION_ID, workspace, undefined, { importTimeoutMs: 500 }); expect(result.ok).toBe(false); if (!result.ok) { @@ -338,6 +658,8 @@ describe('restoreSession', () => { expect(result.error).toContain('kilo import timed out'); } expect(fs.existsSync(TMP_PATH)).toBe(false); + const wrapperLog = fs.readFileSync(wrapperLogPath, 'utf8'); + expect(wrapperLog).toContain('recognizable timeout diagnostic'); }); it('returns import error when kilo import is terminated by a signal', async () => { @@ -376,6 +698,21 @@ describe('restoreSession', () => { // ---- 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' }, @@ -525,7 +862,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 e98bf11d1..7c6eced42 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { logToFile, runProcess } from './utils.js'; +import { logToFile, runProcess, type BoundedProcessOutput } from './utils.js'; // --------------------------------------------------------------------------- // Types @@ -38,6 +38,137 @@ function log(msg: string): void { logToFile(message); } +const IMPORT_DIAGNOSTIC_MAX_BYTES = 4_096; +const IMPORT_DIAGNOSTIC_MAX_OBSERVED_BYTES = 1_048_576; +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; +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' +); + +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, secrets: string[]): string { + const sanitized = redactKnownImportDiagnosticSecrets( + stripUnsafeControlCharacters(diagnostic.replace(ANSI_ESCAPE_SEQUENCE, '')), + 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: BoundedProcessOutput, stderr: BoundedProcessOutput): void { + const streams = [ + { name: 'stdout', output: stdout }, + { name: 'stderr', output: stderr }, + ]; + let loggedPreview = false; + let secrets: string[] | undefined; + + for (const { name, output } of streams) { + if (output.totalBytes === 0) continue; + loggedPreview = true; + const preview = output.truncated + ? REDACTED + : sanitizeImportDiagnostic(output.preview, (secrets ??= collectImportDiagnosticSecrets())); + log( + `kilo import diagnostics stream=${name} totalBytes=${output.totalBytes} retainedBytes=${output.retainedBytes} truncated=${output.truncated} preview=${JSON.stringify(preview)}` + ); + } + + if (!loggedPreview) { + log( + `kilo import diagnostics stdoutBytes=${stdout.totalBytes} stderrBytes=${stderr.totalBytes}` + ); + } +} + function fail( error: string, code: number | null, @@ -519,6 +650,8 @@ export async function restoreSession( cwd: workspacePath, timeoutMs: importTimeoutMs, terminationGraceMs: options.importTerminationGraceMs, + maxCapturedOutputBytes: IMPORT_DIAGNOSTIC_MAX_BYTES, + maxObservedOutputBytes: IMPORT_DIAGNOSTIC_MAX_OBSERVED_BYTES, }); const importElapsedMs = Date.now() - importStartedAt; @@ -526,13 +659,15 @@ export async function restoreSession( log( `kilo import finished outcome=timeout kiloSessionId=${kiloSessionId} input=${downloaded ? 'downloaded' : 'provided'} cwd=${workspacePath} home=${process.env.HOME ?? '(unset)'} elapsedMs=${importElapsedMs} timeoutMs=${importTimeoutMs}` ); + logImportDiagnostics(importResult.boundedOutput.stdout, importResult.boundedOutput.stderr); return fail(`kilo import timed out after ${importTimeoutMs}ms`, null, 'import'); } if (importResult.exitCode !== 0) { log( - `kilo import finished outcome=error exitCode=${importResult.exitCode} kiloSessionId=${kiloSessionId} input=${downloaded ? 'downloaded' : 'provided'} cwd=${workspacePath} home=${process.env.HOME ?? '(unset)'} elapsedMs=${importElapsedMs}` + `kilo import finished outcome=error exitCode=${importResult.exitCode} kiloSessionId=${kiloSessionId} input=${downloaded ? 'downloaded' : 'provided'} cwd=${workspacePath} home=${process.env.HOME ?? '(unset)'} elapsedMs=${importElapsedMs} terminationReason=${importResult.terminationReason ?? '(none)'}` ); + logImportDiagnostics(importResult.boundedOutput.stdout, importResult.boundedOutput.stderr); return fail(`kilo import failed exitCode=${importResult.exitCode}`, null, 'import'); } log( diff --git a/services/cloud-agent-next/wrapper/src/utils.ts b/services/cloud-agent-next/wrapper/src/utils.ts index 6b6f49af4..c0bcfe240 100644 --- a/services/cloud-agent-next/wrapper/src/utils.ts +++ b/services/cloud-agent-next/wrapper/src/utils.ts @@ -1,11 +1,24 @@ import { spawn } from 'child_process'; import { appendFileSync } from 'fs'; +export type BoundedProcessOutput = { + preview: string; + totalBytes: number; + retainedBytes: number; + truncated: boolean; +}; + +export type BoundedProcessResult = { + stdout: BoundedProcessOutput; + stderr: BoundedProcessOutput; +}; + export type ExecResult = { stdout: string; stderr: string; exitCode: number; terminationReason?: TerminationReason; + boundedOutput?: BoundedProcessResult; }; export type ProcessOptions = { @@ -15,6 +28,20 @@ export type ProcessOptions = { terminationGraceMs?: number; }; +export type BoundedProcessOptions = ProcessOptions & { + maxCapturedOutputBytes: number; + maxObservedOutputBytes?: number; +}; + +export type BoundedExecResult = ExecResult & { + boundedOutput: BoundedProcessResult; +}; + +type RunProcessOptions = ProcessOptions & { + maxCapturedOutputBytes?: number; + maxObservedOutputBytes?: number; +}; + export type GitOptions = ProcessOptions; export type TimeoutAbortOptions = { @@ -29,25 +56,103 @@ const EXEC_TERMINATION_GRACE_MS = 2_000; const EXEC_TERMINATION_POLL_MS = 25; const EXEC_TIMEOUT_MESSAGE = 'exec timeout reached'; const EXEC_ABORTED_MESSAGE = 'exec aborted'; +const EXEC_OUTPUT_LIMIT_MESSAGE = 'exec output limit reached'; -export type TerminationReason = 'timeout' | 'abort'; +export type TerminationReason = 'timeout' | 'abort' | 'excessive-output'; function withStderrSuffix(stderr: string, suffix: string): string { return `${stderr}${stderr.endsWith('\n') || stderr.length === 0 ? '' : '\n'}${suffix}`; } +function validateOutputLimit(name: string, value: number | undefined): void { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + throw new RangeError(`${name} must be a non-negative safe integer`); + } +} + +function createBoundedOutputCollector(maxBytes: number): { + observe: (chunk: Buffer) => void; + value: () => BoundedProcessOutput; +} { + let retained = Buffer.alloc(0); + let totalBytes = 0; + let truncated = false; + + return { + observe: chunk => { + totalBytes += chunk.byteLength; + if (truncated) return; + if (totalBytes > maxBytes) { + retained = Buffer.alloc(0); + truncated = true; + return; + } + retained = Buffer.concat([retained, chunk]); + }, + value: () => ({ + preview: retained.toString('utf8'), + totalBytes, + retainedBytes: retained.byteLength, + truncated, + }), + }; +} + +export function runProcess( + command: string, + args: string[], + opts: BoundedProcessOptions +): Promise; export function runProcess( command: string, args: string[], opts?: ProcessOptions +): Promise; +export async function runProcess( + command: string, + args: string[], + opts?: RunProcessOptions ): Promise { + validateOutputLimit('maxCapturedOutputBytes', opts?.maxCapturedOutputBytes); + validateOutputLimit('maxObservedOutputBytes', opts?.maxObservedOutputBytes); + if (opts?.maxObservedOutputBytes !== undefined && opts.maxCapturedOutputBytes === undefined) { + throw new RangeError('maxObservedOutputBytes requires maxCapturedOutputBytes'); + } + + const stdoutCollector = + opts?.maxCapturedOutputBytes === undefined + ? undefined + : createBoundedOutputCollector(opts.maxCapturedOutputBytes); + const stderrCollector = + opts?.maxCapturedOutputBytes === undefined + ? undefined + : createBoundedOutputCollector(opts.maxCapturedOutputBytes); + const createResult = ( + stdout: string, + stderr: string, + exitCode: number, + terminationReason?: TerminationReason, + stderrSuffix?: string + ): ExecResult => { + const boundedOutput = + stdoutCollector && stderrCollector + ? { stdout: stdoutCollector.value(), stderr: stderrCollector.value() } + : undefined; + const resultStderr = boundedOutput?.stderr.preview ?? stderr; + const result: ExecResult = { + stdout: boundedOutput?.stdout.preview ?? stdout, + stderr: stderrSuffix ? withStderrSuffix(resultStderr, stderrSuffix) : resultStderr, + exitCode, + }; + if (terminationReason) result.terminationReason = terminationReason; + if (boundedOutput) result.boundedOutput = boundedOutput; + return result; + }; + if (opts?.signal?.aborted) { - return Promise.resolve({ - stdout: '', - stderr: EXEC_ABORTED_MESSAGE, - exitCode: EXEC_TIMEOUT_EXIT_CODE, - terminationReason: 'abort', - }); + return Promise.resolve( + createResult('', '', EXEC_TIMEOUT_EXIT_CODE, 'abort', EXEC_ABORTED_MESSAGE) + ); } return new Promise((resolve, reject) => { @@ -62,6 +167,7 @@ export function runProcess( let terminationReason: TerminationReason | null = null; let terminationTimer: ReturnType | undefined; let terminationPollTimer: ReturnType | undefined; + let observedOutputBytes = 0; function abortHandler(): void { terminate('abort'); @@ -89,15 +195,13 @@ export function runProcess( clearTimers(); removeAbortHandler(); if (destroyOpenPipes) destroyPipes(); - resolve({ - stdout, - stderr: withStderrSuffix( - stderr, - reason === 'timeout' ? EXEC_TIMEOUT_MESSAGE : EXEC_ABORTED_MESSAGE - ), - exitCode: EXEC_TIMEOUT_EXIT_CODE, - terminationReason: reason, - }); + const message = + reason === 'timeout' + ? EXEC_TIMEOUT_MESSAGE + : reason === 'abort' + ? EXEC_ABORTED_MESSAGE + : EXEC_OUTPUT_LIMIT_MESSAGE; + resolve(createResult(stdout, stderr, EXEC_TIMEOUT_EXIT_CODE, reason, message)); }; const killProcess = (signal: NodeJS.Signals): void => { @@ -134,10 +238,12 @@ export function runProcess( terminationReason = reason; if (timer) clearTimeout(timer); killProcess('SIGTERM'); + const terminationGraceMs = + reason === 'excessive-output' ? 0 : (opts?.terminationGraceMs ?? EXEC_TERMINATION_GRACE_MS); terminationTimer = setTimeout(() => { killProcess('SIGKILL'); resolveTermination(true); - }, opts?.terminationGraceMs ?? EXEC_TERMINATION_GRACE_MS); + }, terminationGraceMs); }; const timer = @@ -145,8 +251,25 @@ export function runProcess( ? setTimeout(() => terminate('timeout'), opts.timeoutMs) : undefined; - proc.stdout.on('data', d => (stdout += d)); - proc.stderr.on('data', d => (stderr += d)); + const observeOutput = (chunk: Buffer): void => { + observedOutputBytes += chunk.byteLength; + if ( + opts?.maxObservedOutputBytes !== undefined && + observedOutputBytes > opts.maxObservedOutputBytes + ) { + terminate('excessive-output'); + } + }; + proc.stdout.on('data', (chunk: Buffer) => { + stdoutCollector?.observe(chunk); + if (!stdoutCollector) stdout += chunk.toString('utf8'); + observeOutput(chunk); + }); + proc.stderr.on('data', (chunk: Buffer) => { + stderrCollector?.observe(chunk); + if (!stderrCollector) stderr += chunk.toString('utf8'); + observeOutput(chunk); + }); if (opts?.signal) { if (opts.signal.aborted) { @@ -164,7 +287,7 @@ export function runProcess( settled = true; clearTimers(); removeAbortHandler(); - resolve({ stdout, stderr, exitCode: code ?? (signal === null ? 0 : 1) }); + resolve(createResult(stdout, stderr, code ?? (signal === null ? 0 : 1))); }); proc.on('error', err => { if (!settled) {