diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index dfbdf084fd..3bf8104af2 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -11,6 +11,15 @@ import { summarizeCommandAttemptFailures, } from './errors.ts'; +test('normalizeError retains redacted nested command errors through repeated normalization', () => { + const stderr = `Launch request denied\n${'Context line\n'.repeat(50)}Underlying reason: token=private-value device locked`; + const expected = stderr.replace('private-value', '[REDACTED]'); + const first = normalizeError(new AppError('COMMAND_FAILED', 'Launch failed', { stderr })); + const second = normalizeError(new AppError(first.code, first.message, first.details)); + assert.equal(first.details?.stderr, expected); + assert.equal(second.details?.stderr, expected); +}); + test('readElementMatchCandidateRefs extracts refs from candidate lines', () => { assert.deepEqual( readElementMatchCandidateRefs({ diff --git a/packages/kernel/src/redaction.test.ts b/packages/kernel/src/redaction.test.ts new file mode 100644 index 0000000000..288f298f1f --- /dev/null +++ b/packages/kernel/src/redaction.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { redactDiagnosticData } from './redaction.ts'; + +test('stderr retains nested command failure details beyond 400 characters', () => { + const stderr = `Launch request denied\n${'Context line\n'.repeat(50)}Underlying reason: device locked`; + assert.equal(redactDiagnosticData({ stderr }).stderr, stderr); +}); + +test('stderr preserves its head and tail within an 8192-character budget', () => { + const stderr = `Launch request denied\n${'x'.repeat(9000)}\nUnderlying reason: device locked`; + const result = redactDiagnosticData({ stderr }); + assert.equal(result.stderr.length, 8192); + assert.ok(result.stderr.startsWith('Launch request denied\n')); + assert.ok(result.stderr.endsWith('\nUnderlying reason: device locked')); + assert.match(result.stderr, //); + assert.deepEqual(redactDiagnosticData(result), result); +}); + +test('stderr at the budget is preserved without truncation', () => { + const stderr = 'x'.repeat(8192); + assert.equal(redactDiagnosticData({ stderr }).stderr, stderr); +}); + +test('expanded stderr redacts secrets before retaining its head and tail', () => { + const stderr = [ + 'Launch request denied token=head-secret', + 'x'.repeat(500), + 'password=middle-secret', + 'x'.repeat(9000), + 'Underlying reason: bearer tail-secret', + 'https://user:pass@example.com/error?token=query-secret', + ].join('\n'); + const result = redactDiagnosticData({ nested: { stderr }, apiKey: 'field-secret' }); + assert.equal(result.apiKey, '[REDACTED]'); + assert.ok(result.nested.stderr.startsWith('Launch request denied token=[REDACTED]')); + assert.ok(result.nested.stderr.includes('Underlying reason: [REDACTED]')); + assert.ok(result.nested.stderr.endsWith('https://REDACTED:REDACTED@example.com/error?REDACTED')); + assert.doesNotMatch(result.nested.stderr, /head-secret|middle-secret|tail-secret|query-secret/); +}); + +test('other diagnostic strings retain the 400-character limit', () => { + const result = redactDiagnosticData({ message: 'm'.repeat(500), stdout: 'o'.repeat(500) }); + assert.equal(result.message.length, 400); + assert.equal(result.stdout.length, 400); + assert.ok(result.message.endsWith('...')); + assert.ok(result.stdout.endsWith('...')); +}); diff --git a/packages/kernel/src/redaction.ts b/packages/kernel/src/redaction.ts index c002e17c31..7889a19f04 100644 --- a/packages/kernel/src/redaction.ts +++ b/packages/kernel/src/redaction.ts @@ -6,6 +6,7 @@ const SENSITIVE_ASSIGNMENT_RE = /\b([a-z0-9_-]*(?:api[_-]?key|token|secret|password|user[_-]?code|device[_-]?code|refresh[_-]?credential)[a-z0-9_-]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi; const URL_RE = /https?:\/\/[^\s"'<>]+/gi; const REDACTED_STRING_MAX_LENGTH = 400; +const REDACTED_STDERR_MAX_LENGTH = 8192; const TRUNCATION_SUFFIX = '...'; export function redactDiagnosticData(input: T): T { @@ -50,7 +51,7 @@ function redactValue(value: unknown, seen: WeakSet, keyHint?: string): u function redactString(value: string, keyHint?: string): string { const trimmed = value.trim(); - if (!trimmed) return boundRedactedString(value); + if (!trimmed) return boundRedactedString(value, keyHint); if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]'; let output = redactUrls(trimmed); output = output.replace(SECRET_TOKEN_RE, '[REDACTED]'); @@ -62,10 +63,17 @@ function redactString(value: string, keyHint?: string): string { return `${key}${separator}[REDACTED]`; }, ); - return boundRedactedString(output); + return boundRedactedString(output, keyHint); } -function boundRedactedString(value: string): string { +function boundRedactedString(value: string, keyHint?: string): string { + if (keyHint === 'stderr') { + if (value.length <= REDACTED_STDERR_MAX_LENGTH) return value; + const marker = `\n${TRUNCATION_SUFFIX}\n`; + const retainedLength = REDACTED_STDERR_MAX_LENGTH - marker.length; + const headLength = Math.ceil(retainedLength / 2); + return `${value.slice(0, headLength)}${marker}${value.slice(-(retainedLength - headLength))}`; + } if (value.length <= REDACTED_STRING_MAX_LENGTH) return value; return `${value.slice(0, REDACTED_STRING_MAX_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`; } diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index aa2e30c539..f7cb7f2e2a 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -81,6 +81,8 @@ Use this flow when you need a clean repro window with logs, recent network activ `open` prints `Session state: `. Inspect that directory for per-run artifacts: `requests/.ndjson` contains daemon request diagnostics, `runner.log` contains Apple runner/`xcodebuild` output, and `app.log` contains app/device logs when log capture is active. The top-level daemon log is for daemon lifecycle/startup issues. +For command failures, use `--json` to inspect `error.details.stderr` when available. Diagnostic stderr is secret-redacted and retains up to 8,192 characters; longer output keeps its beginning and end with a truncation marker between them. Other diagnostic strings remain limited to 400 characters. Compact retry summaries may retain less output. + On iOS simulators, `logs` scope by bundle id and the resolved app executable. For launch-time stdout/stderr, capture the direct app launch console instead of starting raw `simctl` streams: ```bash