From 45fbde7b940f50e8d8c3e42900a7d26d0d0d08b8 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:20:19 +0800 Subject: [PATCH 1/9] feat(web): add single local runtime connection --- frontend/src/lib/localRuntime.ts | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 frontend/src/lib/localRuntime.ts diff --git a/frontend/src/lib/localRuntime.ts b/frontend/src/lib/localRuntime.ts new file mode 100644 index 00000000..3c810985 --- /dev/null +++ b/frontend/src/lib/localRuntime.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { SHOWCASE } from "./showcase/router"; +import { resolveApiBase } from "./apiBase"; + +export type LocalRuntimeStatus = "connected" | "disconnected"; + +const STORAGE_KEY = "offeru_web_local_runtime"; +const API_BASE = resolveApiBase(); + +let status: LocalRuntimeStatus = SHOWCASE ? "disconnected" : "connected"; +const listeners = new Set<() => void>(); + +function emit() { + for (const listener of listeners) listener(); +} + +function setStatus(next: LocalRuntimeStatus) { + if (status === next) return; + status = next; + emit(); +} + +function remembered(): boolean { + if (!SHOWCASE || typeof localStorage === "undefined") return false; + return localStorage.getItem(STORAGE_KEY) === "connected"; +} + +function remember(next: LocalRuntimeStatus) { + if (!SHOWCASE || typeof localStorage === "undefined") return; + if (next === "connected") localStorage.setItem(STORAGE_KEY, "connected"); + else localStorage.removeItem(STORAGE_KEY); +} + +export function isLocalRuntimeConnected(): boolean { + return !SHOWCASE || status === "connected"; +} + +export async function connectLocalRuntime(timeoutMs = 2500): Promise { + if (!SHOWCASE) { + setStatus("connected"); + return true; + } + + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const init = { + method: "GET", + mode: "cors", + cache: "no-store", + credentials: "omit", + redirect: "error", + signal: controller.signal, + targetAddressSpace: "loopback", + } as RequestInit & { targetAddressSpace: "loopback" }; + + const response = await fetch(new Request(`${API_BASE}/api/health`, init)); + const payload = response.ok ? await response.json().catch(() => ({})) : {}; + const ok = response.ok && payload?.status === "ok"; + setStatus(ok ? "connected" : "disconnected"); + remember(ok ? "connected" : "disconnected"); + return ok; + } catch { + setStatus("disconnected"); + remember("disconnected"); + return false; + } finally { + window.clearTimeout(timer); + } +} + +export function disconnectLocalRuntime() { + if (!SHOWCASE) return; + remember("disconnected"); + setStatus("disconnected"); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function snapshot() { + return status; +} + +export function useLocalRuntime() { + const current = useSyncExternalStore(subscribe, snapshot, snapshot); + const connect = useCallback(() => connectLocalRuntime(), []); + const disconnect = useCallback(() => disconnectLocalRuntime(), []); + + useEffect(() => { + if (SHOWCASE && current === "disconnected" && remembered()) { + void connectLocalRuntime(); + } + }, [current]); + + return { + status: current, + connected: !SHOWCASE || current === "connected", + connect, + disconnect, + apiBase: API_BASE, + }; +} From 6326f056c04d6adee667d81d73806817c9bb00f2 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:20:33 +0800 Subject: [PATCH 2/9] feat(web): route connected showcase through local runtime --- frontend/src/lib/api.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 28da1c3e..5da2f778 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import { SHOWCASE, showcaseHandle } from "./showcase/router"; import { showcaseChatResponse } from "./showcase/llm"; import { resolveApiBase } from "./apiBase"; +import { isLocalRuntimeConnected } from "./localRuntime"; import { safeClientErrorMessage } from "./safe-error"; import { decideAgentRuntimeActionInDesktop, @@ -53,8 +54,8 @@ function buildQuery(params?: Record) { } export async function request(path: string, options?: RequestInit): Promise { - if (SHOWCASE) { - // 展示模式:全部请求由本地 IndexedDB 数据层承载(无需 Python 后端) + if (SHOWCASE && !isLocalRuntimeConnected()) { + // Web 未连接本机 Runtime 时继续使用 Showcase;连接后复用同一套本地 API。 return (await showcaseHandle(path, options)) as T; } let res: Response; @@ -83,9 +84,8 @@ async function readEventStream( onEvent?: (event: string, data: any) => void, signal?: AbortSignal ): Promise { - if (SHOWCASE) { - // 展示模式:Agent 工作流端点(optimize/interviews)不接本地数据层, - // 返回空结果避免抛错;对话式交互见 profileApi.chat 的合成 SSE。 + if (SHOWCASE && !isLocalRuntimeConnected()) { + // 纯 Demo 不执行真实 Agent 工作流;连接本地 Runtime 后走同一 SSE 协议。 return {} as T; } const res = await fetch(`${API_BASE}${path}`, { @@ -1584,8 +1584,8 @@ export const profileApi = { request(`/api/profile/sections/${id}`, { method: "DELETE" }), chat: async (data: { topic: string; message: string; session_id?: number }) => { - if (SHOWCASE) { - // 展示模式:合成 SSE 流(本地模板或浏览器直连 LLM),不依赖 Python 后端 + if (SHOWCASE && !isLocalRuntimeConnected()) { + // 未连接本地 Runtime 时保留演示对话;连接后复用真实 Profile Agent。 return showcaseChatResponse(data.topic || "general", data.message || ""); } const res = await fetch(`${API_BASE}/api/profile/chat`, { From 4b1c4497d4dbba56a703485c77657870449c412f Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:20:49 +0800 Subject: [PATCH 3/9] feat(web): reuse Agent connection state when local runtime is linked --- frontend/src/lib/agentConnection.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/agentConnection.tsx b/frontend/src/lib/agentConnection.tsx index 4df03357..15eb2c12 100644 --- a/frontend/src/lib/agentConnection.tsx +++ b/frontend/src/lib/agentConnection.tsx @@ -3,6 +3,7 @@ import useSWR from "swr"; import { usePathname } from "next/navigation"; import { agentRuntimeApi, type AgentConnectionsSnapshot } from "./api"; import { SHOWCASE } from "./showcase/router"; +import { useLocalRuntime } from "./localRuntime"; import { safeClientErrorMessage } from "./safe-error"; import { useWorkbench } from "./workbench"; import type { components } from "./api-types.generated"; @@ -65,6 +66,7 @@ function entityFromRoute(pathname: string): { entity_type: string; entity_id: st export function AgentConnectionProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { selection } = useWorkbench(); + const localRuntime = useLocalRuntime(); const [open, setOpen] = useState(false); const [probing, setProbing] = useState(null); const [integrating, setIntegrating] = useState(null); @@ -85,7 +87,7 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod const controller = useRef(null); const queued = useRef<{ sequence: number; body: AgentContextRequest; title: string } | null>(null); const { data, error, isLoading, isValidating, mutate } = useSWR( - SHOWCASE || /^\/resume\/print\//.test(pathname) ? null : "offeru-agent-connections", + (SHOWCASE && !localRuntime.connected) || /^\/resume\/print\//.test(pathname) ? null : "offeru-agent-connections", agentRuntimeApi.connections, { refreshInterval: 15000, dedupingInterval: 5000, errorRetryCount: 2, errorRetryInterval: 10000 }, ); @@ -175,7 +177,7 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod }, [pathname, selection]); useEffect(() => { - if (SHOWCASE || /^\/resume\/print\//.test(pathname)) { + if ((SHOWCASE && !localRuntime.connected) || /^\/resume\/print\//.test(pathname)) { queued.current = null; sequence.current += 1; return; @@ -185,10 +187,10 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod setSync((previous) => ({ ...previous, status: "syncing", title: body.title, error: "" })); const timer = window.setTimeout(() => void flush(), 250); return () => window.clearTimeout(timer); - }, [payload, pathname, retry, flush]); + }, [payload, pathname, retry, flush, localRuntime.connected]); const probe = useCallback(async (id: string) => { - if (probeInFlight.current || SHOWCASE) return; + if (probeInFlight.current || (SHOWCASE && !localRuntime.connected)) return; probeInFlight.current = true; setProbing(id); setProbeError(""); @@ -209,10 +211,10 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod probeInFlight.current = false; if (mounted.current) setProbing(null); } - }, [data, mutate, record]); + }, [data, mutate, record, localRuntime.connected]); const connect = useCallback(async (id: string, action: "install" | "update" | "repair") => { - if (probeInFlight.current || SHOWCASE) return; + if (probeInFlight.current || (SHOWCASE && !localRuntime.connected)) return; probeInFlight.current = true; setIntegrating(id); setProbeError(""); @@ -233,7 +235,7 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod probeInFlight.current = false; if (mounted.current) setIntegrating(null); } - }, [data, mutate, record]); + }, [data, mutate, record, localRuntime.connected]); const refresh = useCallback(() => { setProbeError(""); void mutate().catch(() => undefined); }, [mutate]); const retrySync = useCallback(() => setRetry((value) => value + 1), []); From caff3fdafea8fa5e0ea620bdb483c3220ad44cfe Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:21:21 +0800 Subject: [PATCH 4/9] feat(web): make Agent panel one-click local link --- .../workbench/AgentConnectionPanel.tsx | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/workbench/AgentConnectionPanel.tsx b/frontend/src/components/workbench/AgentConnectionPanel.tsx index 579b97bc..3ccb6d19 100644 --- a/frontend/src/components/workbench/AgentConnectionPanel.tsx +++ b/frontend/src/components/workbench/AgentConnectionPanel.tsx @@ -9,6 +9,7 @@ import { import { type AgentConnection } from "@/lib/api"; import { connectionTime, useAgentConnection } from "@/lib/agentConnection"; import { SHOWCASE } from "@/lib/showcase/router"; +import { useLocalRuntime } from "@/lib/localRuntime"; const STATUS = { missing: { label: "未检测到", tone: "text-[var(--foreground-muted)]", title: "先准备好本机 Agent", detail: "打开官方指南完成安装,然后回到这里重新检查。" }, @@ -68,12 +69,14 @@ function currentStatus(item: AgentConnection) { export function AgentConnectionStatus({ compact = false }: { compact?: boolean }) { const state = useAgentConnection(); + const localRuntime = useLocalRuntime(); const ready = state.snapshot?.items.find((item) => currentStatus(item) === STATUS.ready); const hasProblem = Boolean(state.error || state.stale || state.sync.status === "failed"); const pending = Boolean(state.loading || state.probing || state.sync.status === "syncing"); - const label = SHOWCASE ? "Agent · 展示模式" : hasProblem ? "Agent · 需要处理" + const label = SHOWCASE && !localRuntime.connected ? "连接本地 OfferU" : hasProblem ? "Agent · 需要处理" : pending ? "Agent · 正在检查 / 同步" : ready ? "Agent · 接入检查通过" : "连接本机 Agent"; - const detail = state.sync.status === "failed" ? "内容同步失败,点击重试" + const detail = SHOWCASE && !localRuntime.connected ? "连接你电脑里的 Agent 和真实职业数据" + : state.sync.status === "failed" ? "内容同步失败,点击重试" : state.error || state.stale ? "状态未更新,点击查看" : ready ? `最近同步 ${connectionTime(state.sync.confirmedAt)}` : "自动检测 · 沿用已有登录"; @@ -110,8 +113,11 @@ function SetupStep({ index, title, done, busy, detail }: { export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean }) { const state = useAgentConnection(); + const localRuntime = useLocalRuntime(); const [selectedId, setSelectedId] = useState(null); const [showAll, setShowAll] = useState(false); + const [connectingRuntime, setConnectingRuntime] = useState(false); + const [runtimeError, setRuntimeError] = useState(""); const candidates = state.snapshot?.items || []; const beginnerCandidates = candidates.filter((item) => item.beginner); const suggested = beginnerCandidates.find((item) => item.recommended) @@ -145,7 +151,33 @@ export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean

自动发现本机 Agent,检查接入,把当前工作交给它。同步进展随时可看。

- {SHOWCASE ?

这是展示模式。请在本机 OfferU 中连接 Agent,查看真实同步状态。

: <> + {SHOWCASE && !localRuntime.connected ?
+
+

连接本地 OfferU

+

+ 直接复用你电脑里的 OfferU Runtime、职业数据和已登录的 OMP / Codex / Claude。无需再配置一套 Web Agent。 +

+ {runtimeError &&

{runtimeError}

} +
+ + 只连接 127.0.0.1,不上传本地职业数据。 +
+
+
: <> {(state.error || state.stale) &&
{state.error || "状态暂未更新,下面保留的是上次结果。"} From e1a1d4377b4eb4127fb8d1902c90f1d23137adfd Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:21:39 +0800 Subject: [PATCH 5/9] feat(web): allow trusted OfferU web origin to local runtime --- backend/app/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 5c31d41e..e10209f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -167,10 +167,11 @@ async def _start_work_source_auto_sync() -> None: # 重指 LLM base_url 形成数据外泄链)。因此 env 提供的值必须过白名单:仅允许本机回环、 # tauri 协议与浏览器扩展来源;其余一律拒绝并明示,绝不静默放宽。 _CORS_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "[::1]", "tauri.localhost"} +_TRUSTED_WEB_ORIGINS = {"https://avabbbb.github.io"} def _is_allowed_cors_origin(origin: str) -> bool: - if origin in ("tauri://localhost",): + if origin in ("tauri://localhost",) or origin in _TRUSTED_WEB_ORIGINS: return True try: parts = urlsplit(origin) @@ -194,12 +195,12 @@ def _is_allowed_cors_origin(origin: str) -> bool: _dropped_cors_origins.append(_origin) if _dropped_cors_origins: logger.warning( - "CORS_ORIGINS 含非本机来源已拒绝: %s(仅允许 localhost/127.0.0.1/tauri 来源)", + "CORS_ORIGINS 含未受信来源已拒绝: %s(仅允许 loopback/tauri/OfferU Web)", _dropped_cors_origins, ) # 前端 dev 端口 7410 无条件可用:系统环境变量 CORS_ORIGINS 会覆盖 settings, # 且该变量可能在旧值(5140/3000)上漂移,导致浏览器请求被 CORS 拦截。 -for _offeru_frontend_origin in ("http://localhost:7410", "http://127.0.0.1:7410"): +for _offeru_frontend_origin in ("http://localhost:7410", "http://127.0.0.1:7410", *_TRUSTED_WEB_ORIGINS): if _offeru_frontend_origin not in cors_origins: cors_origins.append(_offeru_frontend_origin) app.add_middleware( From 38f59b58b32ffba1d479e744db17427f854fdd1e Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:22:54 +0800 Subject: [PATCH 6/9] feat(web): share local runtime across SWR data hooks --- frontend/src/lib/hooks.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 921869c3..ad66b3d0 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -10,6 +10,7 @@ import useSWR from "swr"; import { SHOWCASE, showcaseHandle } from "@/lib/showcase/router"; import { showcaseChatResponse } from "@/lib/showcase/llm"; import { resolveApiBase } from "@/lib/apiBase"; +import { isLocalRuntimeConnected } from "@/lib/localRuntime"; import { safeClientErrorMessage } from "@/lib/safe-error"; import { decideAgentRuntimeActionInDesktop } from "@/lib/desktop-proposal-decision"; @@ -23,7 +24,7 @@ function formatBackendNetworkError(_error?: unknown) { * 通用 fetcher:SWR 默认请求函数 * 自动处理 JSON 解析和错误码 */ -const fetcher = async (url: string) => { if (SHOWCASE) { +const fetcher = async (url: string) => { if (SHOWCASE && !isLocalRuntimeConnected()) { // 展示模式:SWR 请求也由本地数据层承载(URL 为完整地址,提取 path) try { const parsed = new URL(url); @@ -51,7 +52,7 @@ const fetcher = async (url: string) => { if (SHOWCASE) { * 合成标准 Response,调用方无需感知后端是否存在。 */ async function showcaseFetch(url: string, init?: RequestInit): Promise { - if (!SHOWCASE) { + if (!SHOWCASE || isLocalRuntimeConnected()) { const target = /^https?:\/\//i.test(url) ? url : `${API_BASE}${url.startsWith("/") ? url : `/${url}`}`; From 7d4fe9fa34fa82a84952936702e676367984618f Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:23:53 +0800 Subject: [PATCH 7/9] test(web): cover unified local runtime link --- frontend/src/lib/localRuntime.test.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 frontend/src/lib/localRuntime.test.ts diff --git a/frontend/src/lib/localRuntime.test.ts b/frontend/src/lib/localRuntime.test.ts new file mode 100644 index 00000000..d7676228 --- /dev/null +++ b/frontend/src/lib/localRuntime.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./showcase/router", () => ({ SHOWCASE: true })); +vi.mock("./apiBase", () => ({ resolveApiBase: () => "http://127.0.0.1:8766" })); + +import { + connectLocalRuntime, + disconnectLocalRuntime, + isLocalRuntimeConnected, +} from "./localRuntime"; + +describe("localRuntime", () => { + beforeEach(() => { + localStorage.clear(); + disconnectLocalRuntime(); + vi.restoreAllMocks(); + }); + + it("connects the web surface to the existing local OfferU runtime", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + + expect(isLocalRuntimeConnected()).toBe(false); + await expect(connectLocalRuntime()).resolves.toBe(true); + expect(isLocalRuntimeConnected()).toBe(true); + expect(localStorage.getItem("offeru_web_local_runtime")).toBe("connected"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.url).toBe("http://127.0.0.1:8766/api/health"); + }); + + it("fails closed and stays in showcase mode when no local runtime is reachable", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("network unavailable"); + })); + + await expect(connectLocalRuntime()).resolves.toBe(false); + expect(isLocalRuntimeConnected()).toBe(false); + expect(localStorage.getItem("offeru_web_local_runtime")).toBeNull(); + }); +}); From fec23a7551f4dec28dc4896014357f9197fffdf8 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:24:09 +0800 Subject: [PATCH 8/9] test(web): lock trusted-origin local runtime boundary --- .../tests/test_web_local_runtime_security.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/tests/test_web_local_runtime_security.py diff --git a/backend/tests/test_web_local_runtime_security.py b/backend/tests/test_web_local_runtime_security.py new file mode 100644 index 00000000..a38718de --- /dev/null +++ b/backend/tests/test_web_local_runtime_security.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MAIN_SOURCE = (REPO_ROOT / "backend" / "app" / "main.py").read_text(encoding="utf-8") + + +def test_offer_web_origin_is_explicitly_allowlisted_for_loopback_runtime() -> None: + assert '"https://avabbbb.github.io"' in MAIN_SOURCE + assert "_TRUSTED_WEB_ORIGINS" in MAIN_SOURCE + + +def test_local_runtime_cors_does_not_use_wildcard_origins() -> None: + assert 'allow_origins=["*"]' not in MAIN_SOURCE + assert "allow_origins=cors_origins" in MAIN_SOURCE + + +def test_local_runtime_remains_loopback_bound() -> None: + assert '"127.0.0.1"' in MAIN_SOURCE + assert "_LOOPBACK_HOSTS" in MAIN_SOURCE + assert "forbidden_host" in MAIN_SOURCE From e894d4f3ca717f8249ae1194a9571ec2dd73e5ea Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:25:57 +0800 Subject: [PATCH 9/9] test(web): type local runtime fetch mock explicitly --- frontend/src/lib/localRuntime.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/localRuntime.test.ts b/frontend/src/lib/localRuntime.test.ts index d7676228..0b1d27f4 100644 --- a/frontend/src/lib/localRuntime.test.ts +++ b/frontend/src/lib/localRuntime.test.ts @@ -17,7 +17,7 @@ describe("localRuntime", () => { }); it("connects the web surface to the existing local OfferU runtime", async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: "ok" }), { + const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify({ status: "ok" }), { status: 200, headers: { "Content-Type": "application/json" }, })); @@ -29,12 +29,12 @@ describe("localRuntime", () => { expect(localStorage.getItem("offeru_web_local_runtime")).toBe("connected"); expect(fetchMock).toHaveBeenCalledTimes(1); - const request = fetchMock.mock.calls[0][0] as Request; + const request = fetchMock.mock.calls[0]?.[0] as unknown as Request; expect(request.url).toBe("http://127.0.0.1:8766/api/health"); }); it("fails closed and stays in showcase mode when no local runtime is reachable", async () => { - vi.stubGlobal("fetch", vi.fn(async () => { + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL) => { throw new TypeError("network unavailable"); }));