Skip to content

Commit e60c473

Browse files
committed
feat(browser-act): API-key credentials + packs endpoint + frontend surfacing (PR-E)
Credentials (decision #7): BrowserActChannel resolves a stored key via the existing encrypted AuthManager.resolve(source_id) path (key_name "browser_act_api_key") and injects it into the browser-act subprocess env (BROWSER_ACT_API_KEY). stealth mode without a resolvable key is a hard, loud error — never a silent fallback to chrome-direct, never keyless. The key never enters ChannelResult.error/.metadata/logs. Threading source_id required a narrow fetch() override: AbstractChannel's default fetch() bridge drops ctx.source_id, so without this the stealth key would be silently unresolvable in production runs. collect() gains an additive optional source_id (direct calls stay best-effort). Documented trade-off: the override makes channel_runner build an unused httpx client (no socket opened). Endpoint: GET /api/v1/browser-act/packs (read-only PackCatalog listing with per-pack has_manifest + param_schema; invalid manifest -> has_manifest=false, never 500; no credential ever in scope). Frontend (decision #9, minimal): channel_type union + BrowserActPack type + "BrowserAct 采集" label + listBrowserActPacks/useBrowserActPacks hooks (the frontend has no source-creation form yet, so the hooks are wired but unconsumed, matching the existing createSource status). tsc --noEmit + eslint clean. 12 tests (encrypt round-trip + env injection, stealth-missing-key error, chrome-direct keyless ok, no-leak, fetch() source_id path, packs endpoint). 1492 -> 1504 passed, zero regression (full suite verified). Completes GOAL-7.
1 parent 54a61c0 commit e60c473

9 files changed

Lines changed: 553 additions & 9 deletions

File tree

backend/api/v1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from backend.api.v1 import (
66
agents,
7+
browser_act,
78
browsers,
89
chat,
910
control,
@@ -31,6 +32,7 @@
3132
v1_router = APIRouter(prefix="/api/v1")
3233

3334
v1_router.include_router(agents.router)
35+
v1_router.include_router(browser_act.router)
3436
v1_router.include_router(browsers.router)
3537
v1_router.include_router(chat.router)
3638
v1_router.include_router(control.router)

backend/api/v1/browser_act.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""GET /api/v1/browser-act/packs — read-only catalog listing for the
2+
BrowserAct channel's frontend surfacing (GOAL-7 PR-E, decision #9).
3+
4+
Reuses ``PackCatalog`` (PR-A) + ``load_manifest`` (PR-A schema / PR-D seeds)
5+
— no new catalog mechanism, no DB table. Read-only, no auth (matches the
6+
admin-tool style of the other v1 routers, e.g. providers.py/presets.py).
7+
8+
Never exposes anything from ``SourceCredential``/``AuthManager``: this is a
9+
static vendored-file catalog listing (name/description/category/domain/
10+
capability/path/has_manifest/param_schema) — there is no per-source config
11+
or credential in scope here at all, so there is nothing to leak.
12+
"""
13+
14+
import logging
15+
from json import JSONDecodeError
16+
from typing import Any
17+
18+
from fastapi import APIRouter
19+
from pydantic import BaseModel, ValidationError
20+
21+
from backend.browser_act_packs.catalog import PackCatalog
22+
from backend.browser_act_packs.manifest import load_manifest
23+
from backend.schemas.common import ApiResponse
24+
25+
logger = logging.getLogger(__name__)
26+
27+
router = APIRouter(prefix="/browser-act", tags=["browser-act"])
28+
29+
30+
class BrowserActPackRead(BaseModel):
31+
"""One catalogued pack + its manifest status, for the sources UI's
32+
preset dropdown (decision #9). ``param_schema`` is the manifest's own
33+
``param_schema`` (``ParamSpec``-shaped dicts) when ``has_manifest`` is
34+
True, else empty — a missing/invalid manifest is a valid catalog entry,
35+
never a 500 (mirrors ``BrowserActChannel._load_pack_manifest``'s
36+
guard-don't-crash contract)."""
37+
38+
name: str
39+
description: str = ""
40+
category: str
41+
domain: str
42+
capability: str
43+
path: str
44+
has_manifest: bool
45+
param_schema: list[dict[str, Any]] = []
46+
47+
48+
@router.get("/packs", response_model=ApiResponse[list[BrowserActPackRead]])
49+
async def list_packs() -> ApiResponse:
50+
"""List every vendored browser-act pack the catalog can see, each
51+
annotated with whether it has a machine-readable ``channel.manifest.json``
52+
yet (PR-D seeded 2 of the ~78 vendored packs; the rest are catalog-only
53+
until a future manifest is authored for them)."""
54+
catalog = PackCatalog()
55+
packs: list[BrowserActPackRead] = []
56+
for info in catalog.list_packs():
57+
manifest_path = catalog.root / info.path / "channel.manifest.json"
58+
has_manifest = manifest_path.exists()
59+
param_schema: list[dict[str, Any]] = []
60+
if has_manifest:
61+
try:
62+
manifest = load_manifest(manifest_path)
63+
param_schema = [p.model_dump() for p in manifest.param_schema]
64+
except (JSONDecodeError, ValidationError) as exc:
65+
logger.warning(
66+
"browser-act packs endpoint: pack %s has an invalid "
67+
"channel.manifest.json, reporting has_manifest=false: %s",
68+
info.path,
69+
exc,
70+
)
71+
has_manifest = False
72+
packs.append(
73+
BrowserActPackRead(
74+
name=info.name,
75+
description=info.description,
76+
category=info.category,
77+
domain=info.domain,
78+
capability=info.capability,
79+
path=info.path,
80+
has_manifest=has_manifest,
81+
param_schema=param_schema,
82+
)
83+
)
84+
return ApiResponse.ok(packs)

backend/channels/browser_act_channel.py

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""BrowserActChannel — the generic browser-act pack manifest interpreter
2-
(GOAL-7 PR-C; see GOAL-7.md architecture decisions #1, #4, #5, #6, #8, #10).
2+
(GOAL-7 PR-C/PR-E; see GOAL-7.md architecture decisions #1, #4, #5, #6, #7,
3+
#8, #10).
34
45
Drives a vendored pack (backend/browser_act_packs/<category>/<pack>/,
56
PR-A) through its channel.manifest.json (PR-A schema, backend/
@@ -29,6 +30,16 @@
2930
(``"result_count < N"`` / ``"<="``). Anything else falls back to a fixed
3031
``max_pages`` cap and an "a page returned 0 items" stop signal -- documented
3132
limitation, not silently pretended to be complete (see ``_stop_when_triggered``).
33+
34+
Credentials (PR-E, decision #7): ``mode == "stealth"`` requires a BrowserAct
35+
API key, stored encrypted as a ``SourceCredential`` (key_name
36+
``CREDENTIAL_KEY_NAME``) and resolved via the existing ``AuthManager``
37+
(same encrypted store every other channel's credentials go through --
38+
``_resolve_session_env``). It is injected into the browser-act CLI
39+
subprocess env (never argv, never logged) under ``BROWSER_ACT_API_KEY_ENV``.
40+
``collect()`` takes an additive optional ``source_id`` param for this; only
41+
``fetch()`` (the thick-contract entry point ``run_channel`` calls) ever
42+
passes a real one in production -- see both methods' docstrings.
3243
"""
3344

3445
import json
@@ -43,7 +54,14 @@
4354
from backend.browser_act.scripts import ScriptError, run_pack_script
4455
from backend.browser_act_packs.catalog import PackCatalog, PackInfo
4556
from backend.browser_act_packs.manifest import PackManifest, load_manifest
46-
from backend.channels.base import AbstractChannel, Capabilities, ChannelResult
57+
from backend.channels.base import (
58+
AbstractChannel,
59+
Capabilities,
60+
ChannelFetchError,
61+
ChannelResult,
62+
FetchContext,
63+
FetchResult,
64+
)
4765
from backend.channels.registry import register_channel
4866

4967
logger = logging.getLogger(__name__)
@@ -80,6 +98,26 @@
8098
#: taobao-keyword-search's "result count < 10") is understood.
8199
_STOP_WHEN_RE = re.compile(r"result_count\s*(<=|<)\s*(\d+)")
82100

101+
#: SourceCredential key_name the BrowserAct API key is stored under (decision
102+
#: #7). Resolved via the existing AuthManager.resolve(source_id) pattern --
103+
#: same encrypted store api_channel.py / crawl4ai_channel.py already use, no
104+
#: new credential path.
105+
CREDENTIAL_KEY_NAME = "browser_act_api_key"
106+
107+
#: Env var the resolved key is injected under for the browser-act CLI
108+
#: subprocess (BrowserActSession's ``env``, see backend.browser_act.cli).
109+
#: The upstream CLI's own docs (browser-act-skills/docs/installation.md)
110+
#: only document a persistent local auth store -- ``browser-act auth set
111+
#: <key>`` / ``auth login`` / ``auth poll`` -- and never mention an env var
112+
#: for feeding a key non-interactively; there is no live browser-act binary
113+
#: in this environment to confirm one against. This name is this PR's
114+
#: documented assumption (most likely candidate), chosen to match the
115+
#: existing BROWSER_ACT_BIN / browser_act_timeout naming convention already
116+
#: in this codebase -- and PR-C's own test (test_secret_not_leaked_in_error)
117+
#: and BrowserActError's docstring ("PR-C injects secrets ... via env")
118+
#: already anticipated exactly this name.
119+
BROWSER_ACT_API_KEY_ENV = "BROWSER_ACT_API_KEY"
120+
83121

84122
def _classify_error(message: str) -> str:
85123
"""Classify a pack script's reported error message.
@@ -192,6 +230,49 @@ def _load_pack_manifest(
192230
)
193231
return pack_dir, manifest, None
194232

233+
@staticmethod
234+
async def _resolve_session_env(
235+
source_id: str | None, mode: str
236+
) -> tuple[dict[str, str] | None, str | None]:
237+
"""Resolve the stored BrowserAct API key (decision #7,
238+
``CREDENTIAL_KEY_NAME``) into subprocess env for the browser-act CLI
239+
hop, via ``AuthManager.resolve(source_id)`` -- the same encrypted-
240+
store pattern ``ApiChannel._resolve_auth_headers`` /
241+
``Crawl4AIChannel._resolve_cookies`` already use; no new credential
242+
path.
243+
244+
Returns ``(session_env, error)``:
245+
246+
- ``error`` is set only when ``mode == "stealth"`` and no key could
247+
be resolved -- stealth REQUIRES a key and must never silently fall
248+
back to chrome-direct or run keyless (decision #7 DoD).
249+
- ``chrome-direct`` never errors here: a resolved key, if any, is
250+
still injected (harmless -- browser-act simply doesn't need it),
251+
but its absence is fine.
252+
253+
``source_id`` is ``None`` when ``collect()`` is called directly
254+
(bypassing the runner). That is treated identically to "no key
255+
stored" -- best-effort resolution, not a fabricated source id (see
256+
``collect()``'s docstring and ``fetch()`` below, the only path that
257+
ever has a real one in production).
258+
"""
259+
key: str | None = None
260+
if source_id:
261+
from backend.auth.manager import AuthManager
262+
263+
creds = await AuthManager().resolve(source_id)
264+
key = creds.get(CREDENTIAL_KEY_NAME)
265+
266+
if mode == "stealth" and not key:
267+
return None, (
268+
"stealth mode requires a BrowserAct API key "
269+
f"(store it as {CREDENTIAL_KEY_NAME})"
270+
)
271+
272+
if key:
273+
return {BROWSER_ACT_API_KEY_ENV: key}, None
274+
return None, None
275+
195276
async def validate_config(self, config: dict[str, Any]) -> list[str]:
196277
"""Validate channel_config; only checks what config alone can prove:
197278
pack resolvability, mode legality, and required params NOT
@@ -226,8 +307,20 @@ async def validate_config(self, config: dict[str, Any]) -> list[str]:
226307
# ── collect() ────────────────────────────────────────────────────────
227308

228309
async def collect(
229-
self, config: dict[str, Any], parameters: dict[str, Any]
310+
self,
311+
config: dict[str, Any],
312+
parameters: dict[str, Any],
313+
source_id: str | None = None,
230314
) -> ChannelResult:
315+
"""``source_id`` is an additive optional param beyond the
316+
``AbstractChannel.collect(config, parameters)`` contract (legal --
317+
ABC only requires the method exist, not an exact signature): a
318+
direct call (tests, or any future non-source-scoped caller) omits it
319+
and gets best-effort credential resolution (chrome-direct works
320+
keyless; stealth still requires a key, see ``_resolve_session_env``).
321+
``fetch()`` below is this channel's only production path that ever
322+
has a real value to pass -- ``run_channel`` populates
323+
``FetchContext.source_id`` from the real ``DataSource.id``."""
231324
pack_info, pack_error = self._resolve_pack_info(config)
232325
if pack_error:
233326
return ChannelResult(success=False, error_type="error", error=pack_error)
@@ -264,10 +357,14 @@ async def collect(
264357

265358
max_pages = config.get("max_pages") or _DEFAULT_MAX_PAGES
266359

267-
# Credential injection is PR-E's job (decision #7: SourceCredential /
268-
# AuthManager, key_name="browser_act_api_key"). This hook exists so
269-
# PR-E only has to fill session_env -- the loop below doesn't change.
270-
session_env: dict[str, str] | None = None
360+
# Credential injection (decision #7): resolve the stored BrowserAct
361+
# API key via the existing AuthManager.resolve(source_id) pattern and
362+
# inject it into the browser-act subprocess env. stealth mode without
363+
# a resolvable key is a hard, loud error here -- it never silently
364+
# falls back to chrome-direct and never runs keyless.
365+
session_env, cred_error = await self._resolve_session_env(source_id, mode)
366+
if cred_error:
367+
return ChannelResult(success=False, error_type="error", error=cred_error)
271368
session_name = f"browser-act-{pack_info.domain}-{pack_info.capability}"
272369

273370
items: list[dict[str, Any]] = []
@@ -354,6 +451,37 @@ async def collect(
354451
metadata={"pack": pack_info.path, "pages_fetched": pages_fetched, "mode": mode},
355452
)
356453

454+
async def fetch(self, ctx: FetchContext) -> FetchResult:
455+
"""Thick-contract entry point, overridden narrowly (decision #7):
456+
``AbstractChannel``'s default ``fetch()`` bridges straight to
457+
``collect(ctx.config, ctx.params)`` and drops ``ctx.source_id`` on
458+
the floor -- every other collect()-only channel (including this
459+
one before PR-E) is fine with that. Credential resolution needs the
460+
real ``DataSource.id`` though, and ``run_channel`` (backend.pipeline.
461+
channel_runner) is the only place that ever has one -- so this
462+
override exists solely to thread ``ctx.source_id`` through to
463+
``collect()``. A direct ``collect()`` call (tests, or any future
464+
caller outside the runner) still works, just chrome-direct-only /
465+
best-effort on stealth (see ``_resolve_session_env``).
466+
467+
Documented trade-off: overriding ``fetch()`` flips
468+
``channel_runner.run_channel``'s ``channel_migrated`` check, so the
469+
runner builds a ``RateLimitedClient``/``httpx.AsyncClient`` for this
470+
channel's run and tears it down afterward even though this channel
471+
never reads ``ctx.http`` (browser-act drives its own subprocess, not
472+
HTTP). Accepted deliberately: ``httpx.AsyncClient()`` never opens a
473+
real connection until first used, so the unused cost here is one
474+
Python object for the run's duration, not an open socket -- real
475+
credential resolution matters more than avoiding that.
476+
"""
477+
result = await self.collect(ctx.config, ctx.params, source_id=ctx.source_id)
478+
if not result.success:
479+
raise ChannelFetchError(
480+
result.error or f"{self.channel_type} collect failed",
481+
error_type=result.error_type,
482+
)
483+
return FetchResult(items=result.items, metadata=result.metadata)
484+
357485
async def _run_page(
358486
self,
359487
sess: Any,

frontend/app/(app)/sources/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const CHANNEL_LABEL: Record<DataSource['channel_type'], string> = {
3434
cli: 'CLI',
3535
skill: '技能',
3636
crawl4ai: 'Crawl4AI',
37+
browser_act: 'BrowserAct 采集',
3738
}
3839

3940
export default function SourcesPage() {

frontend/lib/api/endpoints.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
AdvisoryReport,
55
ApiResponse,
66
ModelProvider,
7+
BrowserActPack,
78
BrowserBinding,
89
ChromeEndpoint,
910
CollectedRecord,
@@ -380,6 +381,12 @@ export const updateChromeEndpointMode = (endpoint: string, mode: 'bridge' | 'cdp
380381
export const listPresets = () =>
381382
apiClient.get<ApiResponse<PresetsGrouped>>('/presets').then((r) => r.data.data)
382383

384+
// ── BrowserAct packs (GOAL-7 PR-E, decision #9) ─────────────────────────────────
385+
// Read-only vendored-pack catalog for the 'browser_act' channel's one-click
386+
// config preset (pack picker) — never carries a credential/api_key.
387+
export const listBrowserActPacks = () =>
388+
apiClient.get<ApiResponse<BrowserActPack[]>>('/browser-act/packs').then((r) => r.data.data)
389+
383390
// ── Plans (Plan IR issue 02) — Collection Canvas persistence ────────────────────
384391
// Save (create/update) validates server-side and 422s with a node-anchored
385392
// error list (backend.plan_ir.validation.PlanValidationError.to_dict()) on an

frontend/lib/api/hooks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export function usePresets() {
6262
})
6363
}
6464

65+
export function useBrowserActPacks() {
66+
return useQuery({
67+
queryKey: ['browser-act-packs'],
68+
queryFn: () => api.listBrowserActPacks(),
69+
staleTime: 5 * 60_000,
70+
})
71+
}
72+
6573
export function usePlans(params?: { draft?: boolean; page?: number; limit?: number }) {
6674
return useQuery({
6775
queryKey: ['plans', params],

frontend/lib/api/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface DataSource {
4343
id: string
4444
name: string
4545
description?: string
46-
channel_type: 'opencli' | 'web_scraper' | 'api' | 'rss' | 'cli' | 'skill' | 'crawl4ai'
46+
channel_type: 'opencli' | 'web_scraper' | 'api' | 'rss' | 'cli' | 'skill' | 'crawl4ai' | 'browser_act'
4747
channel_config: Record<string, unknown>
4848
ai_config?: Record<string, unknown>
4949
enabled: boolean
@@ -732,3 +732,24 @@ export interface PlanHealthRead {
732732
detail: Record<string, unknown>
733733
recorded_at: string
734734
}
735+
736+
// ── BrowserAct packs (GOAL-7 PR-E, decision #9) ──────────────────────────────
737+
// Mirrors backend.api.v1.browser_act.BrowserActPackRead. Read-only vendored
738+
// pack catalog for the 'browser_act' channel's config preset — never carries
739+
// any credential/api_key (the BrowserAct API key is a SourceCredential,
740+
// configured through the existing credential UI, not through pack config).
741+
export interface BrowserActPack {
742+
name: string
743+
description: string
744+
category: string
745+
domain: string
746+
capability: string
747+
path: string
748+
has_manifest: boolean
749+
param_schema: Array<{
750+
name: string
751+
required: boolean
752+
default?: string | null
753+
enum?: string[] | null
754+
}>
755+
}

0 commit comments

Comments
 (0)