mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-03 08:18:43 +02:00
eric] fix app builder cross-agent chat leak via draftLaunchMap (drop activeSessionId fallback)
This commit is contained in:
+57
-6
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user