diff --git a/.agents/skills/offeru/SKILL.md b/.agents/skills/offeru/SKILL.md index 18d0d5df..2d4c0f19 100644 --- a/.agents/skills/offeru/SKILL.md +++ b/.agents/skills/offeru/SKILL.md @@ -5,12 +5,24 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router Work from `backend/`. The live CLI manifest is the source of truth; this generated file contains no business workflow definitions. +## Install in the Agent you are using + +The canonical public Skill is `https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md`. Install that file in the active Agent. Do not use the local runtime URL as the Skill download source. + +When the Agent is outside an OfferU source checkout, the running local OfferU can provide its current-install projection at `http://127.0.0.1:8766/api/agent/runtime/skill`. Read that local projection only to obtain the runtime-specific CLI command; it is not the public Skill distribution source. If the local runtime cannot be reached, report that the connection is unavailable and do not guess a checkout path. Never use raw HTTP for OfferU business data or Operations. + +Install only `offeru/SKILL.md` in a documented user-level Skills directory. Prefer the shared `~/.agents/skills/offeru/SKILL.md` location when the active Agent documents support for it. Otherwise use that Agent's native user-level location; examples include `~/.claude/skills/offeru/SKILL.md`, `~/.pi/agent/skills/offeru/SKILL.md`, `~/.config/opencode/skills/offeru/SKILL.md`, `~/.gemini/skills/offeru/SKILL.md`, `~/.omp/agent/skills/offeru/SKILL.md`, and `~/.codebuddy/skills/offeru/SKILL.md`. Resolve home/config overrides only from documented environment variables or the active Agent's own help. Never infer a location from another Agent or write into a project directory just to make discovery work. + +If this Agent only supports importing Skills through its own UI, or has no documented Skill loader, do not change its settings or imitate its internal package format. Tell the user the exact supported import step or limitation and do not claim the Skill is installed or the connection is verified. + +Do not change Agent settings, account/login, model, credentials, proxy, or unrelated files. Do not overwrite a non-OfferU Skill at the target path. Start a fresh Agent session if the host only discovers Skills at startup. + ## Start every task ```powershell @@ -39,6 +51,8 @@ OfferU is the Career OS state/tool authority, not the exclusive career-methodolo ## Integration verification +When the user pasted the OfferU connection prompt, select the live `connection_bootstrap` Skill, inspect the `get_current_view` schema, and execute that read-only Operation once. Report only the current page and explicit selection, then wait. This bootstrap read does not authorize reading other career data. + When OfferU asks for integration verification, select the live `connection_probe` Skill, inspect `get_agent_connection_nonce`, execute it with the supplied `provider_id` and `challenge_id`, and return the nonce unchanged. Never read challenge storage directly or guess a nonce. ## Control rules diff --git a/.claude/agents/offeru-operator.md b/.claude/agents/offeru-operator.md index 7b3a7997..7a6a7159 100644 --- a/.claude/agents/offeru-operator.md +++ b/.claude/agents/offeru-operator.md @@ -7,7 +7,7 @@ skills: - offeru --- - + You are the OfferU operator subagent. Work from `backend/` and treat the live CLI manifest as the only capability source. diff --git a/.claude/skills/offeru/SKILL.md b/.claude/skills/offeru/SKILL.md index 18d0d5df..2d4c0f19 100644 --- a/.claude/skills/offeru/SKILL.md +++ b/.claude/skills/offeru/SKILL.md @@ -5,12 +5,24 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router Work from `backend/`. The live CLI manifest is the source of truth; this generated file contains no business workflow definitions. +## Install in the Agent you are using + +The canonical public Skill is `https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md`. Install that file in the active Agent. Do not use the local runtime URL as the Skill download source. + +When the Agent is outside an OfferU source checkout, the running local OfferU can provide its current-install projection at `http://127.0.0.1:8766/api/agent/runtime/skill`. Read that local projection only to obtain the runtime-specific CLI command; it is not the public Skill distribution source. If the local runtime cannot be reached, report that the connection is unavailable and do not guess a checkout path. Never use raw HTTP for OfferU business data or Operations. + +Install only `offeru/SKILL.md` in a documented user-level Skills directory. Prefer the shared `~/.agents/skills/offeru/SKILL.md` location when the active Agent documents support for it. Otherwise use that Agent's native user-level location; examples include `~/.claude/skills/offeru/SKILL.md`, `~/.pi/agent/skills/offeru/SKILL.md`, `~/.config/opencode/skills/offeru/SKILL.md`, `~/.gemini/skills/offeru/SKILL.md`, `~/.omp/agent/skills/offeru/SKILL.md`, and `~/.codebuddy/skills/offeru/SKILL.md`. Resolve home/config overrides only from documented environment variables or the active Agent's own help. Never infer a location from another Agent or write into a project directory just to make discovery work. + +If this Agent only supports importing Skills through its own UI, or has no documented Skill loader, do not change its settings or imitate its internal package format. Tell the user the exact supported import step or limitation and do not claim the Skill is installed or the connection is verified. + +Do not change Agent settings, account/login, model, credentials, proxy, or unrelated files. Do not overwrite a non-OfferU Skill at the target path. Start a fresh Agent session if the host only discovers Skills at startup. + ## Start every task ```powershell @@ -39,6 +51,8 @@ OfferU is the Career OS state/tool authority, not the exclusive career-methodolo ## Integration verification +When the user pasted the OfferU connection prompt, select the live `connection_bootstrap` Skill, inspect the `get_current_view` schema, and execute that read-only Operation once. Report only the current page and explicit selection, then wait. This bootstrap read does not authorize reading other career data. + When OfferU asks for integration verification, select the live `connection_probe` Skill, inspect `get_agent_connection_nonce`, execute it with the supplied `provider_id` and `challenge_id`, and return the nonce unchanged. Never read challenge storage directly or guess a nonce. ## Control rules diff --git a/.codex/agents/offeru-operator.toml b/.codex/agents/offeru-operator.toml index 23b475d3..e9f61e0d 100644 --- a/.codex/agents/offeru-operator.toml +++ b/.codex/agents/offeru-operator.toml @@ -1,4 +1,4 @@ -# generated: offeru-skill-registry@2026-07-30.2 sha256=aaed3fc9f2d46ef3564d49f1a20fdd408c03779eea2554b710b7274118406f23 +# generated: offeru-skill-registry@2026-09-28.1 sha256=68ad2024af8dbe14ecadf581d952c512f8ccceb64b2927a7dc544861b9b62c1c name = "offeru-operator" description = "Operate OfferU through its live Skill Registry and atomic CLI control contract." developer_instructions = """ diff --git a/.copilot/SKILL.md b/.copilot/SKILL.md index 18d0d5df..2d4c0f19 100644 --- a/.copilot/SKILL.md +++ b/.copilot/SKILL.md @@ -5,12 +5,24 @@ user-invocable: true argument-hint: "[skill-id | goal | JD/URL]" --- - + # OfferU External-Agent Router Work from `backend/`. The live CLI manifest is the source of truth; this generated file contains no business workflow definitions. +## Install in the Agent you are using + +The canonical public Skill is `https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md`. Install that file in the active Agent. Do not use the local runtime URL as the Skill download source. + +When the Agent is outside an OfferU source checkout, the running local OfferU can provide its current-install projection at `http://127.0.0.1:8766/api/agent/runtime/skill`. Read that local projection only to obtain the runtime-specific CLI command; it is not the public Skill distribution source. If the local runtime cannot be reached, report that the connection is unavailable and do not guess a checkout path. Never use raw HTTP for OfferU business data or Operations. + +Install only `offeru/SKILL.md` in a documented user-level Skills directory. Prefer the shared `~/.agents/skills/offeru/SKILL.md` location when the active Agent documents support for it. Otherwise use that Agent's native user-level location; examples include `~/.claude/skills/offeru/SKILL.md`, `~/.pi/agent/skills/offeru/SKILL.md`, `~/.config/opencode/skills/offeru/SKILL.md`, `~/.gemini/skills/offeru/SKILL.md`, `~/.omp/agent/skills/offeru/SKILL.md`, and `~/.codebuddy/skills/offeru/SKILL.md`. Resolve home/config overrides only from documented environment variables or the active Agent's own help. Never infer a location from another Agent or write into a project directory just to make discovery work. + +If this Agent only supports importing Skills through its own UI, or has no documented Skill loader, do not change its settings or imitate its internal package format. Tell the user the exact supported import step or limitation and do not claim the Skill is installed or the connection is verified. + +Do not change Agent settings, account/login, model, credentials, proxy, or unrelated files. Do not overwrite a non-OfferU Skill at the target path. Start a fresh Agent session if the host only discovers Skills at startup. + ## Start every task ```powershell @@ -39,6 +51,8 @@ OfferU is the Career OS state/tool authority, not the exclusive career-methodolo ## Integration verification +When the user pasted the OfferU connection prompt, select the live `connection_bootstrap` Skill, inspect the `get_current_view` schema, and execute that read-only Operation once. Report only the current page and explicit selection, then wait. This bootstrap read does not authorize reading other career data. + When OfferU asks for integration verification, select the live `connection_probe` Skill, inspect `get_agent_connection_nonce`, execute it with the supplied `provider_id` and `challenge_id`, and return the nonce unchanged. Never read challenge storage directly or guess a nonce. ## Control rules diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65134763..56b5fc0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,40 @@ jobs: python -m pip install --upgrade pip python -m pip install -r backend/requirements.txt pytest + - name: Check generated OfferU Skill projections + run: python backend/scripts/generate_agent_skill_projections.py --check + + - name: Fetch immutable PR Skill source commit + if: github.event_name == 'pull_request' + env: + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ ! "$PR_HEAD_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid pull request head repository" >&2 + exit 1 + fi + if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid pull request head commit" >&2 + exit 1 + fi + git fetch --no-tags --depth=1 "https://github.com/${PR_HEAD_REPOSITORY}.git" "$PR_HEAD_SHA" + git cat-file -e "${PR_HEAD_SHA}^{commit}" + + - name: Verify public OfferU Skill projection + env: + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + repository="$PR_HEAD_REPOSITORY" + commit_sha="$PR_HEAD_SHA" + else + repository="$GITHUB_REPOSITORY" + commit_sha="$GITHUB_SHA" + fi + python backend/scripts/verify_public_skill_projection.py --repository "$repository" --sha "$commit_sha" + - name: Run backend tests working-directory: backend run: python -m pytest tests -q diff --git a/backend/app/main.py b/backend/app/main.py index 5c31d41e..691db241 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -167,10 +167,13 @@ async def _start_work_source_auto_sync() -> None: # 重指 LLM base_url 形成数据外泄链)。因此 env 提供的值必须过白名单:仅允许本机回环、 # tauri 协议与浏览器扩展来源;其余一律拒绝并明示,绝不静默放宽。 _CORS_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "[::1]", "tauri.localhost"} +# One trusted public surface may reuse the exact same local Runtime as Desktop. +# Keep this list exact: never widen loopback access to arbitrary public origins. +_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 +197,16 @@ 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"): +# 前端 dev 端口与正式 Web surface 无条件可用;二者都只连接这个 loopback API。 +# 系统环境变量 CORS_ORIGINS 会覆盖 settings,所以不能依赖用户手工补白名单。 +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( @@ -357,16 +364,29 @@ async def add_security_headers(request, call_next): if raw_host.startswith("[") and "]" in raw_host else raw_host.split(":", 1)[0] ) - if ( + protected_path = ( request.url.path.startswith("/api/") or request.url.path.startswith("/mcp") - ) and host_header not in _LOOPBACK_HOSTS: + ) + if protected_path and host_header not in _LOOPBACK_HOSTS: return _error_response( request, status_code=403, detail="请求被拒绝:仅允许本机回环来源", kind="forbidden_host", ) + request_origin = (request.headers.get("origin") or "").strip() + if ( + protected_path + and request_origin.startswith(("http://", "https://", "tauri://")) + and not _is_allowed_cors_origin(request_origin) + ): + return _error_response( + request, + status_code=403, + detail="请求被拒绝:来源未授权连接本地 OfferU", + kind="forbidden_origin", + ) try: response = await call_next(request) except Exception as exc: diff --git a/backend/app/routes/main_agent.py b/backend/app/routes/main_agent.py index 31df4c6e..cce00d2d 100644 --- a/backend/app/routes/main_agent.py +++ b/backend/app/routes/main_agent.py @@ -5,6 +5,7 @@ from typing import Any from fastapi import APIRouter, Header, HTTPException +from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse @@ -437,6 +438,22 @@ async def agent_connections() -> dict[str, Any]: return await _ui_operation_outputs("get_agent_connections", {}) +@runtime_router.get("/runtime/skill", include_in_schema=False) +async def download_agent_skill() -> PlainTextResponse: + """Serve a local CLI projection; public Skill distribution is on GitHub.""" + + from app.services.agent_integration import installed_skill_content + + return PlainTextResponse( + installed_skill_content(), + media_type="text/markdown", + headers={ + "Content-Disposition": 'attachment; filename="offeru-SKILL.md"', + "Cache-Control": "no-store", + }, + ) + + @runtime_router.post("/runtime/connections/{provider_id}/probe") async def probe_agent_connection(provider_id: str) -> dict[str, Any]: return await _ui_operation_outputs("probe_agent_connection", {"provider_id": provider_id}) diff --git a/backend/app/services/agent_bridge/protocol.py b/backend/app/services/agent_bridge/protocol.py index ea7b4460..a24c7495 100644 --- a/backend/app/services/agent_bridge/protocol.py +++ b/backend/app/services/agent_bridge/protocol.py @@ -194,10 +194,7 @@ class PairingStatusPayload(_StrictPayload): class RunAttachPayload(_StrictPayload): harness: HarnessIdentity adapter: AdapterIdentity - harness_session_id: Identifier | None = Field( - default=None, - alias="harnessSessionId", - ) + harness_session_id: Identifier = Field(alias="harnessSessionId") bootstrap_token: NonEmptyString | None = Field( default=None, alias="bootstrapToken", @@ -208,7 +205,7 @@ class RunAttachPayload(_StrictPayload): class RunLeaseRenewPayload(_StrictPayload): - lease_id: Identifier | None = Field(default=None, alias="leaseId") + lease_id: Identifier = Field(alias="leaseId") class ContextSnapshotPayload(_StrictPayload): diff --git a/backend/app/services/agent_bridge/run_coordinator.py b/backend/app/services/agent_bridge/run_coordinator.py index f1e904a0..5cadc480 100644 --- a/backend/app/services/agent_bridge/run_coordinator.py +++ b/backend/app/services/agent_bridge/run_coordinator.py @@ -12,14 +12,14 @@ from datetime import datetime, timedelta, timezone from typing import Any -from sqlalchemy import select +from sqlalchemy import select, update from app.database import async_session -from app.models.models import AgentRunRecord, BridgePairing +from app.models.models import AgentRunEvent, AgentRunRecord, BridgePairing from app.services.agent_run_state import ( - ACTIVE_STATUSES, TERMINAL_STATUSES, load_agent_run, + safe_result_preview, ) LEASE_TTL_SECONDS = 120 @@ -92,56 +92,134 @@ async def attach( harness: dict[str, Any], adapter: dict[str, Any], harness_session_id: str, + lease_id: str | None = None, last_event_seq: int = 0, ) -> dict[str, Any]: - run = await load_agent_run(run_id) - if run is None: - raise LookupError(f"Agent Run {run_id} does not exist") - if run.get("status") in TERMINAL_STATUSES: - raise ValueError(f"Agent Run {run_id} is terminal ({run.get('status')})") - lease_id = f"lease_{secrets.token_hex(12)}" - expires = _now() + timedelta(seconds=LEASE_TTL_SECONDS) + session_id = str(harness_session_id or "").strip() + if not session_id: + raise ValueError("Agent Bridge session identity is required") + now = _now() + expires = now + timedelta(seconds=LEASE_TTL_SECONDS) + requested_harness = str(harness.get("name") or "") + requested_adapter = str(adapter.get("name") or "") + requested_harness_version = str(harness.get("version") or "") + requested_adapter_version = str(adapter.get("version") or "") + reconciled = False + context_version = 0 + event_sequence = 0 async with async_session() as db: row = ( await db.execute( select(AgentRunRecord).where(AgentRunRecord.run_id == run_id) ) - ).scalar_one() + ).scalar_one_or_none() + if row is None: + raise LookupError(f"Agent Run {run_id} does not exist") + if row.status in TERMINAL_STATUSES: + raise ValueError(f"Agent Run {run_id} is terminal ({row.status})") + stored_event_sequence = int(row.event_sequence or 0) + if int(last_event_seq) > stored_event_sequence: + raise ValueError("lastEventSeq exceeds the persisted Agent Run event sequence") current_lease = str(row.lease_id or "") current_expires = row.lease_expires_at - if ( - current_lease - and current_lease != lease_id - and (current_expires is None or current_expires > _now()) - ): + lease_is_live = bool(current_lease) and ( + current_expires is None or current_expires > now + ) + same_session = ( + row.harness_name == requested_harness + and row.harness_version == requested_harness_version + and row.adapter_name == requested_adapter + and row.adapter_version == requested_adapter_version + and row.harness_session_id == session_id + ) + if lease_is_live and not same_session: + raise LeaseLostError(run_id) + if row.harness_session_id and not same_session: + # A pairing token authorizes attaching to this run; it does not + # silently authorize replacing the persisted Harness identity. + raise LeaseLostError(run_id) + if current_lease and (not lease_id or lease_id != current_lease): + raise LeaseLostError(run_id) + + if lease_is_live: + # A retry/reconnect from the exact active session reconciles to + # its existing lease. Do not mint another identity or append a + # duplicate run.attached event. leaseId is a bearer capability, + # not a value that can be reconstructed from the session tuple. + lease_id = current_lease + reconciled = True + values = {"lease_expires_at": expires} + else: + # The AgentRunRecord.run_id remains the canonical identity. + # Only the already-bound external session can recover an + # expired lease; changing providers/sessions needs a new Run. + lease_id = f"lease_{secrets.token_hex(12)}" + values = { + "harness_name": requested_harness, + "harness_version": requested_harness_version, + "adapter_name": requested_adapter, + "adapter_version": requested_adapter_version, + "harness_session_id": session_id, + "lease_id": lease_id, + "lease_expires_at": expires, + } + + condition = [ + AgentRunRecord.run_id == run_id, + AgentRunRecord.lease_id == current_lease, + AgentRunRecord.status.notin_(TERMINAL_STATUSES), + ] + condition.append( + AgentRunRecord.lease_expires_at.is_(None) + if current_expires is None + else AgentRunRecord.lease_expires_at == current_expires + ) + if not reconciled: + values["event_sequence"] = AgentRunRecord.event_sequence + 1 + result = await db.execute( + update(AgentRunRecord) + .where(*condition) + .values(**values) + .returning(AgentRunRecord.event_sequence) + .execution_options(synchronize_session=False) + ) + new_sequence = result.scalar_one_or_none() + if new_sequence is None: + await db.rollback() + latest = await load_agent_run(run_id) + if latest is not None and latest.get("status") in TERMINAL_STATUSES: + raise ValueError(f"Agent Run {run_id} is terminal ({latest['status']})") raise LeaseLostError(run_id) - row.harness_name = str(harness.get("name") or "") - row.harness_version = str(harness.get("version") or "") - row.adapter_name = str(adapter.get("name") or "") - row.adapter_version = str(adapter.get("version") or "") - row.harness_session_id = str(harness_session_id or "") - row.lease_id = lease_id - row.lease_expires_at = expires + event_sequence = int(new_sequence) + context_version = int(row.context_version or 0) + if not reconciled: + event_type = ( + "run.resumed" + if int(last_event_seq) > 0 or stored_event_sequence > 0 + else "run.attached" + ) + payload = { + "harness": harness, + "adapter": adapter, + "harnessSessionId": session_id, + "leaseId": lease_id, + "lastEventSeq": int(last_event_seq), + } + db.add( + AgentRunEvent( + event_id=f"evt_{secrets.token_hex(16)}", + run_id=run_id, + sequence=event_sequence, + event_type=event_type, + payload_json=safe_result_preview(payload), + ) + ) await db.commit() - event_type = "run.resumed" if last_event_seq > 0 else "run.attached" - from app.services.agent_run_state import append_agent_run_event - - await append_agent_run_event( - run_id, - event_type=event_type, - payload={ - "harness": harness, - "adapter": adapter, - "harnessSessionId": harness_session_id, - "leaseId": lease_id, - "lastEventSeq": int(last_event_seq), - }, - ) return { "leaseId": lease_id, "leaseExpiresAt": expires.isoformat(), - "contextVersion": int(run.get("context_version") or 0), - "eventSequence": int(run.get("event_sequence") or 0), + "contextVersion": context_version, + "eventSequence": event_sequence, } async def assert_lease(self, *, run_id: str, lease_id: str) -> None: @@ -162,24 +240,50 @@ async def assert_lease(self, *, run_id: str, lease_id: str) -> None: async def renew_lease( self, *, run_id: str, lease_id: str | None ) -> dict[str, Any]: - current = await load_agent_run(run_id) - if current is None: - raise LookupError(f"Agent Run {run_id} does not exist") - stored = str(current.get("lease_id") or "") - if stored and lease_id and stored != lease_id: + capability = str(lease_id or "").strip() + if not capability: raise LeaseLostError(run_id) - new_lease = lease_id or stored or f"lease_{secrets.token_hex(12)}" - expires = _now() + timedelta(seconds=LEASE_TTL_SECONDS) + now = _now() + expires = now + timedelta(seconds=LEASE_TTL_SECONDS) async with async_session() as db: row = ( await db.execute( select(AgentRunRecord).where(AgentRunRecord.run_id == run_id) ) - ).scalar_one() - row.lease_id = new_lease - row.lease_expires_at = expires + ).scalar_one_or_none() + if row is None: + raise LookupError(f"Agent Run {run_id} does not exist") + if row.status in TERMINAL_STATUSES: + raise ValueError(f"Agent Run {run_id} is terminal ({row.status})") + stored = str(row.lease_id or "") + current_expires = row.lease_expires_at + if ( + not stored + or stored != capability + or (current_expires is not None and current_expires <= now) + ): + raise LeaseLostError(run_id) + condition = [ + AgentRunRecord.run_id == run_id, + AgentRunRecord.lease_id == capability, + AgentRunRecord.status.notin_(TERMINAL_STATUSES), + ] + condition.append( + AgentRunRecord.lease_expires_at.is_(None) + if current_expires is None + else AgentRunRecord.lease_expires_at == current_expires + ) + result = await db.execute( + update(AgentRunRecord) + .where(*condition) + .values(lease_expires_at=expires) + .returning(AgentRunRecord.lease_id) + .execution_options(synchronize_session=False) + ) + if result.scalar_one_or_none() is None: + raise LeaseLostError(run_id) await db.commit() - return {"leaseId": new_lease, "leaseExpiresAt": expires.isoformat()} + return {"leaseId": capability, "leaseExpiresAt": expires.isoformat()} async def release_lease(self, *, run_id: str, lease_id: str) -> None: async with async_session() as db: diff --git a/backend/app/services/agent_bridge/server.py b/backend/app/services/agent_bridge/server.py index 66b725af..a18f2925 100644 --- a/backend/app/services/agent_bridge/server.py +++ b/backend/app/services/agent_bridge/server.py @@ -217,6 +217,7 @@ async def _attach(self, request_id: str, payload: dict[str, Any]) -> dict[str, A harness=harness, adapter=adapter, harness_session_id=str(payload.get("harnessSessionId") or ""), + lease_id=str(payload.get("leaseId") or "") or None, last_event_seq=int(payload.get("lastEventSeq") or 0), ) except LookupError as exc: @@ -225,6 +226,13 @@ async def _attach(self, request_id: str, payload: dict[str, Any]) -> dict[str, A str(exc), request_id=request_id, ) from exc + except LeaseLostError as exc: + raise BridgeProtocolError( + "lease_lost", + "The single-writer lease is held by another connection or expired", + retryable=True, + request_id=request_id, + ) from exc except ValueError as exc: raise BridgeProtocolError( "run_not_found", diff --git a/backend/app/services/agent_connection.py b/backend/app/services/agent_connection.py index d3a4d3bf..6ef92cf4 100644 --- a/backend/app/services/agent_connection.py +++ b/backend/app/services/agent_connection.py @@ -184,27 +184,29 @@ async def get_agent_connections() -> dict[str, Any]: runtime.list_local_executors(), list_provider_health(), ) by_id = {item["provider_id"]: item for item in health["providers"]} - skill = Path(__file__).resolve().parents[3] / ".agents" / "skills" / "offeru" / "SKILL.md" - skill_instruction = ( - f"请先阅读本机文件 {skill},按其中的 OfferU 接入约定发现实时能力。" - if skill.is_file() - else "请先通过 OfferU Agent Bridge 的实时 manifest 发现能力,不要猜测固定命令。" - ) - connect_prompt = ( - f"{skill_instruction}" - "先检查连接,再查看 get_current_view 的 schema,通过该只读 Operation 读取我在 OfferU 中同步的当前页面和选中对象。" - "请告诉我你实际读到了什么,然后等待我的任务。不要修改数据、配置、凭据或代理;" - "后续业务操作继续使用同一 Operation Registry,写入先在 OfferU 中等待我确认。" - ) return { "items": [_view(item, by_id.get(item["id"], {})) for item in detected["items"]], "checked_at": datetime.now(timezone.utc).isoformat(), - "connect_prompt": connect_prompt, + "connect_prompt": build_connect_prompt(), "beginner_provider_ids": beginner_host_ids(), "recommended_provider_id": recommended_host_id(), } +def build_connect_prompt() -> str: + """Portable manual bootstrap text; the public Skill owns host setup guidance.""" + + return ( + "请接入这台电脑上正在运行的 OfferU。从 https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md " + "下载官方 OfferU Skill,并按 Skill 中与你当前 Agent 匹配的说明安装;" + "只写入这个 Skill 文件,不改 Agent 的其他设置、账号、模型、凭据或代理。" + "随后按 Skill 检查本机 OfferU 是否可用;选择 connection_bootstrap Skill,查看 get_current_view 的 schema,并只通过对应的只读 Operation " + "读取 OfferU 当前同步页面和显式选中对象。把实际读取结果和连接状态告诉我,然后停止等待我的任务。" + "不要读取其他职业数据。Skill 下载 URL 只用于获取静态指引;之后所有业务操作必须走同一 Operation Registry," + "所有写操作都留在 OfferU 等我确认,不得自行批准、提交、发送或联系第三方。" + ) + + async def probe_agent_connection(provider_id: str) -> dict[str, Any]: if provider_id not in runtime.RUNTIME_DEFINITIONS: raise ValueError("未知的本地 Agent") diff --git a/backend/app/services/agent_integration.py b/backend/app/services/agent_integration.py index bea57749..fc2f9833 100644 --- a/backend/app/services/agent_integration.py +++ b/backend/app/services/agent_integration.py @@ -59,6 +59,12 @@ def _installed_content() -> str: return source.replace("python -m app.cli", f"{_command_prefix()} -m app.cli") +def installed_skill_content() -> str: + """Return the public Skill with only this OfferU install's CLI command projected.""" + + return _installed_content() + + def _skill_metadata(content: str) -> tuple[str, str]: match = _MARKER.search(content) return (match.group(1), match.group(2)) if match else ("", "") diff --git a/backend/app/services/agent_skill_projections.py b/backend/app/services/agent_skill_projections.py index 4f7f3d62..592771f4 100644 --- a/backend/app/services/agent_skill_projections.py +++ b/backend/app/services/agent_skill_projections.py @@ -69,6 +69,18 @@ def _markdown_projection(host: str, snapshot: dict[str, Any], host_id: str = "") Work from `backend/`. The live CLI manifest is the source of truth; this generated file contains no business workflow definitions. +## Install in the Agent you are using + +The canonical public Skill is `https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md`. Install that file in the active Agent. Do not use the local runtime URL as the Skill download source. + +When the Agent is outside an OfferU source checkout, the running local OfferU can provide its current-install projection at `http://127.0.0.1:8766/api/agent/runtime/skill`. Read that local projection only to obtain the runtime-specific CLI command; it is not the public Skill distribution source. If the local runtime cannot be reached, report that the connection is unavailable and do not guess a checkout path. Never use raw HTTP for OfferU business data or Operations. + +Install only `offeru/SKILL.md` in a documented user-level Skills directory. Prefer the shared `~/.agents/skills/offeru/SKILL.md` location when the active Agent documents support for it. Otherwise use that Agent's native user-level location; examples include `~/.claude/skills/offeru/SKILL.md`, `~/.pi/agent/skills/offeru/SKILL.md`, `~/.config/opencode/skills/offeru/SKILL.md`, `~/.gemini/skills/offeru/SKILL.md`, `~/.omp/agent/skills/offeru/SKILL.md`, and `~/.codebuddy/skills/offeru/SKILL.md`. Resolve home/config overrides only from documented environment variables or the active Agent's own help. Never infer a location from another Agent or write into a project directory just to make discovery work. + +If this Agent only supports importing Skills through its own UI, or has no documented Skill loader, do not change its settings or imitate its internal package format. Tell the user the exact supported import step or limitation and do not claim the Skill is installed or the connection is verified. + +Do not change Agent settings, account/login, model, credentials, proxy, or unrelated files. Do not overwrite a non-OfferU Skill at the target path. Start a fresh Agent session if the host only discovers Skills at startup. + ## Start every task ```powershell @@ -97,6 +109,8 @@ def _markdown_projection(host: str, snapshot: dict[str, Any], host_id: str = "") ## Integration verification +When the user pasted the OfferU connection prompt, select the live `connection_bootstrap` Skill, inspect the `get_current_view` schema, and execute that read-only Operation once. Report only the current page and explicit selection, then wait. This bootstrap read does not authorize reading other career data. + When OfferU asks for integration verification, select the live `connection_probe` Skill, inspect `get_agent_connection_nonce`, execute it with the supplied `provider_id` and `challenge_id`, and return the nonce unchanged. Never read challenge storage directly or guess a nonce. ## Control rules diff --git a/backend/app/services/agent_skill_registry.py b/backend/app/services/agent_skill_registry.py index a068ede9..9de5ecb6 100644 --- a/backend/app/services/agent_skill_registry.py +++ b/backend/app/services/agent_skill_registry.py @@ -6,7 +6,7 @@ from typing import Any -SKILL_REGISTRY_VERSION = "2026-07-30.2" +SKILL_REGISTRY_VERSION = "2026-09-28.1" CONFIRMATION_POLICY = "operation_registry" @@ -74,6 +74,7 @@ def _skill( _SKILLS = ( _skill("discovery", "技能中心", "system", "native", "解释 OfferU 能做什么,并选择下一条最短路径。", "general", ("get_profile",), featured=True, order=10, aliases=("help", "menu")), + _skill("connection_bootstrap", "连接 OfferU", "system", "native", "首次连接时只读当前 OfferU 页面,不读取职业档案或修改业务状态。", "skill_assistant", ("get_current_view",), featured=False, order=14, aliases=("current_view", "connect_offeru")), _skill("connection_probe", "连接验证", "system", "native", "仅用于 OfferU 发起的短时本机 Agent 集成验证;读取一次非敏感 nonce,不读取职业档案。", "skill_assistant", ("get_agent_connection_nonce",), featured=False, order=15, aliases=("verify_connection",)), _skill("pre_application_decision", "投前决策闭环", "pipeline", "native", "围绕一个真实岗位检查职业证据和调研,生成可复核投前决策;只有使用者确认投或有条件投后才生成简历提案。", "pre_application_workflow", ("get_profile", "list_jobs", "get_job", "get_pre_application_state", "prepare_pre_application_decision", "review_pre_application_decision", "start_job_research", "resume_job_research", "cancel_job_research", "review_job_research", "prepare_resume_optimization"), featured=True, order=20, aliases=("pre_application", "投前决策", "投前")), _skill("evaluate_job", "岗位评估", "jobs", "native", "基于档案与真实岗位内容做证据化匹配。", "skill_assistant", ("get_profile", "list_jobs", "get_job", "list_career_artifacts", "save_career_artifact", "triage_job"), featured=True, order=30, aliases=("job", "岗位匹配")), diff --git a/backend/scripts/verify_public_skill_projection.py b/backend/scripts/verify_public_skill_projection.py new file mode 100644 index 00000000..15088b71 --- /dev/null +++ b/backend/scripts/verify_public_skill_projection.py @@ -0,0 +1,211 @@ +"""Verify the public immutable GitHub Skill matches this checkout's projection.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +from pathlib import Path +import re +import socket +import ssl +import subprocess +import sys +import time +from typing import Callable +from http.client import HTTPException +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL_RELATIVE_PATH = ".agents/skills/offeru/SKILL.md" +MAX_RESPONSE_BYTES = 1_000_000 +FETCH_TIMEOUT_SECONDS = 10 +FETCH_ATTEMPTS = 4 +RETRY_DELAYS_SECONDS = (0.5, 1.0, 2.0) +RETRYABLE_HTTP_STATUS = {408, 425, 429, 500, 502, 503, 504} +MARKER = re.compile( + rb"^$", + re.MULTILINE, +) + + +class ProjectionCheckError(ValueError): + """The published projection is missing, invalid, or differs from source.""" + + +@dataclass(frozen=True) +class ProjectionMarker: + version: str + registry_hash: str + + +def public_skill_url(repository: str, commit_sha: str) -> str: + """Build the raw URL only from a validated repository and immutable SHA.""" + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ProjectionCheckError("repository must be an owner/repository pair") + if any(part in {".", ".."} for part in repository.split("/")): + raise ProjectionCheckError("repository contains an invalid path component") + if not re.fullmatch(r"[0-9a-f]{40}", commit_sha): + raise ProjectionCheckError("sha must be a full immutable 40-character commit SHA") + return ( + f"https://raw.githubusercontent.com/{repository}/{commit_sha}/" + f"{SKILL_RELATIVE_PATH}" + ) + + +def parse_marker(content: bytes, *, source: str) -> ProjectionMarker: + matches = list(MARKER.finditer(content)) + if len(matches) != 1: + raise ProjectionCheckError( + f"{source} must contain exactly one valid generated Skill version marker" + ) + return ProjectionMarker( + version=matches[0].group(1).decode("ascii"), + registry_hash=matches[0].group(2).decode("ascii"), + ) + + +def read_canonical_projection( + project_root: Path = PROJECT_ROOT, + *, + commit_sha: str = "HEAD", +) -> bytes: + """Read an immutable committed blob so checkout newline conversion cannot mask parity.""" + if commit_sha != "HEAD" and not re.fullmatch(r"[0-9a-f]{40}", commit_sha): + raise ProjectionCheckError("canonical Skill source must be HEAD or a full immutable commit SHA") + try: + result = subprocess.run( + ["git", "show", f"{commit_sha}:{SKILL_RELATIVE_PATH}"], + cwd=project_root, + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise ProjectionCheckError("could not read the canonical Skill blob from this checkout") from exc + return result.stdout + + +def _response_bytes(response: object, expected_url: str) -> bytes: + geturl = getattr(response, "geturl", None) + final_url = geturl() if callable(geturl) else expected_url + if final_url != expected_url: + raise ProjectionCheckError("raw GitHub redirected away from the immutable Skill URL") + status = getattr(response, "status", 200) + if status != 200: + raise ProjectionCheckError(f"raw GitHub returned HTTP {status}") + payload = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined] + if len(payload) > MAX_RESPONSE_BYTES: + raise ProjectionCheckError("published Skill exceeds the allowed response size") + return payload + + +def fetch_public_skill( + url: str, + *, + open_url: Callable[..., object] | None = None, + sleep: Callable[[float], None] | None = None, +) -> bytes: + """Fetch the public immutable raw file, retrying transient network errors boundedly.""" + parsed = urlparse(url) + path_parts = parsed.path.lstrip("/").split("/") + if ( + parsed.scheme != "https" + or parsed.netloc != "raw.githubusercontent.com" + or parsed.query + or parsed.fragment + or len(path_parts) != 7 + or path_parts[3:] != [".agents", "skills", "offeru", "SKILL.md"] + or not re.fullmatch(r"[0-9a-f]{40}", path_parts[2]) + ): + raise ProjectionCheckError( + "Skill fetch URL must be an immutable raw.githubusercontent.com commit URL" + ) + opener = open_url if open_url is not None else urlopen + pause = sleep if sleep is not None else time.sleep + request = Request(url, headers={"Accept": "text/plain", "User-Agent": "OfferU-Skill-Projection-Check"}) + delays = RETRY_DELAYS_SECONDS + last_error = "unknown network error" + + for attempt in range(FETCH_ATTEMPTS): + try: + with opener(request, timeout=FETCH_TIMEOUT_SECONDS) as response: # type: ignore[attr-defined] + return _response_bytes(response, url) + except HTTPError as exc: + exc.close() + if exc.code not in RETRYABLE_HTTP_STATUS: + raise ProjectionCheckError(f"raw GitHub returned HTTP {exc.code}") from exc + last_error = f"HTTP {exc.code}" + except (URLError, TimeoutError, ConnectionError, socket.timeout) as exc: + reason = getattr(exc, "reason", None) + if isinstance(reason, ssl.SSLError) or isinstance(exc, ssl.SSLError): + raise ProjectionCheckError("TLS validation failed while fetching public Skill") from exc + last_error = str(reason or exc)[:200] + except ssl.SSLError as exc: + raise ProjectionCheckError("TLS validation failed while fetching public Skill") from exc + except HTTPException as exc: + last_error = str(exc)[:200] + + if attempt + 1 < FETCH_ATTEMPTS: + pause(delays[min(attempt, len(delays) - 1)]) + + raise ProjectionCheckError( + f"public Skill fetch failed after {FETCH_ATTEMPTS} attempts: {last_error}" + ) + + +def verify_public_skill_projection( + repository: str, + commit_sha: str, + *, + project_root: Path = PROJECT_ROOT, + fetch: Callable[[str], bytes] = fetch_public_skill, + canonical_content: bytes | None = None, +) -> str: + """Require valid equal markers and exact bytes for the canonical projection.""" + local = ( + canonical_content + if canonical_content is not None + else read_canonical_projection(project_root, commit_sha=commit_sha) + ) + local_marker = parse_marker(local, source="canonical projection") + url = public_skill_url(repository, commit_sha) + remote = fetch(url) + remote_marker = parse_marker(remote, source="public projection") + if remote_marker != local_marker: + raise ProjectionCheckError( + "public Skill version marker differs from canonical projection " + f"(public={remote_marker.version}/{remote_marker.registry_hash}, " + f"local={local_marker.version}/{local_marker.registry_hash})" + ) + if remote != local: + raise ProjectionCheckError( + "public Skill bytes differ from the canonical projection " + f"(public_sha256={hashlib.sha256(remote).hexdigest()}, " + f"local_sha256={hashlib.sha256(local).hexdigest()})" + ) + return url + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Verify the immutable public GitHub Skill equals the canonical projection." + ) + parser.add_argument("--repository", required=True, help="GitHub owner/repository") + parser.add_argument("--sha", required=True, help="Full immutable commit SHA") + args = parser.parse_args(argv) + + try: + url = verify_public_skill_projection(args.repository, args.sha) + except ProjectionCheckError as exc: + print(f"Public OfferU Skill projection check failed: {exc}", file=sys.stderr) + return 1 + print(f"Public OfferU Skill projection matches canonical bytes: {url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/agent_bridge/test_protocol_models.py b/backend/tests/agent_bridge/test_protocol_models.py index 865978bd..8164929b 100644 --- a/backend/tests/agent_bridge/test_protocol_models.py +++ b/backend/tests/agent_bridge/test_protocol_models.py @@ -149,6 +149,39 @@ def test_run_id_is_forbidden_before_pairing_and_required_after(self) -> None: } self.assert_schema_invalid(operation) + def test_lease_renew_requires_the_capability_token(self) -> None: + value = { + "v": 1, + "id": "req_renew_1", + "type": "run.lease.renew", + "runId": "run_01J", + "payload": {}, + } + + error = self.assert_schema_invalid(value) + + self.assertTrue( + any(issue["path"][-1] == "leaseId" for issue in error.details["issues"]) + ) + + def test_run_attach_requires_a_persistable_harness_session_identity(self) -> None: + value = { + "v": 1, + "id": "req_attach_1", + "type": "run.attach", + "runId": "run_01J", + "payload": { + "harness": {"name": "harness", "version": "1"}, + "adapter": {"name": "adapter", "version": "1"}, + }, + } + + error = self.assert_schema_invalid(value) + + self.assertTrue( + any(issue["path"][-1] == "harnessSessionId" for issue in error.details["issues"]) + ) + def test_operation_invoke_matches_the_v1_example(self) -> None: value = { "v": 1, diff --git a/backend/tests/agent_bridge/test_slice1_server.py b/backend/tests/agent_bridge/test_slice1_server.py index a77babe7..9169a54b 100644 --- a/backend/tests/agent_bridge/test_slice1_server.py +++ b/backend/tests/agent_bridge/test_slice1_server.py @@ -6,6 +6,7 @@ import sys import unittest from pathlib import Path +from datetime import datetime, timedelta, timezone from unittest.mock import patch BACKEND_DIR = Path(__file__).resolve().parents[2] @@ -13,7 +14,7 @@ sys.path.insert(0, str(BACKEND_DIR)) from app.database import async_session, init_db # noqa: E402 -from app.models.models import AgentRunRecord, BridgePairing, JobSearchTask, Profile # noqa: E402 +from app.models.models import AgentRunEvent, AgentRunRecord, BridgePairing, JobSearchTask, Profile # noqa: E402 from app.services.agent_bridge.errors import BridgeProtocolError # noqa: E402 from app.services.agent_bridge.event_stream import follow_events # noqa: E402 from app.services.agent_bridge.operation_gateway import ( # noqa: E402 @@ -29,6 +30,7 @@ from app.services.agent_bridge.server import BridgeSession # noqa: E402 from app.services.agent_run_state import create_agent_run, load_agent_run # noqa: E402 from sqlalchemy import select # noqa: E402 +from sqlalchemy.exc import IntegrityError # noqa: E402 import secrets # noqa: E402 @@ -110,6 +112,261 @@ async def flow(): first = self._run(flow()) self.assertTrue(str(first["leaseId"]).startswith("lease_")) + def test_attach_reconciles_the_same_session_without_a_duplicate_event(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + identity = { + "harness": {"name": "h1", "version": "1"}, + "adapter": {"name": "a1", "version": "1"}, + "harness_session_id": "sess-a", + } + first = await coordinator.attach(run_id=run_id, **identity) + before = await load_agent_run(run_id) + resumed = await coordinator.attach( + run_id=run_id, + **identity, + lease_id=str(first["leaseId"]), + last_event_seq=2, + ) + after = await load_agent_run(run_id) + return first, before, resumed, after + + first, before, resumed, after = self._run(flow()) + self.assertEqual(resumed["leaseId"], first["leaseId"]) + self.assertEqual(after["event_sequence"], before["event_sequence"]) + self.assertEqual(after["harness_session_id"], "sess-a") + + def test_attach_replaces_an_expired_lease_on_the_same_persisted_run(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + identity = { + "harness": {"name": "h1", "version": "1"}, + "adapter": {"name": "a1", "version": "1"}, + "harness_session_id": "sess-a", + } + first = await coordinator.attach(run_id=run_id, **identity) + async with async_session() as db: + row = ( + await db.execute( + select(AgentRunRecord).where(AgentRunRecord.run_id == run_id) + ) + ).scalar_one() + row.lease_expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=1) + await db.commit() + resumed = await coordinator.attach( + run_id=run_id, + **identity, + lease_id=str(first["leaseId"]), + last_event_seq=2, + ) + result = await load_agent_run(run_id) + return first, resumed, result + + first, resumed, result = self._run(flow()) + self.assertNotEqual(resumed["leaseId"], first["leaseId"]) + self.assertEqual(result["harness_session_id"], "sess-a") + self.assertGreater(result["event_sequence"], 1) + + def test_active_attach_requires_the_existing_lease_capability(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + identity = { + "harness": {"name": "h1", "version": "1"}, + "adapter": {"name": "a1", "version": "1"}, + "harness_session_id": "sess-a", + } + first = await coordinator.attach(run_id=run_id, **identity) + for capability in (None, "lease_wrong"): + with self.assertRaises(LeaseLostError): + await coordinator.attach( + run_id=run_id, **identity, lease_id=capability + ) + retry = await coordinator.attach( + run_id=run_id, + **identity, + lease_id=str(first["leaseId"]), + last_event_seq=2, + ) + return first, retry + + first, retry = self._run(flow()) + self.assertEqual(first["leaseId"], retry["leaseId"]) + + def test_lease_renew_requires_and_preserves_the_capability(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + attached = await coordinator.attach( + run_id=run_id, + harness={"name": "h1", "version": "1"}, + adapter={"name": "a1", "version": "1"}, + harness_session_id="sess-renew", + ) + for capability in (None, "lease_wrong"): + with self.assertRaises(LeaseLostError): + await coordinator.renew_lease(run_id=run_id, lease_id=capability) + renewed = await coordinator.renew_lease( + run_id=run_id, lease_id=str(attached["leaseId"]) + ) + return attached, renewed + + attached, renewed = self._run(flow()) + self.assertEqual(renewed["leaseId"], attached["leaseId"]) + + def test_attach_rejects_a_client_event_cursor_ahead_of_the_run(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + attached = await coordinator.attach( + run_id=run_id, + harness={"name": "h1", "version": "1"}, + adapter={"name": "a1", "version": "1"}, + harness_session_id="sess-cursor", + ) + with self.assertRaisesRegex(ValueError, "lastEventSeq"): + await coordinator.attach( + run_id=run_id, + harness={"name": "h1", "version": "1"}, + adapter={"name": "a1", "version": "1"}, + harness_session_id="sess-cursor", + lease_id=str(attached["leaseId"]), + last_event_seq=10_000, + ) + + self._run(flow()) + + def test_attach_event_failure_rolls_back_the_lease_for_a_clean_retry(self) -> None: + async def flow(): + run_id = await self._make_run() + async with async_session() as db: + existing_event = ( + await db.execute( + select(AgentRunEvent) + .where(AgentRunEvent.run_id == run_id) + .limit(1) + ) + ).scalar_one() + identity = { + "run_id": run_id, + "harness": {"name": "h1", "version": "1"}, + "adapter": {"name": "a1", "version": "1"}, + "harness_session_id": "sess-a", + } + coordinator = RunCoordinator() + duplicate_event_token = existing_event.event_id.removeprefix("evt_") + with patch( + "app.services.agent_bridge.run_coordinator.secrets.token_hex", + side_effect=["lease-id-token", duplicate_event_token], + ): + with self.assertRaises(IntegrityError): + await coordinator.attach(**identity) + failed = await load_agent_run(run_id) + self.assertEqual(failed["lease_id"], "") + self.assertEqual(failed["event_sequence"], 2) + retried = await coordinator.attach(**identity) + succeeded = await load_agent_run(run_id) + return retried, succeeded + + retried, succeeded = self._run(flow()) + self.assertTrue(retried["leaseId"]) + self.assertEqual(succeeded["lease_id"], retried["leaseId"]) + self.assertEqual(succeeded["event_sequence"], 3) + + def test_expired_lease_cannot_be_rebound_to_another_harness_session(self) -> None: + async def flow(): + run_id = await self._make_run() + coordinator = RunCoordinator() + first = await coordinator.attach( + run_id=run_id, + harness={"name": "h1", "version": "1"}, + adapter={"name": "a1", "version": "1"}, + harness_session_id="sess-a", + ) + async with async_session() as db: + row = ( + await db.execute( + select(AgentRunRecord).where(AgentRunRecord.run_id == run_id) + ) + ).scalar_one() + row.lease_expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=1) + await db.commit() + with self.assertRaises(LeaseLostError): + await coordinator.attach( + run_id=run_id, + harness={"name": "h2", "version": "2"}, + adapter={"name": "a2", "version": "2"}, + harness_session_id="sess-b", + lease_id=str(first["leaseId"]), + last_event_seq=2, + ) + stored = await load_agent_run(run_id) + return stored + + stored = self._run(flow()) + self.assertEqual(stored["harness_name"], "h1") + self.assertEqual(stored["harness_session_id"], "sess-a") + + def test_bridge_restart_reconciles_same_run_with_pairing_and_lease(self) -> None: + async def flow(): + run_id = await self._make_run() + pairing = await create_bridge_pairing(run_id=run_id) + first_session = BridgeSession() + await first_session.handle( + {"v": 1, "id": "h1", "type": "hello", "payload": _hello_payload()} + ) + await first_session.handle( + {"v": 1, "id": "p1", "type": "pairing.request", "payload": {"bootstrapToken": pairing["bootstrapToken"]}} + ) + first = await first_session.handle( + { + "v": 1, + "id": "a1", + "type": "run.attach", + "runId": run_id, + "payload": { + "harness": {"name": "fake-harness", "version": "0.0.1"}, + "adapter": {"name": "fake-adapter", "version": "0.0.1"}, + "harnessSessionId": "sess-restart", + }, + } + ) + first_lease = first["result"]["leaseId"] + before = await load_agent_run(run_id) + + new_pairing = await create_bridge_pairing(run_id=run_id) + resumed_session = BridgeSession() + await resumed_session.handle( + {"v": 1, "id": "h2", "type": "hello", "payload": _hello_payload()} + ) + await resumed_session.handle( + {"v": 1, "id": "p2", "type": "pairing.request", "payload": {"bootstrapToken": new_pairing["bootstrapToken"]}} + ) + resumed = await resumed_session.handle( + { + "v": 1, + "id": "a2", + "type": "run.attach", + "runId": run_id, + "payload": { + "harness": {"name": "fake-harness", "version": "0.0.1"}, + "adapter": {"name": "fake-adapter", "version": "0.0.1"}, + "harnessSessionId": "sess-restart", + "leaseId": first_lease, + "lastEventSeq": before["event_sequence"], + }, + } + ) + after = await load_agent_run(run_id) + return first, resumed, before, after + + first, resumed, before, after = self._run(flow()) + self.assertTrue(resumed["ok"]) + self.assertEqual(resumed["result"]["leaseId"], first["result"]["leaseId"]) + self.assertEqual(after["event_sequence"], before["event_sequence"]) + def test_session_full_readonly_flow(self) -> None: async def flow(): run_id = await self._make_run() diff --git a/backend/tests/test_agent_connection.py b/backend/tests/test_agent_connection.py index c03dd686..1999b08a 100644 --- a/backend/tests/test_agent_connection.py +++ b/backend/tests/test_agent_connection.py @@ -4,6 +4,7 @@ import json import time import unittest +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from app.services import agent_connection as connection @@ -68,6 +69,25 @@ async def test_discovery_is_not_a_successful_connection_or_login(self): self.assertEqual(item["resume_state"], "NOT_VERIFIED") self.assertEqual(item["cancel_state"], "NOT_VERIFIED") + async def test_manual_connect_prompt_is_generic_and_uses_public_skill_source(self): + with ( + patch.object(connection.runtime, "list_local_executors", AsyncMock(return_value={"items": []})), + patch.object(connection, "list_provider_health", AsyncMock(return_value={"providers": []})), + ): + result = await connection.get_agent_connections() + + prompt = result["connect_prompt"] + self.assertIn( + "https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md", + prompt, + ) + self.assertIn("get_current_view", prompt) + self.assertIn("Operation Registry", prompt) + self.assertNotIn("http://127.0.0.1:8766", prompt) + self.assertNotIn(str(Path(__file__).resolve().parents[2]), prompt) + for provider in ("Codex", "Claude", "OpenCode", "Pi"): + self.assertNotIn(provider, prompt) + async def test_persisted_conformance_states_are_projected_without_exposing_credentials(self): item = connection._view( detected(), diff --git a/backend/tests/test_agent_integration.py b/backend/tests/test_agent_integration.py index 8147dc53..574b815c 100644 --- a/backend/tests/test_agent_integration.py +++ b/backend/tests/test_agent_integration.py @@ -86,6 +86,12 @@ async def test_install_update_and_repair_use_a_real_file(self) -> None: adapter.install() self.assertEqual(adapter.repair()["skill_status"], "INSTALLED") + async def test_runtime_skill_projection_uses_the_current_install_command(self) -> None: + content = integration.installed_skill_content() + self.assertIn("Install in the Agent you are using", content) + self.assertIn("get_current_view", content) + self.assertIn(str(integration._BACKEND_ROOT.resolve()), content) + async def test_codex_child_uses_system_proxy_without_overriding_process_proxy(self) -> None: with patch.dict(codex_adapter.os.environ, {}, clear=True), patch.object( codex_adapter.urllib.request, diff --git a/backend/tests/test_agent_skill_download.py b/backend/tests/test_agent_skill_download.py new file mode 100644 index 00000000..a45127c7 --- /dev/null +++ b/backend/tests/test_agent_skill_download.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import httpx +from fastapi import FastAPI + +from app.routes import main_agent + + +class AgentSkillDownloadTests(unittest.IsolatedAsyncioTestCase): + async def test_download_returns_only_the_skill_as_an_attachment(self) -> None: + test_app = FastAPI() + test_app.include_router(main_agent.runtime_router, prefix="/api/agent") + content = "---\nname: offeru\n---\nOfferU runtime projection" + with patch( + "app.services.agent_integration.installed_skill_content", + return_value=content, + ): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=test_app), + base_url="http://127.0.0.1:8766", + ) as client: + response = await client.get("/api/agent/runtime/skill") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, content.encode()) + self.assertTrue(response.headers["content-type"].startswith("text/markdown")) + self.assertIn('filename="offeru-SKILL.md"', response.headers["content-disposition"]) + self.assertEqual(response.headers["cache-control"], "no-store") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_agent_skill_projections.py b/backend/tests/test_agent_skill_projections.py index 56bd5fc9..1abf8299 100644 --- a/backend/tests/test_agent_skill_projections.py +++ b/backend/tests/test_agent_skill_projections.py @@ -41,6 +41,12 @@ def test_manifest_projects_the_versioned_skill_registry(self) -> None: set(selected_skill["allowed_tools"]), ) + bootstrap = _manifest(skill="connection_bootstrap") + self.assertEqual( + {operation["name"] for operation in bootstrap["operations"]}, + {"get_current_view"}, + ) + def test_slash_commands_resolve_through_the_registry(self) -> None: self.assertEqual(resolve_skill("/offeru").id, "discovery") self.assertEqual(resolve_skill("/scan").id, "scan_jobs") @@ -77,8 +83,23 @@ def test_external_agent_files_are_generated_and_safe(self) -> None: self.assertNotIn("python -m app.cli routes", content) self.assertNotIn("http://localhost:8000/api", content) for path, content in rendered.items(): - if path.name == "SKILL.md": + if path in { + Path(".agents/skills/offeru/SKILL.md"), + Path(".claude/skills/offeru/SKILL.md"), + Path(".copilot/SKILL.md"), + }: + self.assertIn("Install in the Agent you are using", content) + self.assertIn( + "https://raw.githubusercontent.com/avabbbb/OfferU/main/.agents/skills/offeru/SKILL.md", + content, + ) + self.assertIn("http://127.0.0.1:8766/api/agent/runtime/skill", content) + self.assertIn("runtime-specific CLI", content) + self.assertIn("~/.agents/skills/offeru/SKILL.md", content) + self.assertIn("~/.omp/agent/skills/offeru/SKILL.md", content) + self.assertIn("do not change its settings", content) self.assertIn("get_agent_connection_nonce", content) + self.assertIn("get_current_view", content) def test_checked_in_projections_have_no_drift(self) -> None: self.assertEqual(projection_drift(PROJECT_ROOT), []) diff --git a/backend/tests/test_public_skill_projection.py b/backend/tests/test_public_skill_projection.py new file mode 100644 index 00000000..347d7a79 --- /dev/null +++ b/backend/tests/test_public_skill_projection.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from contextlib import closing +from io import BytesIO +from pathlib import Path +import socket +import subprocess +import unittest +from urllib.error import HTTPError, URLError +from unittest.mock import patch + +from scripts.verify_public_skill_projection import ( + FETCH_ATTEMPTS, + ProjectionCheckError, + fetch_public_skill, + parse_marker, + public_skill_url, + read_canonical_projection, + verify_public_skill_projection, +) + + +REPOSITORY = "offeru-owner/OfferU" +COMMIT_SHA = "a" * 40 +MARKER = b"" +SKILL = b"---\nname: offeru\n---\n" + MARKER + b"\nCanonical body\n" + + +class FakeResponse(BytesIO): + status = 200 + + def __init__(self, content: bytes, url: str): + super().__init__(content) + self._url = url + + def geturl(self) -> str: + return self._url + + +class PublicSkillProjectionTests(unittest.TestCase): + def test_url_requires_owner_repo_and_full_immutable_sha(self) -> None: + expected = ( + "https://raw.githubusercontent.com/offeru-owner/OfferU/" + + COMMIT_SHA + + "/.agents/skills/offeru/SKILL.md" + ) + self.assertEqual(public_skill_url(REPOSITORY, COMMIT_SHA), expected) + for repository, sha in ( + ("https://raw.githubusercontent.com/owner/repo", COMMIT_SHA), + ("owner/repo/branch", COMMIT_SHA), + ("owner/repo", "main"), + ("owner/repo", "a" * 39), + ): + with self.subTest(repository=repository, sha=sha), self.assertRaises(ProjectionCheckError): + public_skill_url(repository, sha) + + def test_marker_must_exist_exactly_once_and_be_well_formed(self) -> None: + parsed = parse_marker(SKILL, source="fixture") + self.assertEqual(parsed.version, "2026-09-28.1") + self.assertEqual(parsed.registry_hash, "b" * 64) + for invalid in (b"no marker\n", SKILL + MARKER + b"\n", SKILL.replace(b"b" * 64, b"z" * 64)): + with self.subTest(invalid=invalid[:30]), self.assertRaises(ProjectionCheckError): + parse_marker(invalid, source="fixture") + + def test_public_projection_requires_same_marker_and_exact_bytes(self) -> None: + url = public_skill_url(REPOSITORY, COMMIT_SHA) + with self.subTest("matching bytes"): + result = verify_public_skill_projection( + REPOSITORY, + COMMIT_SHA, + fetch=lambda requested: self._assert_url_and_return(requested, url, SKILL), + canonical_content=SKILL, + ) + self.assertEqual(result, url) + + for remote in ( + SKILL.replace(b"Canonical body", b"Changed body"), + SKILL.replace(b"2026-09-28.1", b"2026-09-27.1"), + b"not a generated OfferU Skill\n", + ): + with self.subTest(remote=remote[-24:]), self.assertRaises(ProjectionCheckError): + verify_public_skill_projection( + REPOSITORY, + COMMIT_SHA, + fetch=lambda requested, remote=remote: remote, + canonical_content=SKILL, + ) + + def test_canonical_projection_is_read_from_the_requested_immutable_commit(self) -> None: + expected = subprocess.CompletedProcess(args=[], returncode=0, stdout=SKILL, stderr=b"") + with patch("scripts.verify_public_skill_projection.subprocess.run", return_value=expected) as run: + self.assertEqual(read_canonical_projection(Path("H:/repo"), commit_sha=COMMIT_SHA), SKILL) + run.assert_called_once_with( + ["git", "show", f"{COMMIT_SHA}:.agents/skills/offeru/SKILL.md"], + cwd=Path("H:/repo"), + check=True, + capture_output=True, + ) + + with self.assertRaisesRegex(ProjectionCheckError, "full immutable"): + read_canonical_projection(Path("H:/repo"), commit_sha="main") + + def test_fetch_retries_transient_errors_only_with_a_bound(self) -> None: + url = public_skill_url(REPOSITORY, COMMIT_SHA) + pauses: list[float] = [] + attempts = 0 + + def open_url(_request: object, *, timeout: int): + nonlocal attempts + self.assertEqual(timeout, 10) + attempts += 1 + if attempts < 3: + raise URLError(socket.timeout("transient")) + return closing(FakeResponse(SKILL, url)) + + result = fetch_public_skill(url, open_url=open_url, sleep=pauses.append) + self.assertEqual(result, SKILL) + self.assertEqual(attempts, 3) + self.assertEqual(pauses, [0.5, 1.0]) + + attempts = 0 + pauses.clear() + + def always_timeout(_request: object, *, timeout: int): + nonlocal attempts + attempts += 1 + raise URLError(socket.timeout("offline")) + + with self.assertRaisesRegex(ProjectionCheckError, f"after {FETCH_ATTEMPTS} attempts"): + fetch_public_skill(url, open_url=always_timeout, sleep=pauses.append) + self.assertEqual(attempts, FETCH_ATTEMPTS) + self.assertEqual(len(pauses), FETCH_ATTEMPTS - 1) + + def test_fetch_does_not_retry_permanent_http_errors_or_redirects(self) -> None: + url = public_skill_url(REPOSITORY, COMMIT_SHA) + attempts = 0 + + def not_found(_request: object, *, timeout: int): + nonlocal attempts + attempts += 1 + raise HTTPError(url, 404, "not found", {}, None) + + with self.assertRaisesRegex(ProjectionCheckError, "HTTP 404"): + fetch_public_skill(url, open_url=not_found, sleep=lambda _delay: self.fail("must not retry")) + self.assertEqual(attempts, 1) + + def redirected(_request: object, *, timeout: int): + return closing(FakeResponse(SKILL, "https://example.com/skill.md")) + + with self.assertRaisesRegex(ProjectionCheckError, "redirected"): + fetch_public_skill(url, open_url=redirected) + + def test_fetch_rejects_mutable_refs_and_noncanonical_urls_before_network(self) -> None: + for url in ( + "https://raw.githubusercontent.com/offeru-owner/OfferU/main/.agents/skills/offeru/SKILL.md", + "https://user@raw.githubusercontent.com/offeru-owner/OfferU/" + COMMIT_SHA + "/.agents/skills/offeru/SKILL.md", + "https://raw.githubusercontent.com/offeru-owner/OfferU/" + COMMIT_SHA + "/.agents/skills/offeru/SKILL.md?raw=1", + ): + with self.subTest(url=url), self.assertRaises(ProjectionCheckError): + fetch_public_skill( + url, + open_url=lambda *_args, **_kwargs: self.fail("must reject before network"), + ) + + @staticmethod + def _assert_url_and_return(requested: str, expected: str, content: bytes) -> bytes: + if requested != expected: + raise AssertionError(f"unexpected URL: {requested}") + return content + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_release_architecture_audit.py b/backend/tests/test_release_architecture_audit.py index 8d11a641..3ee12bed 100644 --- a/backend/tests/test_release_architecture_audit.py +++ b/backend/tests/test_release_architecture_audit.py @@ -644,7 +644,9 @@ def test_frontend_api_clients_reject_redirects() -> None: assert api_source.count('redirect: "error"') >= 2 assert hooks_source.count('redirect: "error"') >= 2 assert 'redirect: "error"' in providers_source - assert studio_source.count('redirect: "error"') >= 2 + assert 'request("/api/studio/templates")' in studio_source + assert 'request<{ id?: number }>("/api/studio/generate", {' in studio_source + assert 'fetch(`${API_BASE}${path}`, { ...options, redirect: "error" })' in api_source assert "streamOptimizeAgentChat(" in optimize_source assert "export async function streamOptimizeAgentChat" in hooks_source assert "await showcaseFetch(`/api/optimize/agent/chat/stream`" in hooks_source @@ -653,7 +655,7 @@ def test_frontend_api_clients_reject_redirects() -> None: hooks_source.index("async function showcaseFetch") + 800 ] assert 'return fetch(target, { ...init, redirect: "error" })' in showcase_fetch - assert settings_source.count('redirect: "error"') >= 1 + assert 'requestResponse("/api/config/test-llm", { method: "POST" })' in settings_source assert 'redirect: "error"' in showcase_source diff --git a/backend/tests/test_web_local_runtime_cors.py b/backend/tests/test_web_local_runtime_cors.py new file mode 100644 index 00000000..2f918ac1 --- /dev/null +++ b/backend/tests/test_web_local_runtime_cors.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app, _TRUSTED_WEB_ORIGINS, _is_allowed_cors_origin + + +def test_github_pages_is_the_only_trusted_public_loopback_client() -> None: + assert _TRUSTED_WEB_ORIGINS == {"https://avabbbb.github.io"} + assert _is_allowed_cors_origin("https://avabbbb.github.io") is True + assert _is_allowed_cors_origin("https://evil.example") is False + assert _is_allowed_cors_origin("https://avabbbb.github.io.evil.example") is False + + +def test_existing_local_origins_remain_allowed() -> None: + assert _is_allowed_cors_origin("http://127.0.0.1:7410") is True + assert _is_allowed_cors_origin("http://localhost:7410") is True + assert _is_allowed_cors_origin("tauri://localhost") is True + + +def test_untrusted_public_origin_is_rejected_before_local_api() -> None: + client = TestClient(app) + response = client.get( + "/api/health", + headers={ + "Host": "127.0.0.1:8766", + "Origin": "https://evil.example", + }, + ) + assert response.status_code == 403 + assert "来源未授权" in response.json()["detail"] + + +def test_trusted_web_origin_gets_cors_and_standard_preflight() -> None: + client = TestClient(app) + origin = "https://avabbbb.github.io" + + health = client.get( + "/api/health", + headers={"Host": "127.0.0.1:8766", "Origin": origin}, + ) + assert health.status_code == 200 + assert health.headers["access-control-allow-origin"] == origin + + preflight = client.options( + "/api/health", + headers={ + "Host": "127.0.0.1:8766", + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert preflight.status_code == 200 + assert preflight.headers["access-control-allow-origin"] == origin diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 92118649..ee8481ac 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -44,8 +44,8 @@ import { } from "@/lib/hooks"; import { safeClientErrorMessage } from "@/lib/safe-error"; import { useWorkbench } from "@/lib/workbench"; - -import { resolveApiBase } from "@/lib/apiBase"; +import { requestResponse } from "@/lib/api"; +import { isDemoRuntime } from "@/lib/localRuntime"; type SignalNotification = Notification & { acknowledged_at?: string | null }; @@ -354,12 +354,10 @@ export default function TodayPage() { const [signalAckBusy, setSignalAckBusy] = useState(null); const acknowledgeSignal = async (id: number) => { + if (isDemoRuntime()) return; setSignalAckBusy(id); try { - const response = await fetch( - `${resolveApiBase()}/api/email/notifications/${id}/ack`, - { method: "POST" }, - ); + const response = await requestResponse(`/api/email/notifications/${id}/ack`, { method: "POST" }); if (!response.ok) { throw new Error(`API ${response.status}`); } @@ -1028,8 +1026,9 @@ export default function TodayPage() { +
+ + {import.meta.env.VITE_SHOWCASE === "true" && ( + + )} +
) : ( <> diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 7045c606..c6f57031 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -44,10 +44,10 @@ import { type DataIntegrityReport, type DataSafetyStatus, type PrivacyHygieneStatus, + requestResponse, } from "@/lib/api"; -import { resolveApiBase } from "@/lib/apiBase"; import { safeClientErrorMessage } from "@/lib/safe-error"; -import { SHOWCASE } from "@/lib/showcase/router"; +import { isDemoRuntime } from "@/lib/localRuntime"; import { useConfig, updateConfig } from "@/lib/hooks"; import { AgentConnectionPanel } from "@/components/workbench/AgentConnectionPanel"; import { JobSourceConnectionsCard } from "@/components/settings/JobSourceConnectionsCard"; @@ -228,14 +228,14 @@ function TestLlmButton() { const [result, setResult] = useState<{ success: boolean; message: string } | null>(null); const testConnection = async () => { + if (isDemoRuntime()) { + setResult({ success: false, message: "展示模式不能连接本地模型;请先连接本地 OfferU。" }); + return; + } setTesting(true); setResult(null); try { - const API_BASE = resolveApiBase(); - const res = await fetch(`${API_BASE}/api/config/test-llm`, { - method: "POST", - redirect: "error", - }); + const res = await requestResponse("/api/config/test-llm", { method: "POST" }); // 非 2xx 时后端返回 {detail} 而不是 {success,message}, // 不校验会把失败显示成"模型连接测试完成"。 if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -255,6 +255,8 @@ function TestLlmButton() { className="border-2 border-white/30 bg-white/10 text-white hover:bg-white/20" onPress={testConnection} isLoading={testing} + isDisabled={isDemoRuntime()} + title={isDemoRuntime() ? "连接本地 OfferU 后可测试模型" : undefined} > 测试连接 @@ -293,7 +295,7 @@ function LocalDataSafetyCard() { const [nextStatus, nextBackups, nextPrivacyHygiene] = await Promise.all([ dataSafetyApi.status(), dataSafetyApi.listBackups(), - SHOWCASE ? Promise.resolve(null) : dataSafetyApi.privacyHygieneStatus(), + isDemoRuntime() ? Promise.resolve(null) : dataSafetyApi.privacyHygieneStatus(), ]); setStatus(nextStatus); setBackups(nextBackups.items || []); @@ -522,7 +524,7 @@ function LocalDataSafetyCard() { )}
-
- {SHOWCASE &&

Showcase 使用独立 IndexedDB;此处只提供 JSON 导出、完整性说明和 Demo 重置,不伪装成 SQLite 备份。

} + {isDemoRuntime() &&

Showcase 使用独立 IndexedDB;此处只提供 JSON 导出、完整性说明和 Demo 重置,不伪装成 SQLite 备份。

}
@@ -540,7 +542,7 @@ function LocalDataSafetyCard() {

Demo / Fixture 工作区

重置演示数据与删除真实数据是两件事

- {SHOWCASE + {isDemoRuntime() ? "当前为独立 Showcase IndexedDB。重置会清除这个虚构展示工作区,下一次读取会回到内置演示数据。" : "本地模式只会清除 source=offeru-demo 且 batch_id=offeru-demo-v1 的明确合成数据;不会删除 Profile、真实岗位、真实简历、备份或连接信息。这里没有删除真实用户数据的入口。"}

@@ -560,7 +562,7 @@ function LocalDataSafetyCard() {
- {!SHOWCASE && privacyHygiene && ( + {!isDemoRuntime() && privacyHygiene && (
> | null = null; - let backendDiagnosticsStatus: "included" | "showcase" | "unavailable" = SHOWCASE ? "showcase" : "unavailable"; + let backendDiagnosticsStatus: "included" | "showcase" | "unavailable" = isDemoRuntime() ? "showcase" : "unavailable"; let backendErrorId = ""; - if (!SHOWCASE) { + if (!isDemoRuntime()) { try { backendDiagnostics = await diagnosticsApi.bundle(); backendDiagnosticsStatus = "included"; @@ -764,7 +766,7 @@ function LocalFeedbackCard() { current_page: window.location.hash || "#/", app_version: `frontend@${import.meta.env.VITE_APP_VERSION || "unknown"}`, build_mode: import.meta.env.MODE || "unknown", - runtime_mode: import.meta.env.VITE_SHOWCASE === "true" ? "showcase" : "local", + runtime_mode: isDemoRuntime() ? "showcase" : import.meta.env.VITE_SHOWCASE === "true" ? "web-local" : "local", user_note: safeNote, note_redacted: safeNote !== trimmed, diagnostics: { diff --git a/frontend/src/app/studio/page.tsx b/frontend/src/app/studio/page.tsx index baa7c8b1..580563cb 100644 --- a/frontend/src/app/studio/page.tsx +++ b/frontend/src/app/studio/page.tsx @@ -3,8 +3,9 @@ import { useState, useEffect } from "react"; import { Card, Button, Spinner } from "@nextui-org/react"; -import { SHOWCASE, showcaseHandle } from "@/lib/showcase/router"; +import { isDemoRuntime } from "@/lib/localRuntime"; import { resolveApiBase } from "@/lib/apiBase"; +import { request } from "@/lib/api"; // 与 lib/api.ts 同款后端地址解析;vite dev 无 proxy, // 相对路径 /api/... 会打到 Vite 自身(7410)返回 index.html。 @@ -39,22 +40,11 @@ export default function StudioPage() { // 选中这类模板时字体控件无效,需要显式禁用而不是静默失效。 const activeTemplateTokens = templates.find((tpl) => tpl.id === selectedTemplate)?.design_tokens; const supportsFontFamily = - SHOWCASE || !selectedTemplate || Boolean(activeTemplateTokens?.fontFamily); + isDemoRuntime() || !selectedTemplate || Boolean(activeTemplateTokens?.fontFamily); useEffect(() => { - if (SHOWCASE) { - // 展示模式:模板列表由本地数据层提供(无后端) - showcaseHandle("/api/studio/templates").then((data) => { - if (Array.isArray(data)) setTemplates(data as Template[]); - }); - return; - } let cancelled = false; - fetch(`${API_BASE}/api/studio/templates`, { redirect: "error" }) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }) + request("/api/studio/templates") .then((data) => { if (cancelled) return; if (Array.isArray(data)) setTemplates(data as Template[]); @@ -71,13 +61,16 @@ export default function StudioPage() { const handleGenerate = async () => { if (!selectedTemplate) return; + if (isDemoRuntime()) { + setGenerateError("展示模式只显示模板预览,不会生成或写入真实简历。连接本地 OfferU 后即可使用。"); + return; + } setLoading(true); setGenerateError(null); try { - const res = await fetch(`${API_BASE}/api/studio/generate`, { + const data = await request<{ id?: number }>("/api/studio/generate", { method: "POST", - redirect: "error", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ profile_id: 1, // 本地单人应用:固定默认 profile @@ -89,15 +82,6 @@ export default function StudioPage() { job_ids: [] }) }); - - const data = await res.json().catch(() => null); - if (!res.ok) { - // 失败不伪造成功:不把 undefined 拼成预览地址,也不在 iframe 里静默显示错误页 - const detail = data?.detail ?? data?.message; - throw new Error( - typeof detail === "string" && detail ? detail : `生成失败(HTTP ${res.status})` - ); - } if (!data?.id) throw new Error("生成结果缺少简历 ID"); setPreviewUrl(`${API_BASE}/api/studio/resumes/${data.id}/preview`); } catch (err) { @@ -137,7 +121,7 @@ export default function StudioPage() { onPress={() => setSelectedTemplate(tpl.id)} >
- {SHOWCASE || previewFailures[tpl.id] ? ( + {isDemoRuntime() || previewFailures[tpl.id] ? ( // 展示模式或资源不可用时保留可操作的品牌占位态
@@ -229,9 +213,9 @@ export default function StudioPage() { className="w-full" onPress={handleGenerate} isLoading={loading} - isDisabled={!selectedTemplate} + isDisabled={!selectedTemplate || isDemoRuntime()} > - {loading ? "生成中..." : "生成简历"} + {loading ? "生成中..." : isDemoRuntime() ? "连接本地 OfferU 后生成" : "生成简历"}
diff --git a/frontend/src/components/onboarding/OnboardingGate.tsx b/frontend/src/components/onboarding/OnboardingGate.tsx index 7a115ab0..15784080 100644 --- a/frontend/src/components/onboarding/OnboardingGate.tsx +++ b/frontend/src/components/onboarding/OnboardingGate.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { useOnboarding } from "@/lib/useOnboarding"; -import { SHOWCASE } from "@/lib/showcase/router"; +import { isDemoRuntime } from "@/lib/localRuntime"; const OnboardingWizard = lazy(() => import("./OnboardingWizard").then((module) => ({ default: module.OnboardingWizard }))); @@ -10,7 +10,7 @@ export function OnboardingGate({ children }: { children: ReactNode }) { const pathname = usePathname(); return <> {children} - {!SHOWCASE && shouldShowWizard && pathname === "/" && + {!isDemoRuntime() && shouldShowWizard && pathname === "/" && 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 = isDemoRuntime() ? "Agent · Demo" : hasProblem ? "Agent · 需要处理" : pending ? "Agent · 正在检查 / 同步" : ready ? "Agent · 接入检查通过" : "连接本机 Agent"; const detail = state.sync.status === "failed" ? "内容同步失败,点击重试" : state.error || state.stale ? "状态未更新,点击查看" @@ -112,6 +112,10 @@ export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean const state = useAgentConnection(); const [selectedId, setSelectedId] = useState(null); const [showAll, setShowAll] = useState(false); + const demoRuntime = isDemoRuntime(); + + const connectLocal = () => state.localRuntime.connect(); + const disconnectLocal = () => state.localRuntime.disconnect(); const candidates = state.snapshot?.items || []; const beginnerCandidates = candidates.filter((item) => item.beginner); const suggested = beginnerCandidates.find((item) => item.recommended) @@ -141,11 +145,19 @@ export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean 本机 Agent 沿用本机配置
-

让熟悉的 Agent,接着帮你求职。

-

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

+

连接你的 AI

+

Web 和 Studio 共用同一个本地 OfferU Runtime。选一个本机 Agent,后续同步和执行都走同一条连接。

- {SHOWCASE ?

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

: <> + {demoRuntime ?
+

连接本机 OfferU

+

连接后,这个网页会直接使用你电脑上的职业数据和 Coding Agent,不再使用展示数据。浏览器可能会询问是否允许访问本机网络。

+ {(state.localRuntime.error || state.localRuntime.probing) &&

{state.localRuntime.error || "正在安全检查本机 OfferU Runtime…"}

} + +
: <> {(state.error || state.stale) &&
{state.error || "状态暂未更新,下面保留的是上次结果。"} @@ -186,15 +198,16 @@ export function AgentConnectionPanel({ embedded = false }: { embedded?: boolean

{checking ? "正在确认连接与登录状态" : presentation.title}

{presentation.detail}

{selected.last_error &&
{selected.last_error}
} -
    + {ready ?
    +

    {selected.name.replace(" App Server", "").replace(" Agent SDK", " SDK").replace(" CLI", "")} 已连接

    +

    沿用本机登录;OfferU 会自动同步当前工作,需要确认的操作仍会回到工作台。

    +
    :
      - - -
    + +
}
{!selected.installed && selected.docs_url ? 安装 Agent : integrationAction && selected.can_install_skill ? } +
} diff --git a/frontend/src/lib/agentConnection.tsx b/frontend/src/lib/agentConnection.tsx index 4df03357..350bb7d0 100644 --- a/frontend/src/lib/agentConnection.tsx +++ b/frontend/src/lib/agentConnection.tsx @@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import useSWR from "swr"; import { usePathname } from "next/navigation"; import { agentRuntimeApi, type AgentConnectionsSnapshot } from "./api"; -import { SHOWCASE } from "./showcase/router"; +import { isDemoRuntime, useLocalRuntime } from "./localRuntime"; import { safeClientErrorMessage } from "./safe-error"; import { useWorkbench } from "./workbench"; import type { components } from "./api-types.generated"; @@ -24,6 +24,7 @@ interface ConnectionActivity { } interface AgentConnectionContextValue { + localRuntime: ReturnType; snapshot: AgentConnectionsSnapshot | undefined; loading: boolean; refreshing: boolean; @@ -64,6 +65,7 @@ function entityFromRoute(pathname: string): { entity_type: string; entity_id: st export function AgentConnectionProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const localRuntime = useLocalRuntime(); const { selection } = useWorkbench(); const [open, setOpen] = useState(false); const [probing, setProbing] = 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", + isDemoRuntime() || /^\/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 (isDemoRuntime() || /^\/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 || isDemoRuntime()) 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 || isDemoRuntime()) 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), []); @@ -241,6 +243,7 @@ export function AgentConnectionProvider({ children }: { children: React.ReactNod return ( ) { return sp.toString(); } +export async function requestResponse(path: string, options?: RequestInit): Promise { + if (isDemoRuntime()) { + const data = await showcaseHandle(path, options); + return new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + try { + return await fetch(`${API_BASE}${path}`, { ...options, redirect: "error" }); + } catch { + throw new Error("无法连接本地后端,请确认 8766 服务已启动。"); + } +} + export async function request(path: string, options?: RequestInit): Promise { - if (SHOWCASE) { - // 展示模式:全部请求由本地 IndexedDB 数据层承载(无需 Python 后端) + if (isDemoRuntime()) { + // Demo runtime: requests stay in IndexedDB. Web-local and Desktop share 8766. return (await showcaseHandle(path, options)) as T; } let res: Response; @@ -83,8 +99,8 @@ async function readEventStream( onEvent?: (event: string, data: any) => void, signal?: AbortSignal ): Promise { - if (SHOWCASE) { - // 展示模式:Agent 工作流端点(optimize/interviews)不接本地数据层, + if (isDemoRuntime()) { + // Demo runtime: Agent workflow endpoints stay synthetic; connected Web uses 8766. // 返回空结果避免抛错;对话式交互见 profileApi.chat 的合成 SSE。 return {} as T; } @@ -235,20 +251,22 @@ export const resumeApi = { // 文件上传 uploadPhoto: async (resumeId: number, file: File) => { + if (isDemoRuntime()) throw new Error("展示模式不会上传到真实简历,请连接本地 OfferU 后重试。"); const formData = new FormData(); formData.append("file", file); - const res = await fetch(`${API_BASE}/api/resume/${resumeId}/photo`, { + const res = await requestResponse(`/api/resume/${resumeId}/photo`, { method: "POST", body: formData, - redirect: "error", }); if (!res.ok) throw new Error(`API Error: ${res.status}`); return res.json(); }, // 导出 - exportPdf: (id: number) => - fetch(`${API_BASE}/api/resume/${id}/export/pdf`, { method: "POST", redirect: "error" }), + exportPdf: (id: number) => { + if (isDemoRuntime()) throw new Error("展示模式不能导出本地简历,请连接本地 OfferU 后重试。"); + return requestResponse(`/api/resume/${id}/export/pdf`, { method: "POST" }); + }, exportPdfUrl: (id: number) => `${API_BASE}/api/resume/${id}/export/pdf`, @@ -1584,28 +1602,27 @@ 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 (isDemoRuntime()) { + // Demo runtime uses the synthetic browser stream; connected Web uses local Python. return showcaseChatResponse(data.topic || "general", data.message || ""); } - const res = await fetch(`${API_BASE}/api/profile/chat`, { + const res = await requestResponse("/api/profile/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), - redirect: "error", }); if (!res.ok) throw new Error(`API Error: ${res.status}`); return res; }, importResume: async (file: File, parseMode: "ai" | "mechanical" = "ai") => { + if (isDemoRuntime()) throw new Error("展示模式不会导入到真实职业档案,请连接本地 OfferU 后重试。"); const formData = new FormData(); formData.append("file", file); const params = new URLSearchParams({ parse_mode: parseMode }); - const res = await fetch(`${API_BASE}/api/profile/import-resume?${params.toString()}`, { + const res = await requestResponse(`/api/profile/import-resume?${params.toString()}`, { method: "POST", body: formData, - redirect: "error", }); if (!res.ok) throw new Error(`API Error: ${res.status}`); return res.json(); @@ -1633,6 +1650,7 @@ export const profileApi = { target_city?: string; job_goal?: string; }): Promise => { + if (isDemoRuntime()) throw new Error("展示模式不会启动真实职业档案任务,请连接本地 OfferU 后重试。"); const formData = new FormData(); if (data.file) formData.append("file", data.file); formData.append("resume_text", data.resume_text || ""); @@ -1640,10 +1658,9 @@ export const profileApi = { formData.append("target_city", data.target_city || ""); formData.append("job_goal", data.job_goal || ""); - const res = await fetch(`${API_BASE}/api/profile/agent/start`, { + const res = await requestResponse("/api/profile/agent/start", { method: "POST", body: formData, - redirect: "error", }); if (!res.ok) throw new Error(`API Error: ${res.status}`); return res.json(); diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 921869c3..0c85dc73 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -7,7 +7,8 @@ // ============================================= import useSWR from "swr"; -import { SHOWCASE, showcaseHandle } from "@/lib/showcase/router"; +import { showcaseHandle } from "@/lib/showcase/router"; +import { isDemoRuntime } from "@/lib/localRuntime"; import { showcaseChatResponse } from "@/lib/showcase/llm"; import { resolveApiBase } from "@/lib/apiBase"; import { safeClientErrorMessage } from "@/lib/safe-error"; @@ -23,7 +24,7 @@ function formatBackendNetworkError(_error?: unknown) { * 通用 fetcher:SWR 默认请求函数 * 自动处理 JSON 解析和错误码 */ -const fetcher = async (url: string) => { if (SHOWCASE) { +const fetcher = async (url: string) => { if (isDemoRuntime()) { // 展示模式:SWR 请求也由本地数据层承载(URL 为完整地址,提取 path) try { const parsed = new URL(url); @@ -47,11 +48,11 @@ const fetcher = async (url: string) => { if (SHOWCASE) { /** * 统一后端请求:非展示模式等价 fetch(API_BASE + path); - * 展示模式(VITE_SHOWCASE)下由本地 IndexedDB 数据层承载, + * Demo runtime 由本地 IndexedDB 承载;Web-local / Desktop 共用 8766, * 合成标准 Response,调用方无需感知后端是否存在。 */ async function showcaseFetch(url: string, init?: RequestInit): Promise { - if (!SHOWCASE) { + if (!isDemoRuntime()) { const target = /^https?:\/\//i.test(url) ? url : `${API_BASE}${url.startsWith("/") ? url : `/${url}`}`; @@ -385,7 +386,7 @@ export async function controlCareerTask( return payload; } - if (!SHOWCASE) { + if (!isDemoRuntime()) { try { const confirmed = await decideAgentRuntimeActionInDesktop( String(proposal.run_id), diff --git a/frontend/src/lib/localRuntime.test.ts b/frontend/src/lib/localRuntime.test.ts new file mode 100644 index 00000000..8e915516 --- /dev/null +++ b/frontend/src/lib/localRuntime.test.ts @@ -0,0 +1,168 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +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 { + getLocalRuntimeSnapshot, + isDemoRuntime, + isLocalRuntimeSelected, + probeLocalRuntime, + selectDemoRuntime, + selectLocalRuntime, + useLocalRuntime, +} from "./localRuntime"; + +const offeruHealth = { + status: "ok", + service: "OfferU", + runtime: "python", + version: "0.4.0", + build_mode: "release", +}; + +function mockHealth(payload: unknown = offeruHealth, status = 200) { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("localRuntime", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + localStorage.clear(); + selectDemoRuntime(); + }); + + it("keeps showcase in demo until the current page verifies a remembered or explicit connection", () => { + expect(isDemoRuntime()).toBe(true); + expect(isLocalRuntimeSelected()).toBe(false); + expect(getLocalRuntimeSnapshot()).toMatchObject({ selected: false, connected: false, probing: false }); + }); + + it("selects the single local runtime only after OfferU health identity passes", async () => { + const fetchMock = mockHealth(); + + const result = await selectLocalRuntime(); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:8766/api/health", + expect.objectContaining({ + mode: "cors", + credentials: "omit", + targetAddressSpace: "loopback", + cache: "no-store", + redirect: "error", + }), + ); + expect(isLocalRuntimeSelected()).toBe(true); + expect(isDemoRuntime()).toBe(false); + expect(getLocalRuntimeSnapshot()).toMatchObject({ selected: true, connected: true, probing: false, error: "" }); + }); + + it("does not select local runtime from status=ok without both OfferU identity fields", async () => { + for (const payload of [ + { status: "ok" }, + { status: "ok", service: "OfferU", runtime: "node" }, + { status: "degraded", service: "OfferU", runtime: "python" }, + ]) { + localStorage.clear(); + selectDemoRuntime(); + mockHealth(payload); + + const result = await selectLocalRuntime(); + + expect(result.ok).toBe(false); + expect(isLocalRuntimeSelected()).toBe(false); + expect(isDemoRuntime()).toBe(true); + expect(localStorage.getItem("offeru.web.runtime")).toBeNull(); + } + }); + + it("rejects a local OfferU runtime with a different app version", async () => { + mockHealth({ ...offeruHealth, version: "0.3.9" }); + + const result = await selectLocalRuntime(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("版本不匹配"); + expect(isDemoRuntime()).toBe(true); + expect(localStorage.getItem("offeru.web.runtime")).toBeNull(); + }); + + it("never switches truth source when the local probe fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline"))); + + const result = await selectLocalRuntime(); + + expect(result.ok).toBe(false); + expect(isLocalRuntimeSelected()).toBe(false); + expect(isDemoRuntime()).toBe(true); + expect(getLocalRuntimeSnapshot()).toMatchObject({ selected: false, connected: false, probing: false }); + }); + + it("rechecks a remembered connection on hook mount and restores local mode only after identity passes", async () => { + localStorage.setItem("offeru.web.runtime", "local"); + const fetchMock = mockHealth(); + const { result } = renderHook(() => useLocalRuntime()); + + await waitFor(() => expect(result.current).toMatchObject({ selected: true, connected: true, probing: false })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(isDemoRuntime()).toBe(false); + }); + + it("clears a remembered connection and stays in demo when its recheck fails", async () => { + localStorage.setItem("offeru.web.runtime", "local"); + mockHealth({ status: "ok" }); + const { result } = renderHook(() => useLocalRuntime()); + + await waitFor(() => expect(result.current).toMatchObject({ selected: false, connected: false, probing: false })); + + expect(result.current.error).toContain("不是 OfferU Runtime"); + expect(localStorage.getItem("offeru.web.runtime")).toBeNull(); + expect(isDemoRuntime()).toBe(true); + }); + + it("notifies subscribers on connect and disconnect without reloading the page", async () => { + mockHealth(); + const { result } = renderHook(() => useLocalRuntime()); + + await act(async () => { + await result.current.connect(); + }); + expect(result.current.connected).toBe(true); + expect(isDemoRuntime()).toBe(false); + + act(() => result.current.disconnect()); + expect(result.current).toMatchObject({ selected: false, connected: false, probing: false }); + expect(isDemoRuntime()).toBe(true); + }); + + it("does not restore a connection when the user disconnects during a pending probe", async () => { + let resolveFetch!: (response: Response) => void; + const pendingFetch = new Promise((resolve) => { resolveFetch = resolve; }); + vi.stubGlobal("fetch", vi.fn(() => pendingFetch)); + + const selecting = selectLocalRuntime(); + expect(getLocalRuntimeSnapshot().probing).toBe(true); + selectDemoRuntime(); + resolveFetch(new Response(JSON.stringify(offeruHealth), { status: 200 })); + + expect((await selecting).ok).toBe(false); + expect(getLocalRuntimeSnapshot()).toMatchObject({ selected: false, connected: false, probing: false }); + expect(isDemoRuntime()).toBe(true); + }); + + it("probe rejects a non-OfferU loopback service", async () => { + mockHealth({ status: "ok", service: "something-else", runtime: "python" }); + + expect((await probeLocalRuntime()).ok).toBe(false); + }); +}); diff --git a/frontend/src/lib/localRuntime.ts b/frontend/src/lib/localRuntime.ts new file mode 100644 index 00000000..f2caf3a3 --- /dev/null +++ b/frontend/src/lib/localRuntime.ts @@ -0,0 +1,207 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { resolveApiBase } from "./apiBase"; +import { SHOWCASE } from "./showcase/router"; + +const LOCAL_RUNTIME_KEY = "offeru.web.runtime"; +const LOCAL_RUNTIME_VALUE = "local"; +const LOCAL_RUNTIME_TIMEOUT_MS = 1600; +const EXPECTED_RUNTIME_VERSION = import.meta.env.VITE_APP_VERSION || ""; + +export interface LocalRuntimeProbe { + ok: boolean; + status?: string; + service?: string; + runtime?: string; + version?: string; + build_mode?: string; + error?: string; +} + +export interface LocalRuntimeState { + /** A remembered connection choice, not proof that the runtime is reachable. */ + selected: boolean; + /** True only after the current page session has verified the OfferU runtime. */ + connected: boolean; + probing: boolean; + error: string; +} + +function hasRememberedConnection(): boolean { + if (!SHOWCASE || typeof window === "undefined") return false; + try { + return window.localStorage.getItem(LOCAL_RUNTIME_KEY) === LOCAL_RUNTIME_VALUE; + } catch { + return false; + } +} + +const serverSnapshot: LocalRuntimeState = { + selected: false, + connected: !SHOWCASE, + probing: false, + error: "", +}; + +let runtimeSnapshot: LocalRuntimeState = { + ...serverSnapshot, + selected: SHOWCASE ? hasRememberedConnection() : true, +}; + +const listeners = new Set<() => void>(); +let probeGeneration = 0; + +export function subscribeLocalRuntime(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getLocalRuntimeSnapshot(): LocalRuntimeState { + return runtimeSnapshot; +} + +function getServerRuntimeSnapshot(): LocalRuntimeState { + return serverSnapshot; +} + +function publishRuntimeState(next: LocalRuntimeState): void { + runtimeSnapshot = next; + listeners.forEach((listener) => listener()); +} + +function clearRememberedConnection(): void { + if (!SHOWCASE || typeof window === "undefined") return; + try { + window.localStorage.removeItem(LOCAL_RUNTIME_KEY); + } catch { + // A failed storage cleanup never upgrades the in-memory state to connected. + } +} + +export function isLocalRuntimeSelected(): boolean { + return runtimeSnapshot.selected; +} + +export function isDemoRuntime(): boolean { + return SHOWCASE && !runtimeSnapshot.connected; +} + +export async function probeLocalRuntime(): Promise { + if (typeof window === "undefined") { + return { ok: false, error: "只能在浏览器中检查本地 OfferU Runtime。" }; + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), LOCAL_RUNTIME_TIMEOUT_MS); + try { + // Chromium implements targetAddressSpace for Private Network Access. Keep + // it optional in the local type so this remains compatible with DOM lib + // versions that have not added the field to RequestInit yet. + const requestInit: RequestInit & { targetAddressSpace?: "loopback" } = { + mode: "cors", + credentials: "omit", + cache: "no-store", + redirect: "error", + signal: controller.signal, + targetAddressSpace: "loopback", + }; + const response = await fetch(`${resolveApiBase()}/api/health`, requestInit); + if (!response.ok) { + return { ok: false, error: `HTTP ${response.status}` }; + } + const payload = await response.json().catch(() => ({})); + const identityMatches = ( + payload?.status === "ok" + && payload?.service === "OfferU" + && payload?.runtime === "python" + ); + const versionMatches = !EXPECTED_RUNTIME_VERSION || payload?.version === EXPECTED_RUNTIME_VERSION; + const ok = identityMatches && versionMatches; + return { + ok, + status: String(payload?.status || ""), + service: String(payload?.service || ""), + runtime: String(payload?.runtime || ""), + version: String(payload?.version || ""), + build_mode: String(payload?.build_mode || ""), + error: ok ? "" : !identityMatches + ? "检测到的本地服务不是 OfferU Runtime。" + : `本地 OfferU 版本不匹配(预期 ${EXPECTED_RUNTIME_VERSION},实际 ${String(payload?.version || "未知")})。`, + }; + } catch (cause) { + return { + ok: false, + error: cause instanceof Error && cause.name === "AbortError" + ? "连接本地 OfferU 超时。" + : "未检测到本地 OfferU Runtime。", + }; + } finally { + window.clearTimeout(timeout); + } +} + +export async function selectLocalRuntime(): Promise { + const generation = ++probeGeneration; + publishRuntimeState({ + ...runtimeSnapshot, + connected: false, + probing: true, + error: "", + }); + + const probe = await probeLocalRuntime(); + if (generation !== probeGeneration) { + return { ...probe, ok: false, error: "本地连接检查已取消。" }; + } + if (!probe.ok) { + clearRememberedConnection(); + publishRuntimeState({ selected: false, connected: !SHOWCASE, probing: false, error: probe.error || "连接检查失败。" }); + return probe; + } + + if (SHOWCASE) { + try { + window.localStorage.setItem(LOCAL_RUNTIME_KEY, LOCAL_RUNTIME_VALUE); + } catch { + clearRememberedConnection(); + const error = "浏览器无法保存本地连接状态。"; + publishRuntimeState({ selected: false, connected: false, probing: false, error }); + return { ...probe, ok: false, error }; + } + } + + publishRuntimeState({ selected: true, connected: true, probing: false, error: "" }); + return probe; +} + +export function selectDemoRuntime(): void { + if (!SHOWCASE || typeof window === "undefined") return; + probeGeneration += 1; + clearRememberedConnection(); + publishRuntimeState({ selected: false, connected: false, probing: false, error: "" }); +} + +/** + * Subscribe UI to runtime connection changes. A remembered choice is only an + * intent: on mount it is rechecked and discarded if the local service is gone + * or does not identify itself as the OfferU Python runtime. + */ +export function useLocalRuntime() { + const state = useSyncExternalStore( + subscribeLocalRuntime, + getLocalRuntimeSnapshot, + getServerRuntimeSnapshot, + ); + + useEffect(() => { + if (SHOWCASE && !state.connected && !state.probing && hasRememberedConnection()) { + void selectLocalRuntime(); + } + }, [state.connected, state.probing, state.selected]); + + return { + ...state, + connect: selectLocalRuntime, + disconnect: selectDemoRuntime, + recheck: selectLocalRuntime, + }; +}