From 9b319e6c98fce7a809fc8fc7f0f90f4cc0e9083f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 4 Nov 2026 18:01:21 -0800 Subject: [PATCH] fix(logs): keep checked trace content at every depth when the projection budget runs out Also tidy what the release review found in the same range: shared UTF-8 length helper, redundant mounted guards, session-expired wrapper, misplaced TSDoc, repeated capture copy, and a styled page-access error. --- apps/extension/src/capture.ts | 14 +- apps/extension/src/reference.ts | 4 +- apps/extension/src/sidepanel.tsx | 20 +-- apps/extension/src/site-access.tsx | 20 +-- .../app/extension/[workspaceId]/workspace.tsx | 7 +- .../o/[organizationId]/organization.tsx | 7 +- apps/sim/app/extension/workspaces.tsx | 7 +- apps/sim/hooks/use-speech-to-text.ts | 10 +- .../execution/trace-secret-projection.test.ts | 82 ++++++++-- .../logs/execution/trace-secret-projection.ts | 144 +++++++++++------- apps/sim/lib/mothership/chat/page-context.ts | 4 +- .../lib/sim-links/application/use-cases.ts | 17 +-- .../sim/scripts/test-browser-extension-e2e.ts | 1 + .../browser-extension-protocol/src/index.ts | 1 - 14 files changed, 209 insertions(+), 129 deletions(-) diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index da26f4597a9..7e9e66b2609 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -9,6 +9,8 @@ import { extractPageText } from '@/extract' import { digestUrl } from '@/utils' /** A public capture error whose message is safe to share with the hosted composer. */ +export const PAGE_CHANGED_MESSAGE = 'The page changed. Attach it again.' + export class PageCaptureError extends Error {} /** Chrome scripting failures may contain the full private URL of the tab. */ @@ -49,7 +51,7 @@ export async function captureActivePage( if (tab?.id === undefined || tab.incognito) throw new PageCaptureError('This page cannot be attached.') if (expected && expected.tabId !== tab.id) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } if (!tab.url) { const allowed = await chrome.permissions.contains({ origins: PAGE_ACCESS_ORIGINS }) @@ -75,10 +77,10 @@ export async function captureActivePage( signal?.addEventListener('abort', removeListeners, { once: true }) try { const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal) - if (changed) throw new PageCaptureError('The page changed. Attach it again.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) return snapshot } catch (error) { - if (changed) throw new PageCaptureError('The page changed. Attach it again.') + if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE) if (expected && !(error instanceof PageCaptureError)) throw new PageCaptureError('The original page is unavailable. Attach the current page.') throw error @@ -95,7 +97,7 @@ async function readTab( ) { const url = new URL(tab.url) if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } let pathname = url.pathname if (url.origin === SIM_ORIGIN) { @@ -132,12 +134,12 @@ async function readTab( world: 'ISOLATED', }) if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } const [current] = await chrome.tabs.query({ active: true, currentWindow: true }) signal?.throwIfAborted() if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') { - throw new PageCaptureError('The page changed. Attach it again.') + throw new PageCaptureError(PAGE_CHANGED_MESSAGE) } if (result.result.error) { if (kind === 'selection') diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts index 46e0f717a25..2b3890c9370 100644 --- a/apps/extension/src/reference.ts +++ b/apps/extension/src/reference.ts @@ -11,6 +11,8 @@ import { } from '@/capture' import { REFERENCE_PAGE_KEY } from '@/config' +export const PAGE_ATTACHMENT_PENDING_MESSAGE = 'Wait for the page attachment, then try again.' + interface ReferenceOptions { session: () => string | null post: (message: ExtensionToAppMessage) => void @@ -124,7 +126,7 @@ export function createReferenceCapture({ replyError( sessionId, requestId, - manualError?.message ?? 'Wait for the page attachment, then try again.', + manualError?.message ?? PAGE_ATTACHMENT_PENDING_MESSAGE, manualError?.reason ) return diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx index 73beab91033..7e579300244 100644 --- a/apps/extension/src/sidepanel.tsx +++ b/apps/extension/src/sidepanel.tsx @@ -8,17 +8,19 @@ import { createRoot } from 'react-dom/client' import { captureActivePage, captureErrorMessage, + PAGE_CHANGED_MESSAGE, PageCaptureError, SiteAccessRequiredError, UnreadableSelectionError, } from '@/capture' import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config' import { type CaptureIntent, captureIntentSchema } from '@/intent' -import { createReferenceCapture } from '@/reference' +import { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference' import { SearchRecoveryDialog } from '@/search-recovery-dialog' import { PageAccessButton, SiteAccessDialog } from '@/site-access' import '@/shell.css' +const PAGE_ATTACHMENT_EXPIRED_MESSAGE = 'The page attachment expired. Attach it again.' const BLOCKED_SELECTION = 'Select readable text again, attach the page explicitly, or turn off Reference current page.' const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac') @@ -49,9 +51,7 @@ function SidePanel({ origin }: SidePanelProps) { if (!(status instanceof SiteAccessRequiredError)) return const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => { if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) { - setStatus((current) => - current === status ? 'The page changed. Attach it again.' : current - ) + setStatus((current) => (current === status ? PAGE_CHANGED_MESSAGE : current)) } } const activated = (tab: chrome.tabs.OnActivatedInfo) => { @@ -74,7 +74,7 @@ function SidePanel({ origin }: SidePanelProps) { if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) { pendingRef.current = null focusRequested.current = false - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } const message: ExtensionToAppMessage = { @@ -130,12 +130,12 @@ function SidePanel({ origin }: SidePanelProps) { } ) { if (referenceRef.current?.busy()) { - setStatus('Wait for the page attachment, then try again.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } if (busyRef.current) return if (pendingRef.current) { - setStatus('Wait for the page attachment, then try again.') + setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE) return } busyRef.current = true @@ -171,7 +171,7 @@ function SidePanel({ origin }: SidePanelProps) { manualCaptureError: () => { if (busyRef.current || pendingRef.current || processingManual || manualIntents.size) return { - message: 'Wait for the page attachment, then try again.', + message: PAGE_ATTACHMENT_PENDING_MESSAGE, } if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' } }, @@ -249,7 +249,7 @@ function SidePanel({ origin }: SidePanelProps) { } if (disposed || !intent) return if (!currentIntent(intent, window.id)) { - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) return } if (intent.kind === 'focus') { @@ -362,7 +362,7 @@ function SidePanel({ origin }: SidePanelProps) { ) { pendingRef.current = null focusRequested.current = false - setStatus('The page attachment expired. Attach it again.') + setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE) } void consumeIntent() }, 5_000) diff --git a/apps/extension/src/site-access.tsx b/apps/extension/src/site-access.tsx index ddf6468eaef..0ee685ade23 100644 --- a/apps/extension/src/site-access.tsx +++ b/apps/extension/src/site-access.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn' import { PAGE_ACCESS_ORIGINS } from '@/config' @@ -8,25 +8,17 @@ interface PageAccessButtonProps { } export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonProps) { - const mounted = useRef(false) const [status, setStatus] = useState<'idle' | 'requesting' | 'error'>('idle') - useEffect(() => { - mounted.current = true - return () => { - mounted.current = false - } - }, []) - async function enable() { if (status === 'requesting') return setStatus('requesting') try { const granted = await chrome.permissions.request({ origins: PAGE_ACCESS_ORIGINS }) - if (mounted.current) setStatus('idle') + setStatus('idle') if (granted) onAccessGranted() } catch { - if (mounted.current) setStatus('error') + setStatus('error') } } @@ -47,7 +39,11 @@ export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonP void enable()}> Allow across websites - {status === 'error' && Could not enable page access. Try again.} + {status === 'error' && ( + + Could not enable page access. Try again. + + )} ) } diff --git a/apps/sim/app/extension/[workspaceId]/workspace.tsx b/apps/sim/app/extension/[workspaceId]/workspace.tsx index 1bd55ea6ed3..6c5f61f72cb 100644 --- a/apps/sim/app/extension/[workspaceId]/workspace.tsx +++ b/apps/sim/app/extension/[workspaceId]/workspace.tsx @@ -4,8 +4,7 @@ import { Chip, ChipLink } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' import { ExtensionChat } from '@/app/extension/[workspaceId]/chat' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' @@ -26,13 +25,11 @@ interface ExtensionWorkspaceProps { } export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) { - const { userId } = useExtensionIdentity() const { data, isPending, error, refetch } = useWorkspaceHostContextQuery(workspaceId, { refetchInterval: WORKSPACE_HOST_CONTEXT_STALE_TIME, refetchOnWindowFocus: true, }) - if (isApiClientError(error) && error.status === 401) - return + if (isApiClientError(error) && error.status === 401) return if (isPending) return const accessDenied = isApiClientError(error) && (error.status === 403 || error.status === 404) if (accessDenied || !data) { diff --git a/apps/sim/app/extension/o/[organizationId]/organization.tsx b/apps/sim/app/extension/o/[organizationId]/organization.tsx index fb5dd1b01c0..eaad05cbccc 100644 --- a/apps/sim/app/extension/o/[organizationId]/organization.tsx +++ b/apps/sim/app/extension/o/[organizationId]/organization.tsx @@ -10,8 +10,7 @@ import { ExtensionHeader } from '@/app/extension/[workspaceId]/header' import { extensionChatParam } from '@/app/extension/[workspaceId]/search-params' import { useExtensionComposer } from '@/app/extension/hooks/use-composer' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { ExtensionUnavailable } from '@/app/extension/unavailable' import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation' import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' @@ -27,14 +26,12 @@ interface ExtensionOrganizationProps { } export function ExtensionOrganization({ context }: ExtensionOrganizationProps) { - const { userId } = useExtensionIdentity() const access = useOrganizationSummary(context.organization.id) const history = useOrganizationMothershipChats(context.organization.id) const denied = [access.error, history.error].find( (error) => isApiClientError(error) && [401, 403, 404].includes(error.status) ) - if (isApiClientError(denied) && denied.status === 401) - return + if (isApiClientError(denied) && denied.status === 401) return if (access.isPending) return if (denied || !access.data?.chatAccess) return ( diff --git a/apps/sim/app/extension/workspaces.tsx b/apps/sim/app/extension/workspaces.tsx index 74f5d607252..e3ed7a67bf2 100644 --- a/apps/sim/app/extension/workspaces.tsx +++ b/apps/sim/app/extension/workspaces.tsx @@ -11,17 +11,14 @@ import { } from '@sim/emcn' import { isApiClientError } from '@/lib/api/client/errors' import ExtensionLoading from '@/app/extension/loading' -import { useExtensionIdentity } from '@/app/extension/providers' -import { ExtensionSignIn } from '@/app/extension/sign-in' +import { ExtensionSessionExpired } from '@/app/extension/providers' import { useWorkspacesQuery } from '@/hooks/queries/workspace' export function ExtensionWorkspaces() { - const { userId } = useExtensionIdentity() const [scrollElement, setScrollElement] = useState(null) const scrollEdges = useScrollEdges(scrollElement) const { data: workspaces, isPending, error, refetch } = useWorkspacesQuery() - if (isApiClientError(error) && error.status === 401) - return + if (isApiClientError(error) && error.status === 401) return if (isPending) return const transientError = !isApiClientError(error) || error.status === 408 || error.status === 429 || error.status >= 500 diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index fc7b5a14576..5a5d2e60c8b 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -233,7 +233,7 @@ export function useSpeechToText({ : { workspaceId: workspaceIdRef.current }, }) } catch (err) { - if (!mountedRef.current || generation !== generationRef.current) return false + if (generation !== generationRef.current) return false if (isApiClientError(err) && err.status === 402) { const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member' onUsageLimitExceededRef.current?.(err.message, isMemberLimit) @@ -244,7 +244,7 @@ export function useSpeechToText({ const token = typeof tokenData.token === 'string' ? tokenData.token : undefined if (!token) throw new Error('Failed to get speech token') - if (!mountedRef.current || generation !== generationRef.current) return false + if (generation !== generationRef.current) return false const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -256,7 +256,7 @@ export function useSpeechToText({ }, }) - if (!mountedRef.current || generation !== generationRef.current) { + if (generation !== generationRef.current) { stream.getTracks().forEach((track) => track.stop()) return false } @@ -281,7 +281,7 @@ export function useSpeechToText({ ws.onerror = () => reject(new Error('WebSocket connection failed')) ws.onmessage = (event) => { - if (!mountedRef.current || generation !== generationRef.current) return + if (generation !== generationRef.current) return try { const msg = JSON.parse(event.data) @@ -325,7 +325,7 @@ export function useSpeechToText({ } }) - if (!mountedRef.current || generation !== generationRef.current) { + if (generation !== generationRef.current) { ws.close() stream.getTracks().forEach((track) => track.stop()) return false diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts index dc82652bd25..bd339ed6f7a 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts @@ -1176,24 +1176,86 @@ describe('projectTraceSpansForSecrets whole-trace content budget', () => { expect(projected[1].toolCalls).toHaveLength(50_000) }) - it('withholds the whole top-level span whose descendant crossed the budget', async () => { + it('keeps checked content under a single workflow root when a descendant crosses the budget', async () => { const projected = await projectTraceSpansForSecrets( [ - heavySpan('span-a', 'first'), createSpan({ - id: 'span-b', - output: { marker: 'parent top-secret' }, - children: [heavySpan('child-1', 'child one'), heavySpan('child-2', 'child two')], + id: 'workflow-execution', + output: { marker: 'root top-secret' }, + children: [ + heavySpan('block-a', 'first top-secret'), + createSpan({ + id: 'loop', + output: { marker: 'loop top-secret' }, + children: [ + heavySpan('iteration-1', 'second'), + heavySpan('iteration-2', 'third top-secret', { + children: [createSpan({ id: 'nested', output: { marker: 'nested top-secret' } })], + }), + createSpan({ id: 'iteration-3', output: { marker: 'fourth top-secret' } }), + ], + }), + createSpan({ id: 'block-b', output: { marker: 'fifth top-secret' } }), + ], }), + createSpan({ id: 'after-root', output: { marker: 'sixth top-secret' } }), ], { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } ) - expect(projected[0].output?.marker).toBe('first') - expect(projected[1]).not.toHaveProperty('output') - expect(projected[1].contentWithheld).toBe(true) - expect(projected[1].children?.map((child) => child.id)).toEqual(['child-1', 'child-2']) - expect(projected[1].children?.every((child) => !Object.hasOwn(child, 'output'))).toBe(true) + const [root, afterRoot] = projected + const [blockA, loop, blockB] = root.children ?? [] + const [iteration1, iteration2, iteration3] = loop.children ?? [] + + for (const [kept, marker] of [ + [root, 'root {{API_SECRET}}'], + [blockA, 'first {{API_SECRET}}'], + [loop, 'loop {{API_SECRET}}'], + [iteration1, 'second'], + ] as const) { + expect(kept.output?.marker).toBe(marker) + expect(kept).not.toHaveProperty('contentWithheld') + } + for (const withheld of [iteration2, iteration2.children?.[0], iteration3, blockB, afterRoot]) { + expect(withheld).toMatchObject({ contentWithheld: true }) + expect(withheld).not.toHaveProperty('output') + } expect(JSON.stringify(projected)).not.toContain('top-secret') + expect(warnMock).toHaveBeenCalledWith( + 'Trace content exceeded the projection budget; withholding the remaining content', + expect.objectContaining({ retainedSpanCount: 4, withheldSpanCount: 5 }) + ) + }) + + it('withholds every span when a descendant fails for any reason but the budget', async () => { + const projected = await projectTraceSpansForSecrets( + [ + createSpan({ + id: 'workflow-execution', + output: { marker: 'root' }, + children: [ + createSpan({ id: 'block-a', output: { marker: 'first' } }), + createSpan({ + id: 'block-b', + toolCalls: Array.from({ length: 100_001 }, (_, index) => ({ + name: `tool-${index}`, + duration: 0, + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + status: 'success', + })), + }), + ], + }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + + expect(projected[0]).not.toHaveProperty('output') + expect(projected[0].children?.[0]).not.toHaveProperty('output') + expect(warnMock).toHaveBeenCalledWith( + 'Trace secret projection failed; retaining structural spans only', + expect.objectContaining({ executionId: 'execution-1', phase: 'read' }) + ) }) }) diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 0e0d7f1c733..2060479f2cd 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -86,6 +86,8 @@ interface ProjectionContext { omittedContentFields: number logDetails: ProjectionLogDetails displayFieldsBySpanId?: ReadonlyMap + /** Set once the content walk runs out of budget; every span after that keeps only its structure. */ + contentBudgetFailure?: TraceContentBudgetError } interface OversizedHydrationGate { @@ -1046,16 +1048,52 @@ async function sanitizeTraceSpan( if (span.children !== undefined) { assertTraceStructureArrayFits(span.children, state) - const children: TraceSpan[] = [] - for (const child of span.children) { - children.push(await sanitizeTraceSpan(child, context, depth + 1, state, assertContentIsSafe)) - } - projected.children = children + projected.children = await sanitizeSiblingSpans( + span.children, + context, + depth + 1, + state, + assertContentIsSafe + ) } return projected } +/** + * A budget failure stops at the span that crossed it, so the spans checked before it keep their + * content at any depth, ancestors included: each span's own content passed the whole-trace check + * before its children were walked. The span that crossed and every span after it fall back to + * structure, which resumes the budget where the retained spans left it. + */ +async function sanitizeSiblingSpans( + spans: readonly TraceSpan[], + context: ProjectionContext, + depth: number, + state: BoundedTraceStructureState, + assertContentIsSafe: SpanContentAssertion +): Promise { + const projected: TraceSpan[] = [] + let index = 0 + for (; index < spans.length && !context.contentBudgetFailure; index += 1) { + const retainedStructureNodes = state.nodes + try { + projected.push( + await sanitizeTraceSpan(spans[index], context, depth, state, assertContentIsSafe) + ) + } catch (error) { + if (!(error instanceof TraceContentBudgetError)) throw error + context.contentBudgetFailure = error + state.nodes = retainedStructureNodes + break + } + } + const withheld = projectBoundedArray(spans.slice(index), (span) => + structuralOnlySpan(span, state, depth) + ) + return projected.concat(withheld) +} + function stripLegacyToolCallContent(call: ToolCall): ToolCall { return omit(call, ['input', 'output', 'error']) as ToolCall } @@ -1279,9 +1317,9 @@ function projectBoundedTraceSpans( state: BoundedTraceStructureState, depth: number ) => TraceSpan | undefined, - logDetails: ProjectionLogDetails, - state: BoundedTraceStructureState = { nodes: 0, truncated: false } + logDetails: ProjectionLogDetails ): TraceSpan[] { + const state: BoundedTraceStructureState = { nodes: 0, truncated: false } const projected = projectBoundedArray(traceSpans, (span) => project(span, state, 0)) if (state.truncated) { logger.warn( @@ -1292,14 +1330,12 @@ function projectBoundedTraceSpans( return projected } -/** Pass `state` to continue a structure budget that earlier spans of the same trace already spent. */ function structuralOnlyTraceSpans( traceSpans: TraceSpan[], - logDetails: ProjectionLogDetails, - state?: BoundedTraceStructureState + logDetails: ProjectionLogDetails ): TraceSpan[] { try { - return projectBoundedTraceSpans(traceSpans, structuralOnlySpan, logDetails, state) + return projectBoundedTraceSpans(traceSpans, structuralOnlySpan, logDetails) } catch (error) { logger.warn('Trace structure could not be safely traversed; omitting projected spans', { ...logDetails, @@ -1643,28 +1679,30 @@ async function assertPostTransformTraceSpansAreSafe( await verifyPostTransformLargeValues(refs, context, new Set()) } -/** - * Counts the real spans kept before `withheldFrom` and withheld from it on, naming the withheld - * display-envelope fields apart from them. - */ +/** Counts the real spans that kept or lost content, naming the withheld display-envelope fields apart. */ function describeWithheldContent( - traceSpans: readonly TraceSpan[], - withheldFrom: number, + projected: readonly TraceSpan[], displayFieldsBySpanId: ReadonlyMap | undefined ): { retainedSpanCount: number; withheldSpanCount: number; withheldDisplayFields?: string[] } { - let retainedDisplayFieldCount = 0 + let retainedSpanCount = 0 + let withheldSpanCount = 0 const withheldDisplayFields: string[] = [] - if (displayFieldsBySpanId?.size) { - for (let index = 0; index < traceSpans.length; index += 1) { - const displayField = displayFieldsBySpanId.get(traceSpans[index].id) - if (displayField === undefined) continue - if (index < withheldFrom) retainedDisplayFieldCount += 1 - else withheldDisplayFields.push(displayField) + const visit = (spans: readonly TraceSpan[]) => { + for (const span of spans) { + const displayField = displayFieldsBySpanId?.get(span.id) + if (displayField === undefined) { + if (span.contentWithheld) withheldSpanCount += 1 + else retainedSpanCount += 1 + } else if (span.contentWithheld) { + withheldDisplayFields.push(displayField) + } + if (span.children) visit(span.children) } } + visit(projected) return { - retainedSpanCount: withheldFrom - retainedDisplayFieldCount, - withheldSpanCount: traceSpans.length - withheldFrom - withheldDisplayFields.length, + retainedSpanCount, + withheldSpanCount, ...(withheldDisplayFields.length > 0 ? { withheldDisplayFields } : {}), } } @@ -1732,40 +1770,30 @@ export async function projectTraceSpansForSecrets( options.displayFieldsBySpanId ) - const projected: TraceSpan[] = [] const state: BoundedTraceStructureState = { nodes: 0, truncated: false } assertTraceStructureArrayFits(traceSpans, state) - const assertContentIsSafe = createSpanContentAssertion(traceSpans, context) - for (let index = 0; index < traceSpans.length; index += 1) { - const retainedStructureNodes = state.nodes - try { - projected.push( - await sanitizeTraceSpan(traceSpans[index], context, 0, state, assertContentIsSafe) - ) - } catch (error) { - if (!(error instanceof TraceContentBudgetError)) throw error - /** - * Every span already returned was sanitized and then passed the whole-trace check, - * descendants included, so only the span that crossed the budget and those after it - * lose their content. The fallback resumes the structure budget where the retained - * spans left it, so the trace keeps exactly the structure a whole-trace fallback would. - */ - logger.warn( - 'Trace content exceeded the projection budget; withholding the remaining content', - { - ...logDetails, - ...describeWithheldContent(traceSpans, index, options.displayFieldsBySpanId), - failure: describeProjectionFailure(error), - } - ) - return [ - ...projected, - ...structuralOnlyTraceSpans(traceSpans.slice(index), logDetails, { - nodes: retainedStructureNodes, - truncated: false, - }), - ] - } + const projected = await sanitizeSiblingSpans( + traceSpans, + context, + 0, + state, + createSpanContentAssertion(traceSpans, context) + ) + if (context.contentBudgetFailure) { + logger.warn( + 'Trace content exceeded the projection budget; withholding the remaining content', + { + ...logDetails, + ...describeWithheldContent(projected, options.displayFieldsBySpanId), + failure: describeProjectionFailure(context.contentBudgetFailure), + } + ) + } + if (state.truncated) { + logger.warn( + 'Trace structure exceeded projection limits; truncating projected structure', + logDetails + ) } return projected } catch (error) { diff --git a/apps/sim/lib/mothership/chat/page-context.ts b/apps/sim/lib/mothership/chat/page-context.ts index be88ee20e6a..78c8b827645 100644 --- a/apps/sim/lib/mothership/chat/page-context.ts +++ b/apps/sim/lib/mothership/chat/page-context.ts @@ -1,9 +1,9 @@ import type { PageSnapshot } from '@sim/browser-extension-protocol' +import { utf8ByteLength } from '@sim/utils/paste' import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits' const MAX_PAGE_CONTEXT_TEXT_LENGTH = 150_000 const MAX_PAGE_CONTEXT_BYTES = 450_000 -const encoder = new TextEncoder() /** Bounds the combined page budget and refuses ambiguous snapshot identities within a turn. */ export function getPageContextBudgetError( @@ -19,7 +19,7 @@ export function getPageContextBudgetError( if (ids.has(context.snapshot.id)) return 'Page snapshot IDs must be unique within a message' ids.add(context.snapshot.id) textLength += context.snapshot.text.length - bytes += encoder.encode(JSON.stringify(context.snapshot)).byteLength + bytes += utf8ByteLength(JSON.stringify(context.snapshot)) if (textLength > MAX_PAGE_CONTEXT_TEXT_LENGTH || bytes > MAX_PAGE_CONTEXT_BYTES) return 'These pages contain too much text' } diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index f2a722a2f73..dccbdf2e47e 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -194,14 +194,6 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({ }, }) -async function findLinkIdByName(organizationId: string, typedName: string) { - const link = await repository.findSimLinkDestination( - organizationId, - stripSimLinkPrefix(typedName) - ) - return link?.id -} - /** * One link by id or by name as typed. Hidden exactly as browsing hides it: an unlisted link reads * as missing to anyone who cannot manage it. @@ -215,7 +207,14 @@ export const getSimLinkView = defineAuthorizedOrganizationUseCase({ }: OrganizationUseCaseContext) { const viewer = viewerOf(context) const linkId = - 'linkId' in input ? input.linkId : await findLinkIdByName(input.organizationId, input.name) + 'linkId' in input + ? input.linkId + : ( + await repository.findSimLinkDestination( + input.organizationId, + stripSimLinkPrefix(input.name) + ) + )?.id const record = linkId ? await repository.getSimLink(db, input.organizationId, linkId) : null if (!record || (record.unlisted && !canManage(record.createdBy?.id ?? null, viewer))) throw new OrchestrationError('not_found', 'Link not found') diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts index 64996fd55e8..a82c9a4dc29 100644 --- a/apps/sim/scripts/test-browser-extension-e2e.ts +++ b/apps/sim/scripts/test-browser-extension-e2e.ts @@ -1502,6 +1502,7 @@ try { await mic.click() await expect(stop).toBeVisible() await expect.poll(() => sessions.length).toBe(2) + assert.equal(sessions[0].closed, false, 'Stale-transcript grace must still be open') sessions[0].socket.send( JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' }) ) diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index 4532a879758..06a9828905f 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -127,7 +127,6 @@ export const SIM_LINK_URL_MAX_LENGTH = 2048 /** Short hosts a link opens from: `sim/` and `go/`. */ export const SIM_LINK_HOSTS = ['sim', 'go'] as const -/** {@link SIM_LINK_HOSTS} as a regular-expression alternation. */ export const SIM_LINK_HOST_PATTERN = SIM_LINK_HOSTS.join('|') /** Where the extension asks for the address-bar suggestions behind its `sim` keyword. */