diff --git a/dato-agent/src/components/AgentSurface.module.css b/dato-agent/src/components/AgentSurface.module.css
index 860a527a..f98dd120 100644
--- a/dato-agent/src/components/AgentSurface.module.css
+++ b/dato-agent/src/components/AgentSurface.module.css
@@ -644,6 +644,51 @@
line-height: 1.4;
}
+.diagnosticsReview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ margin: var(--spacing-s) 0 0;
+ padding: var(--spacing-s);
+ border: 1px solid var(--color--border);
+ border-radius: var(--border-radius-m, 6px);
+ font-size: var(--font-size-xs);
+}
+
+.diagnosticsReview legend {
+ padding: 0 var(--spacing-xs);
+ color: var(--color--ink);
+ font-weight: 600;
+}
+
+.diagnosticsReviewItem {
+ display: flex;
+ align-items: baseline;
+ gap: var(--spacing-xs);
+ color: var(--color--ink-subtle);
+}
+
+.diagnosticsReviewHint {
+ margin: 0;
+ color: var(--color--ink-subtle);
+}
+
+.diagnosticsReviewItem .diagnosticsReviewAction {
+ flex: none;
+ color: var(--color--ink);
+ font-weight: 600;
+}
+
+.diagnosticsReviewItem code {
+ flex: none;
+ white-space: nowrap;
+}
+
+.diagnosticsReviewItem span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
.activityDisclosure {
color: var(--color--ink-subtle);
font-size: var(--font-size-s);
diff --git a/dato-agent/src/components/AgentSurface.test.tsx b/dato-agent/src/components/AgentSurface.test.tsx
index 1f3c3fc2..fd0c5fc5 100644
--- a/dato-agent/src/components/AgentSurface.test.tsx
+++ b/dato-agent/src/components/AgentSurface.test.tsx
@@ -1384,6 +1384,78 @@ describe('AgentSurface', () => {
).not.toBeInTheDocument();
});
+ it('asks which secret-looking values to redact before copying diagnostics', async () => {
+ const onCopyFailureDiagnostics = vi
+ .fn()
+ .mockResolvedValueOnce({
+ status: 'review',
+ candidates: [
+ {
+ value: 'first-secret-value-0001',
+ preview: '…0001 · 23 chars',
+ path: 'conversation[0].text',
+ occurrences: 1,
+ },
+ {
+ value: 'second-secret-value-002',
+ preview: '…0002 · 23 chars',
+ path: 'turn.events[1].output',
+ occurrences: 2,
+ },
+ ],
+ })
+ .mockResolvedValueOnce({ status: 'copied' });
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Copy diagnostics' }));
+ expect(
+ screen.getByText(/2 values in the diagnostics look like secrets/),
+ ).toBeInTheDocument();
+ expect(screen.getByText('…0002 · 23 chars')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Ticked values are replaced with [redacted] in the copy.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getAllByText('Redact')).toHaveLength(2);
+ expect(
+ screen.queryByText('second-secret-value-002'),
+ ).not.toBeInTheDocument();
+
+ // Everything starts selected; untick one, then copy.
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(
+ checkboxes.every((checkbox) => (checkbox as HTMLInputElement).checked),
+ ).toBe(true);
+ await user.click(checkboxes[1] as HTMLElement);
+ await user.click(
+ screen.getByRole('button', { name: 'Copy with 1 redacted' }),
+ );
+
+ expect(onCopyFailureDiagnostics).toHaveBeenLastCalledWith('failure-1', [
+ 'first-secret-value-0001',
+ ]);
+ expect(screen.queryByText(/look like secrets/)).not.toBeInTheDocument();
+ });
+
it('offers compact recovery actions only for a terminal assistant failure', async () => {
const onRetryFailedTurn = vi.fn();
const onCopyFailureDiagnostics = vi.fn().mockResolvedValue(undefined);
@@ -1442,7 +1514,10 @@ describe('AgentSurface', () => {
await user.click(copyButton);
expect(onCopyFailureDiagnostics).toHaveBeenCalledOnce();
- expect(onCopyFailureDiagnostics).toHaveBeenCalledWith('failure-1');
+ expect(onCopyFailureDiagnostics).toHaveBeenCalledWith(
+ 'failure-1',
+ undefined,
+ );
});
it('shows only diagnostics for a non-retryable terminal failure', () => {
diff --git a/dato-agent/src/components/AgentSurface.tsx b/dato-agent/src/components/AgentSurface.tsx
index 416398b4..0ab660d9 100644
--- a/dato-agent/src/components/AgentSurface.tsx
+++ b/dato-agent/src/components/AgentSurface.tsx
@@ -31,6 +31,7 @@ import type {
Mention,
} from '../lib/mentions';
import { fallbackAssetMention, fallbackRecordMention } from '../lib/mentions';
+import type { SecretCandidate } from '../lib/secretCandidates';
import { MentionDisplay } from '../recordComments/entrypoints/components/shared/MentionDisplay';
import commentStyles from '../recordComments/entrypoints/styles/comment.module.css';
import {
@@ -313,7 +314,14 @@ export type AgentSurfaceProps = {
onRejectUnsafeAction?: (approval: UnsafeApprovalViewModel) => void;
onRepairUnsafeAction?: (approval: UnsafeApprovalViewModel) => void;
onRetryFailedTurn?: (failureId: string) => void | Promise;
- onCopyFailureDiagnostics?: (failureId: string) => Promise;
+ /**
+ * Copies a failure's diagnostics. Without `redact`, it may return `review` with values that look
+ * like secrets instead of copying; calling again with the values to redact (possibly none) copies.
+ */
+ onCopyFailureDiagnostics?: (
+ failureId: string,
+ redact?: readonly string[],
+ ) => Promise;
autoApproveEnabled?: boolean;
autoApproveChanging?: boolean;
autoApproveError?: string;
@@ -695,6 +703,10 @@ function EmptyState() {
);
}
+export type CopyDiagnosticsResult =
+ | { status: 'copied' }
+ | { status: 'review'; candidates: readonly SecretCandidate[] };
+
function FailureActions({
failure,
onRetry,
@@ -708,6 +720,9 @@ function FailureActions({
const [copyState, setCopyState] = useState<
'idle' | 'copying' | 'copied' | 'error'
>('idle');
+ // Values that look like secrets, awaiting the user's choice; all start selected.
+ const [review, setReview] = useState();
+ const [selected, setSelected] = useState>(new Set());
const copiedTimerRef = useRef>(undefined);
useEffect(
@@ -735,7 +750,7 @@ function FailureActions({
}
};
- const copyDiagnostics = async () => {
+ const copyDiagnostics = async (redact?: readonly string[]) => {
if (retryPending || copyState === 'copying' || !onCopyDiagnostics) {
return;
}
@@ -748,7 +763,16 @@ function FailureActions({
setCopyState('copying');
try {
- await onCopyDiagnostics(failure.id);
+ const result = await onCopyDiagnostics(failure.id, redact);
+ if (result?.status === 'review') {
+ setReview(result.candidates);
+ setSelected(
+ new Set(result.candidates.map((candidate) => candidate.value)),
+ );
+ setCopyState('idle');
+ return;
+ }
+ setReview(undefined);
setCopyState('copied');
copiedTimerRef.current = setTimeout(() => {
copiedTimerRef.current = undefined;
@@ -797,6 +821,66 @@ function FailureActions({
Couldn’t copy diagnostics
)}
+ {review && (
+
+ )}
>
);
}
diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx
index 03f8cf89..95601e24 100644
--- a/dato-agent/src/entrypoints/AgentFrame.test.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx
@@ -6708,11 +6708,18 @@ describe('AgentFrame', () => {
expect(writeText).toHaveBeenCalledOnce();
const diagnostics = JSON.parse(String(writeText.mock.calls[0]?.[0]));
- expect(diagnostics.configuration.openAiApiKey).toBe('sk-diagnostic-secret');
- expect(diagnostics.configuration.activeApiKey).toBe('sk-diagnostic-secret');
- expect(diagnostics.oauthCredentials.token.accessToken).toBe(
+ // Allowlisted payload: keys and tokens are absent, not just masked.
+ expect(diagnostics.configuration.openAiApiKey).toBeUndefined();
+ expect(diagnostics.configuration.activeApiKey).toBeUndefined();
+ expect(diagnostics.configuration.hasOpenAiApiKey).toBe(true);
+ expect(diagnostics.oauthCredentials.token.accessToken).toBeUndefined();
+ expect(diagnostics.oauthCredentials.token.tokenType).toBe('Bearer');
+ expect(String(writeText.mock.calls[0]?.[0])).not.toContain(
'diagnostic-oauth-secret',
);
+ expect(String(writeText.mock.calls[0]?.[0])).not.toContain(
+ 'sk-diagnostic-secret',
+ );
expect(diagnostics.turn.message).toBe(
'Find the private record with all of its content',
);
@@ -6752,6 +6759,100 @@ describe('AgentFrame', () => {
);
});
+ it('masks the OAuth token a turn actually used, not only the one its render closed over', async () => {
+ const frameProps = props({
+ currentUserId: `diagnostics-rotated-user-${testUser}`,
+ });
+ const credentialStore = createCredentialStore({
+ siteId: frameProps.siteId,
+ currentUserId: frameProps.currentUserId,
+ });
+ const saveToken = (accessToken: string) =>
+ credentialStore.save(
+ createOAuthCredentials(
+ {
+ clientId: 'diagnostic-client',
+ clientIdIssuedAt: 1_700_000_000,
+ redirectUri: 'https://example.test/oauth/callback',
+ },
+ { accessToken, tokenType: 'Bearer', obtainedAt: 1_700_000_000 },
+ ),
+ { remember: true },
+ );
+ saveToken('stale-oauth-secret-value');
+ const failure = {
+ code: 'api_error' as const,
+ message:
+ 'MCP rejected Bearer rotated-oauth-secret-value after stale-oauth-secret-value expired.',
+ retryable: true,
+ };
+ mocks.runtime = {
+ runTurn: vi.fn(
+ async (
+ _args: unknown,
+ onEvent?: (event: AgentRuntimeEvent) => void | Promise,
+ ) => {
+ const failed = completedResult({
+ status: 'failed',
+ responseId: 'resp_rotated',
+ error: failure,
+ });
+ await onEvent?.({
+ type: 'error',
+ responseId: 'resp_rotated',
+ error: failure,
+ });
+ await onEvent?.({ type: 'turn_completed', result: failed });
+ return failed;
+ },
+ ),
+ } as unknown as AgentRuntime;
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ render();
+ // A submit handler from before the token rotation: its render still holds the stale token.
+ const staleSubmit = mocks.surfaceProps?.onSubmit;
+ saveToken('rotated-oauth-secret-value');
+ act(() => {
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: credentialStore.key,
+ newValue: localStorage.getItem(credentialStore.key),
+ storageArea: localStorage,
+ }),
+ );
+ });
+ act(() => {
+ staleSubmit?.('Describe the project');
+ });
+ await waitFor(() => expect(mocks.surfaceProps?.isRunning).toBe(false));
+
+ const failedEntry = mocks.surfaceProps?.entries.find(
+ (entry) =>
+ entry.kind === 'message' && entry.role === 'assistant' && entry.failure,
+ );
+ if (failedEntry?.kind !== 'message' || !failedEntry.failure) {
+ throw new Error('Expected terminal failure diagnostics.');
+ }
+ await act(async () => {
+ await mocks.surfaceProps?.onCopyFailureDiagnostics?.(
+ failedEntry.failure?.id ?? '',
+ );
+ });
+
+ expect(writeText).toHaveBeenCalledOnce();
+ const serialized = String(writeText.mock.calls[0]?.[0]);
+ expect(serialized).not.toContain('rotated-oauth-secret-value');
+ expect(serialized).not.toContain('stale-oauth-secret-value');
+ expect(serialized).toContain(
+ 'MCP rejected Bearer [redacted] after [redacted] expired.',
+ );
+ });
+
it('offers diagnostics but never retry when the runtime throws without an explicit retryable result', async () => {
mocks.runtime = {
runTurn: vi
diff --git a/dato-agent/src/entrypoints/AgentFrame.tsx b/dato-agent/src/entrypoints/AgentFrame.tsx
index d739546b..9d648c8b 100644
--- a/dato-agent/src/entrypoints/AgentFrame.tsx
+++ b/dato-agent/src/entrypoints/AgentFrame.tsx
@@ -9,6 +9,7 @@ import {
type AgentRecordResultViewModel,
AgentSurface,
type AgentTranscriptEntry,
+ type CopyDiagnosticsResult,
type UnsafeApprovalViewModel,
} from '../components/AgentSurface';
import {
@@ -53,6 +54,7 @@ import {
activeModel,
activeModelMaxOutputTokens,
activeReasoningEffort,
+ diagnosticConfiguration,
providerLabel,
} from '../lib/config';
import {
@@ -71,6 +73,7 @@ import {
type CredentialScope,
createCredentialStore,
createOAuthCredentials,
+ diagnosticOAuthCredentials,
invalidateOAuthToken,
isOAuthClientRegistrationReusable,
type OAuthCredentials,
@@ -124,6 +127,7 @@ import {
revokeToken,
waitForOAuthCallback,
} from '../lib/oauth';
+import { findSecretCandidates, redactValues } from '../lib/secretCandidates';
import { type AgentSurfaceKind, buildSystemPrompt } from '../lib/systemPrompt';
import {
createUnsafeDispatchJournalStore,
@@ -3200,90 +3204,106 @@ export default function AgentFrame(props: AgentFrameProps) {
const systemContext = runtimeSystemContext();
failureDiagnosticsRef.current.set(
failureId,
- serializeDiagnostics({
- schemaVersion: DIAGNOSTICS_SCHEMA_VERSION,
- capturedAt,
- plugin: {
- name: 'Dato Agent (Beta)',
- package: 'datocms-plugin-dato-agent',
- version: '0.1.0',
- url: window.location.href,
- },
- browser: {
- userAgent: navigator.userAgent,
- language: navigator.language,
- online: navigator.onLine,
- },
- project: {
- pluginId: props.pluginId,
- siteId: props.siteId,
- siteName: props.siteName,
- environment: props.environment,
- isEnvironmentPrimary: props.isEnvironmentPrimary,
- currentUserId: props.currentUserId,
- scope: stableScope,
- currentRecord: props.currentRecord,
- editorHasUnsavedChanges: editorDirtyRef.current,
- },
- configuration: {
- ...props.config,
- activeApiKey: activeApiKey(props.config),
- activeModel: activeModel(props.config),
- activeModelMaxOutputTokens: activeModelMaxOutputTokens(props.config),
- activeReasoningEffort: activeReasoningEffort(props.config),
- autoApproveEnabled: autoApproveEnabledRef.current,
- },
- oauthCredentials,
- conversation: {
- beforeTurn: turn.conversationBefore,
- current: conversationRef.current,
- providerHistory: turn.history,
+ serializeDiagnostics(
+ {
+ schemaVersion: DIAGNOSTICS_SCHEMA_VERSION,
+ capturedAt,
+ plugin: {
+ name: 'Dato Agent (Beta)',
+ package: 'datocms-plugin-dato-agent',
+ version: '0.1.0',
+ url: window.location.href,
+ },
+ browser: {
+ userAgent: navigator.userAgent,
+ language: navigator.language,
+ online: navigator.onLine,
+ },
+ project: {
+ pluginId: props.pluginId,
+ siteId: props.siteId,
+ siteName: props.siteName,
+ environment: props.environment,
+ isEnvironmentPrimary: props.isEnvironmentPrimary,
+ currentUserId: props.currentUserId,
+ scope: stableScope,
+ currentRecord: props.currentRecord,
+ editorHasUnsavedChanges: editorDirtyRef.current,
+ },
+ configuration: {
+ ...diagnosticConfiguration(props.config),
+ activeModel: activeModel(props.config),
+ activeModelMaxOutputTokens: activeModelMaxOutputTokens(
+ props.config,
+ ),
+ activeReasoningEffort: activeReasoningEffort(props.config),
+ autoApproveEnabled: autoApproveEnabledRef.current,
+ },
+ oauthCredentials: diagnosticOAuthCredentials(oauthCredentials),
+ conversation: {
+ beforeTurn: turn.conversationBefore,
+ current: conversationRef.current,
+ providerHistory: turn.history,
+ },
+ turn: {
+ id: turn.id,
+ source,
+ message: turn.message,
+ userEntryId: turn.userEntryId,
+ assistantEntryId: turn.assistantEntryId,
+ activityEntryId: turn.activityEntryId,
+ provider: turn.provider,
+ model: turn.model,
+ startedAt: turn.startedAt,
+ finishedAt: capturedAt,
+ durationMs: Math.max(
+ 0,
+ Date.parse(capturedAt) - Date.parse(turn.startedAt),
+ ),
+ previousResponseId: turn.previousResponseId,
+ injectHostContext: turn.injectHostContext,
+ textDeltaCount: turn.textDeltaCount,
+ textDeltaCharacters: turn.textDeltaCharacters,
+ diagnosticEventOutputCharacters:
+ turn.diagnosticEventOutputCharacters,
+ diagnosticEventsDropped: turn.diagnosticEventsDropped,
+ hostContexts: turn.hostContexts,
+ userStopped: turn.userStopped,
+ unsafeOperationDispatched: turn.unsafeOperationDispatched,
+ dispatchedApprovalIds: turn.dispatchedApprovalIds,
+ cancelledBeforeDispatchApprovalIds:
+ turn.cancelledBeforeDispatchApprovalIds,
+ cancelledBeforeDispatchReason: turn.cancelledBeforeDispatchReason,
+ autoApprovalBundleCount: turn.autoApprovalBundleCount,
+ approvalSubmissions: turn.approvalSubmissions,
+ approvalOutcomes: turn.approvalOutcomes,
+ confirmedApprovalIds: turn.confirmedApprovalIds,
+ completionResult: result,
+ events: turn.events,
+ thrownError,
+ systemPrompt: buildSystemPrompt(systemContext, {
+ additionalInstructions: props.config.additionalInstructions,
+ readOnly: props.config.readOnly,
+ mcpAccessLevel: accessRef.current.snapshot.level,
+ }),
+ },
+ transcript: entriesRef.current,
+ pendingApprovals: [...pendingApprovalsRef.current.values()],
+ pendingNavigation: pendingNavigationRef.current,
},
- turn: {
- id: turn.id,
- source,
- message: turn.message,
- userEntryId: turn.userEntryId,
- assistantEntryId: turn.assistantEntryId,
- activityEntryId: turn.activityEntryId,
- provider: turn.provider,
- model: turn.model,
- startedAt: turn.startedAt,
- finishedAt: capturedAt,
- durationMs: Math.max(
- 0,
- Date.parse(capturedAt) - Date.parse(turn.startedAt),
- ),
- previousResponseId: turn.previousResponseId,
- injectHostContext: turn.injectHostContext,
- textDeltaCount: turn.textDeltaCount,
- textDeltaCharacters: turn.textDeltaCharacters,
- diagnosticEventOutputCharacters: turn.diagnosticEventOutputCharacters,
- diagnosticEventsDropped: turn.diagnosticEventsDropped,
- hostContexts: turn.hostContexts,
- userStopped: turn.userStopped,
- unsafeOperationDispatched: turn.unsafeOperationDispatched,
- dispatchedApprovalIds: turn.dispatchedApprovalIds,
- cancelledBeforeDispatchApprovalIds:
- turn.cancelledBeforeDispatchApprovalIds,
- cancelledBeforeDispatchReason: turn.cancelledBeforeDispatchReason,
- autoApprovalBundleCount: turn.autoApprovalBundleCount,
- approvalSubmissions: turn.approvalSubmissions,
- approvalOutcomes: turn.approvalOutcomes,
- confirmedApprovalIds: turn.confirmedApprovalIds,
- completionResult: result,
- events: turn.events,
- thrownError,
- systemPrompt: buildSystemPrompt(systemContext, {
- additionalInstructions: props.config.additionalInstructions,
- readOnly: props.config.readOnly,
- mcpAccessLevel: accessRef.current.snapshot.level,
- }),
+ {
+ // Every live secret this frame holds; masked wherever it appears in the payload. This
+ // closure can predate a token rotation, so read the live ref and the tokens the turn used.
+ secrets: [
+ props.config.openAiApiKey,
+ props.config.anthropicApiKey,
+ oauthCredentials?.token?.accessToken,
+ oauthCredentialsRef.current?.token?.accessToken,
+ turn.mcpAccessToken,
+ turn.repairOauthAccessToken,
+ ],
},
- transcript: entriesRef.current,
- pendingApprovals: [...pendingApprovalsRef.current.values()],
- pendingNavigation: pendingNavigationRef.current,
- }),
+ ),
);
retryCandidateRef.current = retryable
@@ -4436,13 +4456,23 @@ export default function AgentFrame(props: AgentFrameProps) {
await submit(candidate.submission, candidate);
};
- const copyFailureDiagnostics = async (failureId: string): Promise => {
+ const copyFailureDiagnostics = async (
+ failureId: string,
+ redact?: readonly string[],
+ ): Promise => {
const diagnostics = failureDiagnosticsRef.current.get(failureId);
if (!diagnostics) {
throw new Error('Diagnostics are no longer available.');
}
- await copyTextToClipboard(diagnostics);
+ // Known secrets are already masked; ask about anything else that looks like one.
+ if (redact === undefined) {
+ const candidates = findSecretCandidates(diagnostics);
+ if (candidates.length > 0) return { status: 'review', candidates };
+ }
+
+ await copyTextToClipboard(redactValues(diagnostics, redact ?? []));
+ return { status: 'copied' };
};
const stopActiveTurn = (): void => {
diff --git a/dato-agent/src/lib/config.ts b/dato-agent/src/lib/config.ts
index 2dbf21d9..73aa5c84 100644
--- a/dato-agent/src/lib/config.ts
+++ b/dato-agent/src/lib/config.ts
@@ -296,3 +296,27 @@ export function serializeConfig(
...normalizeConfig(next),
};
}
+
+/**
+ * The configuration as it may appear in copied diagnostics: an explicit allowlist of non-secret
+ * settings. API keys are reported only as present or missing, so a new secret field never leaks
+ * by default.
+ */
+export function diagnosticConfiguration(config: AgentConfig) {
+ return {
+ provider: config.provider,
+ model: config.model,
+ reasoningEffort: config.reasoningEffort,
+ openAiFastMode: config.openAiFastMode,
+ anthropicModel: config.anthropicModel,
+ anthropicModelMaxOutputTokens: config.anthropicModelMaxOutputTokens,
+ anthropicReasoningEffort: config.anthropicReasoningEffort,
+ anthropicFastMode: config.anthropicFastMode,
+ additionalInstructions: config.additionalInstructions,
+ readOnly: config.readOnly,
+ enableRecordSidebar: config.enableRecordSidebar,
+ allowedRoleIds: config.allowedRoleIds,
+ hasOpenAiApiKey: config.openAiApiKey.trim() !== '',
+ hasAnthropicApiKey: config.anthropicApiKey.trim() !== '',
+ };
+}
diff --git a/dato-agent/src/lib/credentials.ts b/dato-agent/src/lib/credentials.ts
index 9497743f..5b9b73fb 100644
--- a/dato-agent/src/lib/credentials.ts
+++ b/dato-agent/src/lib/credentials.ts
@@ -363,3 +363,23 @@ function isCleanAbsoluteUrl(value: unknown): value is string {
return false;
}
}
+
+/**
+ * OAuth state as it may appear in copied diagnostics: version, client registration and token
+ * metadata only. The access token itself is never included.
+ */
+export function diagnosticOAuthCredentials(
+ credentials: OAuthCredentials | null | undefined,
+) {
+ if (!credentials) return undefined;
+ return {
+ version: credentials.version,
+ client: credentials.client,
+ token: credentials.token
+ ? {
+ tokenType: credentials.token.tokenType,
+ obtainedAt: credentials.token.obtainedAt,
+ }
+ : undefined,
+ };
+}
diff --git a/dato-agent/src/lib/diagnostics.test.ts b/dato-agent/src/lib/diagnostics.test.ts
index 6617bcf6..f54c99a1 100644
--- a/dato-agent/src/lib/diagnostics.test.ts
+++ b/dato-agent/src/lib/diagnostics.test.ts
@@ -28,7 +28,7 @@ afterEach(() => {
});
describe('serializeDiagnostics', () => {
- it('produces deterministic pretty JSON while preserving raw diagnostic values', () => {
+ it('produces deterministic pretty JSON with credentials redacted', () => {
const first = {
token: 'dato_oauth_secret',
schemaVersion: DIAGNOSTICS_SCHEMA_VERSION,
@@ -46,15 +46,75 @@ describe('serializeDiagnostics', () => {
expect(serializeDiagnostics(first)).toBe(`{
"arguments": {
"a": false,
- "apiKey": "sk-secret",
+ "apiKey": "[redacted]",
"z": true
},
"prompt": "Change record 178178740",
- "schemaVersion": 1,
- "token": "dato_oauth_secret"
+ "schemaVersion": 2,
+ "token": "[redacted]"
}`);
});
+ it('redacts provider keys and OAuth credentials while keeping non-secret settings', () => {
+ const serialized = serializeDiagnostics(
+ {
+ configuration: {
+ anthropicApiKey: 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
+ openaiApiKey: '',
+ activeApiKey: 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
+ anthropicModelMaxOutputTokens: 128_000,
+ oauthCredentials: {
+ client: { clientId: 'client-123' },
+ token: {
+ accessToken: 'dato-access-token-value',
+ refreshToken: 'dato-refresh-token-value',
+ expiresAt: 1_800_000_000,
+ },
+ pending: { codeVerifier: 'pkce-verifier-value', state: 'abc' },
+ },
+ },
+ turn: { mcpAccessToken: 'dato-access-token-value' },
+ error: {
+ message:
+ 'Request failed: Authorization: Bearer dato-access-token-value with key sk-proj-abcdefghijklmnopqrstuvwxyz',
+ },
+ },
+ {
+ secrets: [
+ 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz',
+ 'sk-proj-abcdefghijklmnopqrstuvwxyz',
+ 'dato-access-token-value',
+ ],
+ },
+ );
+
+ expect(serialized).not.toContain('abcdefghijklmnopqrstuvwxyz');
+ expect(serialized).not.toContain('dato-access-token-value');
+ expect(serialized).not.toContain('dato-refresh-token-value');
+ expect(serialized).not.toContain('pkce-verifier-value');
+ expect(JSON.parse(serialized)).toMatchObject({
+ configuration: {
+ anthropicApiKey: '[redacted]',
+ openaiApiKey: '',
+ activeApiKey: '[redacted]',
+ anthropicModelMaxOutputTokens: 128_000,
+ oauthCredentials: {
+ client: { clientId: 'client-123' },
+ token: {
+ accessToken: '[redacted]',
+ refreshToken: '[redacted]',
+ expiresAt: 1_800_000_000,
+ },
+ pending: { codeVerifier: '[redacted]', state: 'abc' },
+ },
+ },
+ error: {
+ message:
+ 'Request failed: Authorization: Bearer [redacted] with key [redacted]',
+ },
+ });
+ });
+
it('handles errors, BigInt, Map, Set, circular references, and unsupported values', () => {
const cause = new Error('Provider request failed');
const error = new Error('Turn failed', { cause });
@@ -104,6 +164,42 @@ describe('serializeDiagnostics', () => {
});
});
+ it('masks known secret values wherever they appear, whatever their format', () => {
+ // Deliberately formatless: masking must not depend on recognising a key shape.
+ const providerKey = 'not-a-known-shape-0123456789';
+ const accessToken = 'opaque.token.value~42';
+ const error = new Error(`401 for ${providerKey}`);
+ error.stack = `Error: boom\n Authorization: Bearer ${accessToken}`;
+
+ const output = serializeDiagnostics(
+ {
+ thrownError: error,
+ context: new Map([['notASecretKeyName', providerKey]]),
+ request: {
+ url: `https://example.com/cb?state=x&access_token=${accessToken}`,
+ },
+ notes: [`the user pasted ${providerKey} into chat`],
+ status: 'status code=401 kept',
+ },
+ { secrets: [providerKey, accessToken, '', undefined, 'short'] },
+ );
+
+ expect(output).not.toContain(providerKey);
+ expect(output).not.toContain(accessToken);
+ expect(output).toContain('"message": "401 for [redacted]"');
+ expect(output).toContain('?state=x&access_token=[redacted]');
+ expect(output).toContain('status code=401 kept');
+ });
+
+ it('keeps short or empty secret values from masking ordinary text', () => {
+ expect(
+ serializeDiagnostics(
+ { note: 'a short word' },
+ { secrets: ['short', ''] },
+ ),
+ ).toContain('a short word');
+ });
+
it('does not throw for values whose properties cannot be inspected', () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
diff --git a/dato-agent/src/lib/diagnostics.ts b/dato-agent/src/lib/diagnostics.ts
index 6ec9610d..a892a04b 100644
--- a/dato-agent/src/lib/diagnostics.ts
+++ b/dato-agent/src/lib/diagnostics.ts
@@ -1,4 +1,5 @@
-export const DIAGNOSTICS_SCHEMA_VERSION = 1;
+/** 2: configuration and OAuth state are allowlisted; secrets are never included. */
+export const DIAGNOSTICS_SCHEMA_VERSION = 2;
type JsonSafeValue =
| null
@@ -50,15 +51,88 @@ function describeSymbol(value: symbol): string {
}
}
+// Same marker as the plugin's other redactors (conversations, MCP client, script outcomes).
+const REDACTED = '[redacted]';
+
+const SENSITIVE_EXACT_KEYS = new Set([
+ 'authorization',
+ 'bearer',
+ 'codeverifier',
+ 'cookie',
+ 'credentials',
+ 'idtoken',
+ 'jwt',
+ 'session',
+ 'sessionid',
+ 'token',
+]);
+
+const SENSITIVE_KEY_SUFFIXES = [
+ 'accesstoken',
+ 'apikey',
+ 'password',
+ 'privatekey',
+ 'refreshtoken',
+ 'secret',
+];
+
+function isSensitiveKey(key: string): boolean {
+ const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
+ return (
+ SENSITIVE_EXACT_KEYS.has(normalized) ||
+ SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
+ );
+}
+
+/** Shorter values would mask ordinary words; real keys and tokens are far longer. */
+const MIN_SECRET_LENGTH = 8;
+
+/**
+ * Builds a masker for the plugin's own secret values (provider API keys, OAuth tokens). Replacing
+ * exact values catches them wherever they end up, e.g. echoed inside an error message, whatever
+ * their format. Longest first, so a secret that contains another is masked whole.
+ */
+function knownSecretMasker(
+ secrets: readonly (string | null | undefined)[],
+): (text: string) => string {
+ const values = [
+ ...new Set(
+ secrets.filter(
+ (secret): secret is string =>
+ typeof secret === 'string' && secret.length >= MIN_SECRET_LENGTH,
+ ),
+ ),
+ ].sort((left, right) => right.length - left.length);
+ return (text) =>
+ values.reduce(
+ (current, secret) => current.replaceAll(secret, REDACTED),
+ text,
+ );
+}
+
function propertyPath(parentPath: string, key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key)
? `${parentPath}.${key}`
: `${parentPath}[${JSON.stringify(key)}]`;
}
-function normalizeDiagnostics(value: unknown): JsonSafeValue {
+function normalizeDiagnostics(
+ value: unknown,
+ maskSecrets: (text: string) => string,
+): JsonSafeValue {
const seen = new WeakMap