eric] fix app builder cross-agent chat leak via draftLaunchMap (drop activeSessionId fallback)

This commit is contained in:
ciregenz
2026-05-11 11:28:48 -07:00
parent 741fea5c89
commit c670033238
4 changed files with 179 additions and 40 deletions
+57 -6
View File
@@ -147,6 +147,37 @@ class _TokenScrubFilter(logging.Filter):
return True
return False
@classmethod
def _scrub_args(cls, args):
"""Replace token within args in-place-equivalent, preserving the
original tuple/dict shape. Uvicorn's AccessFormatter unpacks
record.args as a 5-tuple (client_addr, method, full_path,
http_version, status_code); blanking args to None — what the
previous slow path did — triggered `cannot unpack non-iterable
NoneType object` for every access-logged line that contained
`?token=...`. Returns the same object if nothing was rewritten."""
if args is None:
return args
if isinstance(args, dict):
new_dict = None
for k, v in args.items():
if isinstance(v, str) and _TOKEN in v:
if new_dict is None:
new_dict = dict(args)
new_dict[k] = v.replace(_TOKEN, cls._PLACEHOLDER)
return new_dict if new_dict is not None else args
if isinstance(args, tuple):
new_list = None
for i, v in enumerate(args):
if isinstance(v, str) and _TOKEN in v:
if new_list is None:
new_list = list(args)
new_list[i] = v.replace(_TOKEN, cls._PLACEHOLDER)
return tuple(new_list) if new_list is not None else args
if isinstance(args, str) and _TOKEN in args:
return args.replace(_TOKEN, cls._PLACEHOLDER)
return args
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
if not _TOKEN:
return True
@@ -157,13 +188,33 @@ class _TokenScrubFilter(logging.Filter):
raw_msg = record.msg if isinstance(record.msg, str) else ""
if _TOKEN not in raw_msg and not self._args_might_contain_token(record.args):
return True
# Slow path: token might be present after substitution. Format,
# scrub, and clear args so the formatted msg is what handlers see.
# Slow path. Two-step scrub so we cover both shapes:
# 1. In-place rewrite of record.msg and any string in record.args
# (or string-valued dict entry). Preserves args shape so
# uvicorn's AccessFormatter — which unpacks record.args as a
# 5-tuple and would explode on args=None — keeps working.
# 2. Render via record.getMessage() and check the substituted
# output. If a token survived step 1 (because it was buried
# inside a nested structure or a custom object's repr, e.g.
# `logger.info("env: %s", env_dict)` where the dict's repr
# exposes the value), bake the redacted final string into
# record.msg and clear args. This last-resort path only
# trips for records that the in-place pass couldn't reach,
# and uvicorn access logs never hit it (their args are
# always primitive strings/ints, fully scrubbed by step 1).
try:
msg = record.getMessage()
if _TOKEN in msg:
record.msg = msg.replace(_TOKEN, self._PLACEHOLDER)
record.args = None
if isinstance(record.msg, str) and _TOKEN in record.msg:
record.msg = record.msg.replace(_TOKEN, self._PLACEHOLDER)
scrubbed = self._scrub_args(record.args)
if scrubbed is not record.args:
record.args = scrubbed
try:
rendered = record.getMessage()
if _TOKEN in rendered:
record.msg = rendered.replace(_TOKEN, self._PLACEHOLDER)
record.args = None
except Exception:
pass
except Exception:
# Never let the scrubber suppress a log line — if formatting
# fails for any reason, fall through and let normal handling
+26 -1
View File
@@ -493,12 +493,37 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
})();
}, [dispatch, output, stableWorkspaceId, settingsLoaded, modelsLoaded, defaultModel, defaultThinkingLevel, modelsByProvider]);
// Resolve our bound session id strictly through our own pointers:
// 1. initialDraftId is the draft we created OR the real id reattached
// from output.session_id.
// 2. If the draft was launched in the meantime, the real id lives in
// draftLaunchMap — promote to that.
// We deliberately do NOT fall back to state.agents.activeSessionId here.
// activeSessionId is a global pointer that any dashboard click, child
// chat, or sibling App Builder can clobber, so falling back to it bled
// unrelated agents' chats into the App Builder while a session was
// still loading (e.g. JobFinder's transcript showing inside the
// Chatbot app builder).
const launchedFromDraft = useAppSelector((state) =>
initialDraftId ? state.agents.draftLaunchMap[initialDraftId] : undefined,
);
const effectiveSessionId = useAppSelector((state) => {
if (!initialDraftId) return null;
if (state.agents.sessions[initialDraftId]) return initialDraftId;
return state.agents.activeSessionId;
const mapped = state.agents.draftLaunchMap[initialDraftId];
if (mapped && state.agents.sessions[mapped]) return mapped;
return null;
});
// Once a draft has been replaced by a real launched session, promote
// initialDraftId so subsequent renders bypass the map lookup and we
// stay on the real id even if draftLaunchMap is later cleaned up.
useEffect(() => {
if (launchedFromDraft && initialDraftId && launchedFromDraft !== initialDraftId) {
setInitialDraftId(launchedFromDraft);
}
}, [launchedFromDraft, initialDraftId]);
const agentStatus = useAppSelector((state) => {
if (!effectiveSessionId) return null;
return state.agents.sessions[effectiveSessionId]?.status ?? null;
+87 -33
View File
@@ -4,6 +4,13 @@ import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
// We render apps in a <webview> when running inside the Electron shell so
// they escape iframe restrictions (popups, mic/camera, WebAuthn,
// cross-origin fetch with cookies). Outside Electron — webpack-dev-server
// in the browser, jest, etc. — `<webview>` is a no-op element, so we fall
// back to the iframe path. Same detection BrowserCard uses.
const isElectron = navigator.userAgent.includes('Electron');
export interface ViewPreviewHandle {
reload: () => void;
}
@@ -53,6 +60,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
style,
}, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
const ctx = useElementSelection();
const [reloadKey, setReloadKey] = useState(0);
// Track auth token in state so the iframe URL is rebuilt the moment the
@@ -69,14 +77,6 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return () => { cancelled = true; };
}, [authToken]);
useEffect(() => {
if (ctx && iframeRef.current) {
ctx.iframeRef.current = iframeRef.current;
}
}, [ctx, frontendCode, serveUrl]);
useIframeElementSelector(iframeRef);
const iframeSrc = useMemo(() => {
if (!serveUrl) return undefined;
// Don't ship a tokenless URL — the backend auth middleware would 401 and
@@ -92,9 +92,41 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return buildSrcdoc(frontendCode, inputData, backendResult);
}, [serveUrl, frontendCode, inputData, backendResult]);
// Use webview when (a) we're in Electron and (b) we have a real serveUrl
// to navigate to. Inline srcdoc still goes through the iframe path: a
// webview's only inline option is `data:text/html,...` which the Electron
// sandbox treats as a null/opaque origin, breaking localStorage and
// same-origin fetch for the rendered app.
const useWebview = isElectron && !!iframeSrc;
// Wire the iframe element into the element-selection context only when
// we're actually rendering an iframe. A <webview>'s document lives in
// a separate renderer process — its contentDocument is null from the
// host page, so useIframeElementSelector's overlay/listener injection
// can't reach it. Element selection on in-Electron previews is a known
// regression of the webview swap.
useEffect(() => {
if (useWebview) return;
if (ctx && iframeRef.current) {
ctx.iframeRef.current = iframeRef.current;
}
}, [ctx, frontendCode, serveUrl, useWebview]);
// Selector hook keys off iframeRef.current. When webview is mounted
// instead, no <iframe> is rendered, so iframeRef.current stays null and
// setupSelection() bails — same effect as an explicit gate.
useIframeElementSelector(iframeRef);
useImperativeHandle(ref, () => ({
reload: () => {
if (serveUrl) {
if (useWebview) {
// Bumping reloadKey changes _v= in the URL, which React threads
// back into the webview's `src` prop and re-navigates. Belt-and-
// suspenders: also call reload() on the element in case React
// skipped the re-render (e.g. reloadKey was already pending).
setReloadKey(k => k + 1);
webviewRef.current?.reload?.();
} else if (serveUrl) {
setReloadKey(k => k + 1);
} else if (iframeRef.current && srcdoc) {
iframeRef.current.srcdoc = '';
@@ -103,13 +135,14 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
});
}
},
}), [serveUrl, srcdoc]);
}), [useWebview, serveUrl, srcdoc]);
useEffect(() => {
if (useWebview) return;
if (iframeRef.current && srcdoc != null) {
iframeRef.current.srcdoc = srcdoc;
}
}, [srcdoc]);
}, [srcdoc, useWebview]);
const hasContent = !!(serveUrl || frontendCode?.trim());
@@ -159,28 +192,49 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}),
}}
>
<iframe
ref={iframeRef}
// Key stable across reloads — only changes when switching MODES
// (URL vs srcdoc). Previously the key embedded reloadKey, which
// unmounted-and-remounted the iframe on every reload, producing
// a visible blank flash mid-burst. With a stable key, reloadKey
// still updates iframeSrc → React swaps the src attribute on
// the EXISTING iframe element → browser navigates in place,
// keeping the prior frame's pixels visible until the new doc
// paints. No flash.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={iframeSrc}
sandbox="allow-scripts allow-same-origin"
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
...style,
}}
title="App Preview"
/>
{useWebview ? (
<webview
ref={(el: any) => { webviewRef.current = el; }}
// Stable key so React swaps src in place rather than remounting
// — preserves the prior frame's pixels through reload, same
// pattern as the iframe path.
key="url-mode-webview"
src={iframeSrc}
// Autoplay is the most common cross-app expectation; matches
// the BrowserCard default. Plugins / nodeintegration stay off.
webpreferences="autoplayPolicy=no-user-gesture-required"
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
...style,
}}
/>
) : (
<iframe
ref={iframeRef}
// Key stable across reloads — only changes when switching MODES
// (URL vs srcdoc). Previously the key embedded reloadKey, which
// unmounted-and-remounted the iframe on every reload, producing
// a visible blank flash mid-burst. With a stable key, reloadKey
// still updates iframeSrc → React swaps the src attribute on
// the EXISTING iframe element → browser navigates in place,
// keeping the prior frame's pixels visible until the new doc
// paints. No flash.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={iframeSrc}
sandbox="allow-scripts allow-same-origin"
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
...style,
}}
title="App Preview"
/>
)}
</Box>
);
});
+9
View File
@@ -158,6 +158,13 @@ interface AgentsState {
loading: boolean;
historySearch: HistorySearchState;
trackedNotificationIds: string[];
// Maps the temporary frontend draft id minted by createDraftSession to the
// real backend session id that replaces it once launchAndSendFirstMessage
// fulfills. Lets components that bound to the draft id (App Builder /
// ViewEditor in particular) find their new session without falling back
// to the global `activeSessionId` — which would silently leak whatever
// agent the user last interacted with from the dashboard.
draftLaunchMap: Record<string, string>;
}
const initialState: AgentsState = {
@@ -168,6 +175,7 @@ const initialState: AgentsState = {
loading: false,
historySearch: { results: [], total: 0, hasMore: false, query: '', loading: false },
trackedNotificationIds: [],
draftLaunchMap: {},
};
export const fetchSessions = createAsyncThunk(
@@ -1105,6 +1113,7 @@ const agentsSlice = createSlice({
delete state.sessions[draftId];
state.sessions[session.id] = { ...session, streamingMessage: null, tool_group_meta: session.tool_group_meta ?? {} };
state.activeSessionId = session.id;
state.draftLaunchMap[draftId] = session.id;
state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id));
if (shouldExpand && !state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);