diff --git a/backend/auth.py b/backend/auth.py index 6da03d94..441560fe 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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 diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index c6319d18..97743708 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -493,12 +493,37 @@ const ViewEditor: React.FC = ({ 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; diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index ff07e00e..e94cc5b5 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -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 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. — `` 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(({ style, }, ref) => { const iframeRef = useRef(null); + const webviewRef = useRef(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(({ 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(({ 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 '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