Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/kernel/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions packages/kernel/src/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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, /<truncated>/);
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('...<truncated>'));
assert.ok(result.stdout.endsWith('...<truncated>'));
});
14 changes: 11 additions & 3 deletions packages/kernel/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '...<truncated>';

export function redactDiagnosticData<T>(input: T): T {
Expand Down Expand Up @@ -50,7 +51,7 @@ function redactValue(value: unknown, seen: WeakSet<object>, 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]');
Expand All @@ -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))}`;

@cubic-dev-ai cubic-dev-ai Bot Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new head/tail truncation can split an emoji at either retained boundary, so JSON consumers receive an unpaired surrogate and the diagnostic character is corrupted. Trim each retained boundary when it cuts a surrogate pair before concatenating the marker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/kernel/src/redaction.ts, line 75:

<comment>The new head/tail truncation can split an emoji at either retained boundary, so JSON consumers receive an unpaired surrogate and the diagnostic character is corrupted. Trim each retained boundary when it cuts a surrogate pair before concatenating the marker.</comment>

<file context>
@@ -62,10 +63,17 @@ function redactString(value: string, keyHint?: string): string {
+    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;
</file context>
Suggested change
return `${value.slice(0, headLength)}${marker}${value.slice(-(retainedLength - headLength))}`;
const head = value.slice(0, headLength).replace(/[\uD800-\uDBFF]$/, '');
const tail = value.slice(-(retainedLength - headLength)).replace(/^[\uDC00-\uDFFF]/, '');
return `${head}${marker}${tail}`;
Fix with cubic

}
if (value.length <= REDACTED_STRING_MAX_LENGTH) return value;
return `${value.slice(0, REDACTED_STRING_MAX_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`;
}
Expand Down
2 changes: 2 additions & 0 deletions website/docs/docs/debugging-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ Use this flow when you need a clean repro window with logs, recent network activ

`open` prints `Session state: <path>`. Inspect that directory for per-run artifacts: `requests/<request-id>.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
Expand Down
Loading