Skip to content
7 changes: 4 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/test_web_local_runtime_security.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 35 additions & 3 deletions frontend/src/components/workbench/AgentConnectionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "打开官方指南完成安装,然后回到这里重新检查。" },
Expand Down Expand Up @@ -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)}` : "自动检测 · 沿用已有登录";

Expand Down Expand Up @@ -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<string | null>(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)
Expand Down Expand Up @@ -145,7 +151,33 @@ export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean
<p className="mt-2 max-w-xl text-sm leading-relaxed text-[var(--foreground-muted)]">自动发现本机 Agent,检查接入,把当前工作交给它。同步进展随时可看。</p>
</div>

{SHOWCASE ? <p role="status" className="p-6 text-sm text-[var(--foreground-muted)]">这是展示模式。请在本机 OfferU 中连接 Agent,查看真实同步状态。</p> : <>
{SHOWCASE && !localRuntime.connected ? <div className="p-6 sm:p-7">
<div className="max-w-xl">
<p className="text-sm font-semibold text-[var(--foreground)]">连接本地 OfferU</p>
<p className="mt-2 text-xs leading-6 text-[var(--foreground-muted)]">
直接复用你电脑里的 OfferU Runtime、职业数据和已登录的 OMP / Codex / Claude。无需再配置一套 Web Agent。
</p>
{runtimeError && <p role="alert" className="mt-3 text-xs text-red-700">{runtimeError}</p>}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button
size="sm"
isLoading={connectingRuntime}
className="bg-[var(--foreground)] px-4 text-xs font-semibold text-[var(--surface)]"
onPress={() => {
setRuntimeError("");
setConnectingRuntime(true);
void localRuntime.connect().then((ok) => {
if (!ok) setRuntimeError("没有检测到本机 OfferU。请先启动 OfferU Desktop / Runtime,再重试。");
else state.refresh();
}).finally(() => setConnectingRuntime(false));
}}
>
连接本地 OfferU
</Button>
<span className="text-[11px] text-[var(--foreground-muted)]">只连接 127.0.0.1,不上传本地职业数据。</span>
</div>
</div>
</div> : <>
{(state.error || state.stale) && <div role="alert" className="mx-5 mt-5 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900">
<AlertCircle size={15} className="mt-0.5 shrink-0" />
<span className="flex-1">{state.error || "状态暂未更新,下面保留的是上次结果。"}</span>
Expand Down
16 changes: 9 additions & 7 deletions frontend/src/lib/agentConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
const [integrating, setIntegrating] = useState<string | null>(null);
Expand All @@ -85,7 +87,7 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod
const controller = useRef<AbortController | null>(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 },
);
Expand Down Expand Up @@ -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;
Expand All @@ -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("");
Expand All @@ -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("");
Expand All @@ -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), []);
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,8 +54,8 @@ function buildQuery(params?: Record<string, unknown>) {
}

export async function request<T>(path: string, options?: RequestInit): Promise<T> {
if (SHOWCASE) {
// 展示模式:全部请求由本地 IndexedDB 数据层承载(无需 Python 后端)
if (SHOWCASE && !isLocalRuntimeConnected()) {
// Web 未连接本机 Runtime 时继续使用 Showcase;连接后复用同一套本地 API。
return (await showcaseHandle(path, options)) as T;
}
let res: Response;
Expand Down Expand Up @@ -83,9 +84,8 @@ async function readEventStream<T>(
onEvent?: (event: string, data: any) => void,
signal?: AbortSignal
): Promise<T> {
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}`, {
Expand Down Expand Up @@ -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`, {
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
Expand Down Expand Up @@ -51,7 +52,7 @@ const fetcher = async (url: string) => { if (SHOWCASE) {
* 合成标准 Response,调用方无需感知后端是否存在。
*/
async function showcaseFetch(url: string, init?: RequestInit): Promise<Response> {
if (!SHOWCASE) {
if (!SHOWCASE || isLocalRuntimeConnected()) {
const target = /^https?:\/\//i.test(url)
? url
: `${API_BASE}${url.startsWith("/") ? url : `/${url}`}`;
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/lib/localRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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 (_input: RequestInfo | URL) => 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 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 (_input: RequestInfo | URL) => {
throw new TypeError("network unavailable");
}));

await expect(connectLocalRuntime()).resolves.toBe(false);
expect(isLocalRuntimeConnected()).toBe(false);
expect(localStorage.getItem("offeru_web_local_runtime")).toBeNull();
});
});
Loading
Loading