Compare commits

..

12 Commits

Author SHA1 Message Date
Nikhil Sonti
7aa4437a2d fix: remove shared governance exports 2026-05-07 10:42:11 -07:00
Nikhil Sonti
5e74487bb8 fix: address PR review comments for unship governance 2026-05-07 09:42:06 -07:00
Nikhil Sonti
3ca03675c2 chore: unship governance 2026-05-07 09:29:42 -07:00
shivammittal274
6f8da5b7fb refactor(openclaw): TKT-788 cleanup (relanded, openclaw-only) — bump image, lock no-auth, delete observer + image bypass (#954)
* refactor(openclaw): TKT-788 cleanup — bump image, lock no-auth, delete observer + image bypass

Re-lands the openclaw-only changes from #934 (reverted in #953 because the
original PR's working tree had stale rollback content for
`packages/browseros/tools/patch/`). This commit is the same openclaw
diff with zero changes outside `packages/browseros-agent/`.

What changes (TKT-788 work-streams A + B + C):

WS-A — bundled gateway no-auth:
- Bump image from `ghcr.io/openclaw/openclaw:2026.4.12` to
  `ghcr.io/browseros-ai/openclaw:2026.5.2-browseros.1` (BrowserOS-
  pinned variant with the no-auth contract baked in).
- Configure gateway with `auth.mode: 'none'`; remove the device-auth
  bootstrap dance that the older binary required.
- Delete the per-call token plumbing the http-client / observer / chat-
  client carried (340 LOC). The harness still passes a stable token in
  headers for backwards-compat with code that hasn't been re-pointed yet,
  but it is no longer required by the gateway.

WS-C — delete the image-attachment bypass:
- The HTTP `/v1/chat/completions` carve-out for OpenClaw image turns
  is gone. Image attachments now ride through ACP as image content
  blocks (which acpx 0.6.x supports natively for openclaw, claude, codex).
- Delete `openclaw-gateway-chat-client.ts` (211 LOC) and `image-turn.ts`
  (219 LOC).
- Drop `maybeHandleTurn` from the `AcpxAgentAdapter` interface and
  the openclaw entry. `AcpxAdapterTurnInput` removed.
- Drop the corresponding 'diverts OpenClaw image turns to the gateway
  chat client' test from `acpx-runtime.test.ts`.

WS-B — replace the WS observer with harness events:
- Delete `openclaw-observer.ts` (276 LOC) — no more parallel WS
  subscription, no more `new OpenClawObserver`, no more
  `ensureObserverConnected` / `observer.disconnect()` plumbing.
- Wire `AgentHarnessService` to receive turn-lifecycle events from
  the runtime stream itself (`turnLifecycleListeners`) and feed
  ClawSession from those, preserving the dashboard SSE shape.

Net: 314 insertions / 1144 deletions, all under
`packages/browseros-agent/`. Typecheck clean across all 6 packages.
946 server tests pass (1 unrelated CDP-dependent test skipped — same
state as origin/dev).

Reference: TKT-788. The patch-CLI rollback that was in the squash of
#934 is intentionally NOT in this commit.

* fix(openclaw): handle 2026.5.4 acp-cli envelope shapes (media + injected timestamp) + bump image

OpenClaw 2026.5.4 (the BrowserOS-pinned image variant with the no-auth
handshake bypass needed for cron tool calls from inside ACP) introduced
two new envelope prefix shapes that the post-bypass-deletion path now
surfaces in user-message text:

  [media attached: <internal-path> (<mime>)]
  [<weekday> <YYYY-MM-DD HH:MM> <TZ>] [Working directory: <path>]
  <BrowserOS role envelope>

The previous cleaner only matched a leading [Working directory: ...]
\n\n line. With media + timestamp prefixes ahead of it the anchor
no longer matched, so image-attachment user turns rendered with
8+ lines of envelope leak in the chat panel.

Replaces the single OPENCLAW_WORKDIR_PREFIX with three content-shape-
anchored patterns chained through stripOpenClawAcpCliEnvelope():

  1. [media attached: <path> (<mime>)]      ← repeats per attachment
  2. [<weekday> <YYYY-MM-DD HH:MM> <TZ>]    ← injectTimestamp
  3. [Working directory: <path>]            ← acp-cli prefixCwd

Each is anchored on its content shape (media attached:, weekday
abbrev + ISO date, Working directory:) rather than just '[…]', so
user-typed lines that happen to start with brackets are not eaten.

Also bumps OPENCLAW_IMAGE from 2026.5.2-browseros.1 to
2026.5.4-browseros.1. The 5.2 image refused tool-side WS connections
with 'device identity required' even though gateway auth.mode=none —
PR #6 in browseros-ai/openclaw added the OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH
bypass that ships in 5.4. Without 5.4, the cron tool (and any other
tool that opens a fresh gateway WS from inside the embedded runner)
fails with 1008.

Verified end-to-end with the BrowserOS chat endpoint:
- Plain text turn: clean
- Image attachment turn: clean (was leaking 8 envelope lines pre-fix)
- One-shot kind:at cron fires, PING fire renders clean
- Second openclaw agent creates, runs, history isolated

15/15 history-mapper unit tests pass; typecheck clean across all
packages.
2026-05-07 02:26:25 +05:30
shivammittal274
50cbe48558 Revert "refactor(openclaw): lock no-auth gateway, bump image, delete token pl…" (#953)
This reverts commit d81b99c8e3.
2026-05-07 01:49:50 +05:30
shivammittal274
d81b99c8e3 refactor(openclaw): lock no-auth gateway, bump image, delete token plumbing (TKT-788 WS-A) (#934)
* fix: disable bundled OpenClaw gateway auth

* refactor(openclaw): delete token plumbing now that auth is locked off

Builds on the cherry-picked spike (#933). With gateway.auth.mode=none
locked in as the only path the bundled gateway runs, the BrowserOS-side
token machinery becomes dead weight. This commit deletes:

- OpenClawService: token field, tokenLoaded, gatewayAuthMode state
  machine, getGatewayToken(), getGatewayHttpToken(),
  ensureTokenLoaded(), refreshGatewayAuthToken(),
  loadTokenFromConfig() and all six lifecycle call sites.
- OpenclawGatewayAccessor.getGatewayToken interface field.
- OpenClawHttpClient / OpenClawGatewayChatClient: optional getToken
  constructor arg and authHeaders() helpers.
- OpenClawObserver: gatewayToken field/parameter and the auth.token
  branch in the connect frame.
- GatewayContainerSpec.gatewayToken and the
  OPENCLAW_GATEWAY_TOKEN env wiring; the
  OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH=1 env is now always set
  rather than conditional.

Test suites: dropped bearer-token assertions and the two persisted-token
tests in openclaw-service that asserted deleted behavior.

Net: -310 LOC across src + tests, with 118 openclaw + acpx tests still
green. Typecheck and biome clean.

Reference: TKT-788 (move OpenClaw integration to ACPX runtime), WS-A.

* refactor(openclaw): delete gateway image bypass, route image turns via ACP (TKT-788 WS-C) (#935)

* refactor(openclaw): delete gateway image bypass, route image turns through ACP

The browseros-ai/openclaw ACP bridge accepts image content blocks
natively (extractAttachmentsFromPrompt at openclaw/src/acp/event-mapper.ts:92,
forwarded via chat.send attachments at translator.ts:295), so the
BrowserOS-side carve-out that diverted image-bearing turns to the
gateway HTTP /v1/chat/completions endpoint is no longer needed.

Deletes:

- apps/server/src/api/services/openclaw/openclaw-gateway-chat-client.ts
- The corresponding test file
- AcpxRuntime.sendOpenclawViaGateway, persistGatewayTurn,
  recordToOpenAIMessages helpers
- The image-attachment carve-out branch in AcpxRuntime.send
- openclawGatewayChat option from AcpxRuntime + AgentHarnessService
  + agent routes ctor wiring
- The randomUUID import (only the deleted helper used it)
- The acpx-runtime test for the deleted carve-out

Net: 614 LOC removed, 0 added, all 142 openclaw + acpx + agent tests
still green.

Reference: TKT-788, WS-C. Stacked on WS-A (#934).

* refactor(openclaw): delete WS observer, feed ClawSession from harness events (#936)

The openclaw-observer.ts WebSocket observer was a second tap on the
same gateway events the AcpxRuntime already sees as ACP session/update
notifications. Replace it with a pull from the AgentHarnessService's
turn lifecycle stream — keeping ClawSession and the /openclaw/dashboard
SSE endpoint shape unchanged for the BrowserOS UI.

Changes:

- AgentHarnessService: emit `turn_started` / `turn_event` / `turn_ended`
  to subscribers via a new `onTurnLifecycle(listener)` API. Wired around
  the existing `notifyTurnStarted/Ended` calls and inside the
  per-event read loop.
- agents route: forward an optional `onTurnLifecycle` dep into the
  service it constructs.
- server.ts: subscribe and route OpenClaw-adapter events to
  `OpenClawService.recordAgentTurnEvent(agentId, sessionKey, event)`.
- OpenClawService: new `recordAgentTurnEvent` method that maps stream
  events to ClawSession transitions (working/idle/error + currentTool
  from `tool_call` events). Keeps the existing
  `onAgentStatusChange` / `getAgentState` / `getDashboard` API.
- Delete `openclaw-observer.ts` (276 LOC) and all observer wiring
  (`new OpenClawObserver`, `ensureObserverConnected`, three
  `observer.disconnect()` call sites, the import).

Net: 276 LOC removed from the observer; ~130 LOC added across harness
event plumbing + recorder method. -146 LOC overall, all 141 tests still
green, typecheck clean, biome clean.

Reference: TKT-788, WS-B (Path 1: keep ClawSession + dashboard SSE shape).
Independent of WS-A (#934) and WS-C (#935); will rebase on top of
whichever lands first.

---------

Co-authored-by: Nikhil Sonti <nikhilsv92@gmail.com>
2026-05-07 01:40:37 +05:30
shivammittal274
86cb03a1fc fix(openclaw): drop BrowserOS-envelope regexes in history mapper (#952)
* fix(openclaw): drop BrowserOS-envelope regexes in history mapper

Replace the four BrowserOS-side regex strips (`<role>`,
`<user_request>`, `<system-reminder>`, `[Working directory:]`)
in history-mapper with a single call to
`unwrapBrowserosAcpUserMessage`. That helper is the same exact-string
matcher acpx-runtime already uses for non-OpenClaw history paths
(chat history endpoint, listing's `lastUserMessage`); it anchors on
the exact constants `buildBrowserosAcpPrompt` writes, so matcher and
wrapper travel together.

Also drops two patterns that were defensive-only with no emit site in
the codebase (`[Working directory:]` prefix and trailing
`<system-reminder>` block), and updates the corresponding tests to
use the realistic envelope shape `buildBrowserosAcpPrompt` actually
produces.

The OpenClaw-injected scaffolding patterns (cron prefix, queued-
marker, subagent context) stay in place for now — replacing those
needs either a side-channel cache keyed on cron job id or a structured
`trigger` field on the gateway's history schema, tracked as a
follow-up.

* fix(openclaw): strip acp-cli's [Working directory:] prefix before BrowserOS unwrap

The previous commit incorrectly removed the workdir-prefix strip on the
assumption it was speculative defensive code with no live emit site.
Actually emitted by OpenClaw's acp-cli (`/app/dist/acp-cli-*.js` line
1361, `prefixCwd ? \`[Working directory: ${displayCwd}]\\n\\n...` style),
so live history rendering regressed: every user message surfaced with
a `[Working directory: /Users/...]\\n\\n<role>...` envelope intact.

Restore the strip as an exact-shape line match (`^\\[Working directory:
[^\\]]*\\]\\n\\n`) anchored on the closing bracket + double-newline so
path content is consumed without a content-shape regex. Apply it
ahead of `unwrapBrowserosAcpUserMessage` so the BrowserOS unwrap's
`^<role>` anchor can match the now-leading envelope.

Also fix the test fixture: the BrowserOS unwrap performs exact-prefix
match against the full `BROWSEROS_ACP_AGENT_INSTRUCTIONS` constant —
truncated `<role>...` test bodies didn't match. Tests now use the
verbatim constant text via a shared `ROLE_BLOCK` helper.

Verified live: 8/8 history entries render with no envelope leaks.
2026-05-06 23:54:09 +05:30
shivammittal274
7765d99c73 feat(openclaw): aggregate sub-session history into agent main session (#939)
* feat(openclaw): aggregate sub-session history into agent's main session

Cron-triggered (and hook/channel-triggered) runs land in their own
ephemeral session files under the parent agent's directory:

  /home/node/.openclaw/agents/<agentId>/sessions/<runId>.jsonl

The chat panel reads agent:<id>:main, so autonomous runs were invisible
in history even though they fired and persisted on disk.

This change makes `getSessionHistory(agent:<id>:main)` enumerate every
session under that agent (via the existing `sessions.list` gateway RPC)
and merge their messages into one chronological response. Each merged
message is tagged with `source` (main / cron / hook / channel) and the
sub-session's key, so the UI can render section markers without
re-parsing.

Filesystem isolation is enforced upstream — `sessions.list({ agentId })`
resolves to that agent's directory only (browseros-ai/openclaw
src/config/sessions/combined-store-gateway.ts:90), so no cross-agent
leakage is possible.

Behavior:
- Main session keys (`^agent:[^:]+:main$`) → aggregate
- Any other key → existing single-session behavior
- Sub-session fetch failures → logged + dropped (partial timeline
  preferable to a hard failure that hides main)
- `limit` applied post-merge across the unified timeline
- Streaming variant (`Accept: text/event-stream`) unchanged for now

Reuses the pre-existing `cliClient.listSessions` and
`httpClient.getSessionHistory` — no new gateway integration.

Validation:
- bun typecheck clean
- bunx biome check clean
- 44 openclaw service + route tests pass

* feat(openclaw): wire chat panel history through gateway aggregation

Adds the missing seam between the chat panel's history fetch and
OpenClawService's aggregated history.

Before this change:
- Chat panel calls GET /agents/<id>/sessions/main/history
- AgentHarnessService.getHistory delegates to AcpxRuntime.getHistory
- AcpxRuntime reads ~/.browseros-dev/agents/acpx/sessions/<key>.json
- That local file is only written by AcpxRuntime.send (user turns)
- Cron / hook / channel turns persist on the gateway side instead
- Panel sees user turns only; autonomous turns are invisible

After this change:
- OpenClawProvisioner gains optional getAgentHistory(agentId) method
- AgentHarnessService.getHistory branches on adapter — for openclaw,
  routes through the provisioner instead of the runtime
- server.ts wires the provisioner method to call
  OpenClawService.getSessionHistory("agent:<id>:main") which already
  aggregates main + every sub-session
- New history-mapper.ts converts OpenClaw rich content blocks
  (text/thinking/toolCall/toolResult) into AgentHistoryEntry shape
  the chat panel consumes

Layering preserved:
- AcpxRuntime untouched, still generic, zero services/openclaw imports
- AgentHarnessService still talks only to abstract OpenClawProvisioner
- server.ts is the single concrete-binding seam (same place that
  wires createAgent, removeAgent, getStatus)
- Other adapters (claude, codex) keep their existing local-file
  history path — no behavior change for them

Tool-call pairing: assistant `toolCall` blocks are stored by
toolCallId; subsequent `toolResult` (role: 'tool') messages mutate the
same AgentHistoryToolCall reference to attach output / error, so the
UI renders complete tool entries instead of orphan inputs.

Net: +240 LOC, 1 new file, AcpxRuntime untouched, 117 tests still pass.

* feat(openclaw): paginate aggregated history + strip prompt scaffolding

Two follow-ups on the aggregation work, both required for the chat
panel to render OpenClaw history cleanly.

1. Compound-cursor pagination across sub-sessions

The previous aggregation always returned the full merged window with
cursor=null/hasMore=false, which broke "load more" in the chat panel
once an agent's history grew beyond a single page (every cron job
spawns a sub-session, so this hits quickly).

Per-session cursor support already exists on the gateway HTTP endpoint
(`session-history-state.ts:paginateSessionMessages`). The aggregator
now threads each session's cursor through and emits a compound cursor
encoding `{<sessionKey>: messageSeq | null}`, base64url JSON. A `null`
slot means the session is exhausted; subsequent pages skip it.

The gateway records the per-session monotonic seq inside the
`__openclaw.seq` extension envelope rather than the top-level
`messageSeq` field; the cursor reads from there. The wire-shape type
gains an optional `__openclaw?: { id?, seq? }` field reflecting that.

2. Strip OpenClaw + BrowserOS scaffolding from history user messages

Cron-fired user messages on the gateway side carry an OpenClaw
template:

  [cron:<uuid> <name>] <payload>
  Current time: ...
  Use the message tool if you need to notify the user directly with an
  explicit target. ...

BrowserOS-initiated turns carry the ACP system prefix:

  [Working directory: ...]
  <role>...</role>
  <user_request>
  <actual user text>
  </user_request>
  <system-reminder>...</system-reminder>

Both surface verbatim in the chat panel today. Add
`cleanHistoryUserText` (in history-mapper) which extracts:
- the cron payload (and drops the trailer)
- the user_request body (and drops the role / working-dir / system-
  reminder envelopes)

Non-matching text falls through unchanged so future patterns we don't
recognize stay visible rather than getting silently dropped.

Verified end-to-end:
- /agents history endpoint now returns clean text per item
- Pagination cursor advances across pages with correct seq ordering
- Chat panel renders messages as `print('hello')`, `hey`, etc.
  (no leaked envelopes or trailers)
- 8 new unit tests for cleanHistoryUserText + the converter, +
  86 existing openclaw tests still pass

* feat(openclaw): handle queued-marker concatenation in history cleaner

When multiple cron prompts (or any prompts) arrive while a turn is
still active, BrowserOS's harness queue concatenates them into a
single user message joined by a marker line:

  [Queued user message that arrived while the previous turn was still active]

That blob renders as one wall of text in the chat panel — and worse,
the cron-prompt cleaner doesn't fire because the message no longer
*starts* with `[cron:...]`. cleanHistoryUserText now splits on the
queued-marker line and runs each chunk through the per-message cleaner
(cron-prompt extraction or BrowserOS-prefix unwrap), then joins the
non-empty results with single newlines so each prompt renders as its
own visually distinct line.

Verified live: a 6926-char queued blob containing five concatenated
[cron:...] prompts now renders as five short `print('hello')` lines.

+ 2 unit tests covering split + leading-marker edge case.

* feat(openclaw): drop subagent context + reasoning-only assistant turns

Two new patterns surfaced during e2e cron testing.

1. [Subagent Context] prefix: when an OpenClaw agent invokes a nested
   subagent, the subagent's session is seeded with a user message:

     [Subagent Context] You are running as a subagent (depth N/M). ...
     Begin. Your assigned task is in the system prompt under **Your Role**.

   The actual task lives in the subagent's system prompt; the user
   message body is pure scaffolding. cleanHistoryUserText now returns
   empty for these so the converter drops the entry — no empty bubble.

2. Reasoning-only assistant turns: MiniMax with thinking:minimal often
   returns content with only `thinking` blocks and no `text` block on
   trivial prompts ("Print hello"). The empty text bubble plus dangling
   reasoning collapsible reads as a broken UI. The converter now skips
   any entry where text is empty AND there are no tool calls (regardless
   of reasoning).

Trade-off: reasoning-only turns lose their reasoning collapsible. The
alternative (empty-bubble cards) is worse. If we want to preserve the
reasoning, surface it as the bubble's text — separate UI decision for
later.

+ 3 unit tests covering both patterns.
2026-05-06 00:15:57 +05:30
Dani Akash
db5e55a174 feat(agent-files): expose openclaw produced files inline + outputs rail (#946)
* feat(server): foundation for OpenClaw agent file-output attribution

Phase 1 of TKT-762 — surface files OpenClaw agents produce as
artifacts inline in chat + a per-agent Outputs rail. This commit
lays the storage + I/O foundation only; turn-lifecycle wiring,
HTTP routes, and UI follow in subsequent phases.

- New `produced_files` Drizzle table (FK→agent_definitions with
  cascade, unique on (agent, path) so re-modifications upsert).
  Migration 0002_chemical_whirlwind.sql. Adapter-agnostic schema
  — V1 only enables the watcher for openclaw, V2 can plug Claude
  / Codex into the same table without migrating.
- `ProducedFilesStore` — snapshot/finalize-turn diff API plus
  by-turn / by-agent queries and a path-resolver that enforces
  workspace-root containment for the download / preview routes.
- `walkWorkspace` — bounded recursive workspace walker; skips
  symlinks (no host-fs smuggling), excludes node_modules / .git /
  .cache, hard-capped at 50k entries / depth 16.
- `file-preview` helper — extension + magic-byte MIME detection,
  bounded text-snippet reader (1 MB cap), inline image base64
  reader (4 MB cap). Streaming download path lives in the route
  layer (next phase) — this module only handles the small
  in-memory reads the preview UX needs.

* feat(server): attribute openclaw turn outputs to the harness layer

Phase 2 of TKT-762 — wire the per-turn workspace diff into the
single dispatch path that owns every turn's lifecycle. Two prior
wiring points the original plan named (the OpenClaw HTTP chat
route + OutboundQueueService.tryDispatch) were collapsed in dev
into agent-harness-service.runDetachedTurn — both direct sends
and queued sends route through it now, so a single hook covers
both. The old `OutboundQueueService` is gone; its successor
`message-queue.ts` re-enters runDetachedTurn for the queued
case, so we still only need to bracket once.

Changes:

- New `produced_files` variant on `AgentStreamEvent` so the
  inline artifact card has a wire-format hook independent of the
  REST API.
- `ProducedFilesStore` gains `resolveAgentDefinitionId` to bridge
  gateway-side openclaw agent names to the harness's
  `agent_definitions.id`, handling both the reconciled-row shape
  (id == openclaw name) and the BrowserOS-created shape
  (id = oc-<uuid>, name = openclaw display name).
- `AgentHarnessService.runDetachedTurn`: snapshot the openclaw
  workspace before `runtime.send(...)`, finalize the diff in the
  outer finally, push the resulting rows as a `produced_files`
  event. Adapter-gated to openclaw only — Claude / Codex agents
  write to the user's own filesystem and don't need
  attribution.
- Skip attribution on user-cancel (`abort.signal.aborted`) so
  the side effects of an aborted turn don't get surfaced as
  "outputs you asked for." On runtime errors we still attribute,
  because partial outputs are what the user is most likely to
  want to recover.
- Lazy-init the store via `tryGetProducedFilesStore()` so tests
  that swap in a fake `agentStore` don't trip the
  process-wide `getDb()` initialisation guard.
- File attribution extracted into `attributeTurnFiles` helper to
  keep `runDetachedTurn`'s cognitive complexity under the lint
  ceiling.

Verifications:
- Server tsgo --noEmit clean for changed files.
- 162/162 server-api tests pass.
- Biome lint clean on all three changed files.

* feat(server): expose produced-files HTTP API for /agents

Phase 3 of TKT-762 — surface the rows Phase 2 attributes via four
read-only endpoints under the existing `/agents` router. Mounted
where the agents page already polls so the rail UI doesn't add
a second router/origin to its trust boundary.

Routes:

- GET /agents/:agentId/files
    Outputs-rail data, grouped by the assistant turn that
    produced each batch, newest first. `?limit=` clamps to N
    rows server-side (default 200).

- GET /agents/:agentId/files/turn/:turnId
    Per-turn refresh — used by the inline-card consumer to
    rebuild metadata after the SSE `produced_files` event lands,
    and by direct fetches that missed the live event.

- GET /agents/files/:fileId/preview
    Discriminated `FilePreview` JSON: text snippet (≤1MB),
    base64 image (≤4MB), pdf metadata, or `binary` placeholder
    when neither preview path applies. 404 when the file id is
    unknown OR the on-disk file disappeared after attribution.

- GET /agents/files/:fileId/download
    Streams raw bytes via `Bun.file().stream()` with
    `Content-Disposition: attachment` and the detected MIME
    type. The fileId is opaque — the server resolves the agent
    and on-disk path; the client never sees a path, so traversal
    is impossible by construction.

Service layer:

- `AgentHarnessService` gains `listAgentFiles`,
  `listAgentFilesForTurn`, `previewProducedFile`, and
  `resolveProducedFileForDownload`. All four are no-ops for
  claude / codex adapters (they return null/[]) so the route
  contract stays uniform across adapters even though only
  openclaw produces rows in v1.
- New `ProducedFileEntry` and `ProducedFilesRailGroup` DTOs —
  trimmed wire shapes that strip `agentDefinitionId` and
  `sessionKey` from the on-disk row.

Verifications:
- Server tsgo --noEmit clean for changed files (only pre-
  existing `Bun` global warning).
- 162/162 server-api tests pass.
- Biome clean on both changed files.

Smoke-test instructions for the route shape live in the plan
under §6 and §8; full end-to-end smoke happens in Phase 6.

* feat(agent): client-side hooks + types for agent file outputs

Phase 4 of TKT-762 — frontend foundation for the inline artifact
card and the per-agent Outputs rail. UI components themselves
land in Phase 5; this commit only adds types, hooks, and shared
helpers so the wiring is in place when the components arrive.

New module: `apps/agent/lib/agent-files/`

- `types.ts` — `ProducedFile`, `ProducedFilesRailGroup`, and the
  discriminated `FilePreview` union, mirrored from the server-side
  DTOs in `apps/server/src/api/services/agents/agent-harness-service.ts`.
  The `agentDefinitionId` / `sessionKey` columns on the on-disk
  rows deliberately do NOT exist at the type boundary — clients
  refer to files by opaque `id`.
- `file-helpers.ts` — pure helpers: `inferFileKind` (icon
  routing), `formatFileSize`, `extensionOf`, `basenameOf`,
  `buildFileDownloadUrl`. No React, no fetch, no DOM — anything
  stateful belongs in the hooks.
- `useAgentOutputs.ts` — `useAgentOutputs(agentId)` for the rail,
  `useAgentTurnFiles(agentId, turnId)` for the inline card,
  `useInvalidateAgentOutputs()` for the chat-stream-completion
  hook (Phase 5 will plumb this), and `useRefreshAgentOutputs()`
  for the rail's manual refresh button.
- `useFilePreview.ts` — `useFilePreview(fileId)` with
  `staleTime: Infinity` (previews are immutable for a given id;
  no point refetching on focus). Always opt-in (`enabled`) — the
  preview only loads when the user clicks a row.
- `index.ts` — barrel re-export so consumers import from one path.

Touched in `apps/agent/entrypoints/app/agents/`:

- `agent-harness-types.ts` — added `produced_files` variant + the
  `HarnessProducedFile` type to `AgentHarnessStreamEvent`. Mirrors
  the server-side change from Phase 2 so the client SSE consumer
  type-narrows correctly.
- `useAgents.ts` — exported the previously-private `agentsFetch`
  helper and the `AGENT_QUERY_KEYS` registry so the agent-files
  hooks reuse them without duplicating fetch / key conventions.
  Three new keys added: `agentOutputs`, `agentTurnFiles`,
  `filePreview`.

Verifications:
- Agent tsgo --noEmit clean.
- Biome clean on all touched files.

* feat(agent): inline artifact card + per-agent outputs rail

Wires the chat surface to the produced-files API shipped earlier:

- Inline artifact card under each assistant turn that produced files,
  populated by the live `produced_files` SSE event (resumes also stamp
  `turnId` so a missed live event can fall back to the per-turn fetch).
- Collapsible right-side Outputs rail on the agent conversation page,
  grouped by turn, with Refresh + per-agent open/close persistence in
  localStorage. Gated to openclaw adapters in v1.
- Shared file preview Sheet branches on the FilePreview union: text
  snippet (markdown for `.md`/`.mdx`, otherwise pre+code), image data
  URL, and download-only fallback for pdf/binary/missing.
- Conversation hook invalidates the rail's React Query cache from its
  finally block so newly attributed files appear without a manual
  refresh.

* feat(agent-files): polish — symlink-safe paths + toast on failures

- `resolveFilePath` now rejects symlink-escapes from the workspace
  by realpath-resolving both endpoints and re-checking containment.
  Lexical traversal (`..` segments) still fails fast without
  touching the filesystem.
- Added `produced-files-store.test.ts` with 6 path-resolution cases
  including a symlink whose target lives outside the workspace
  root — the prior string-only check would have allowed this.
- File preview Sheet: surfaces preview-load failures in a toast
  (in addition to the inline error block, which is easy to miss
  when the body has scrolled). Download button now intercepts the
  click so a missing baseUrl shows a toast instead of silently
  hiding the button.
- Outputs rail: refresh failures fire `toast.error` with the
  underlying message.

* fix(agent-files): drop duplicate `/agents` prefix from client paths

`agentsFetch` / `buildAgentApiUrl` already prepend `/agents`, but
the file-output hooks were passing fully-qualified paths
(`/agents/<id>/files`, `/agents/files/<id>/preview`, etc.) which
resolved to `/agents/agents/...` and 404'd. Fixed the four call
sites to pass paths relative to the `/agents` root.

* fix(agents): strip openclaw role envelope from chat history

PR #924 introduced a second `<role>…</role>` prefix for openclaw
turns — a single-line block distinct from the multi-line BrowserOS
role TKT-774 wired the unwrap against. Because TKT-774's
`stripOuterRoleEnvelope` matched the BrowserOS prefix exactly, the
openclaw envelope sailed through unstripped and user messages on
openclaw agents rendered the full preamble in /sessions/main/history
responses.

Make the strip adapter-agnostic: any
`<role …>…</role>\n\n<user_request>\n…\n</user_request>` shape gets
unwrapped. Drops the now-unused BROWSEROS_ACP_AGENT_INSTRUCTIONS
constant and adds a regression test that uses the openclaw form
verbatim.

* feat(agent-files): inline file-card strip with rail deep-link

Replaces Phase 5's row-list ArtifactCard with a horizontal strip
of small file cards under any assistant turn that produced files.
Click a card → opens the FilePreviewSheet directly (preview +
download). Click View / +N → opens the per-agent Outputs rail and
scrolls / expands the matching turn group.

The card strip:
- Caps at 4 visible cards; remainder collapses into a +N pill that
  shares the View handler.
- Owns its own FilePreviewSheet instance (parallel to the
  deprecated ArtifactCard) so the per-card preview path doesn't
  fight with the rail's Sheet.
- Hidden during streaming and absent when producedFiles is empty.
- Adapter-gated upstream: AgentCommandConversation only passes the
  open-rail callback when adapter==='openclaw', so claude / codex
  agents render no rail-opening affordance.

Rail changes:
- Accepts focusTurnId + onFocusTurnConsumed; the matching
  RailTurnGroup expands and scrollIntoView's on focus, then fires
  the consumed callback so the parent can drop the URL state.
- ?outputsTurn=<turnId> deep-links work: external nav opens the
  rail, sets focusTurnId, and clears the param after consumption.

ArtifactCard is marked @deprecated; remove in a follow-up once
nothing imports it.

* fix(agent-files): keep file-card strip visible after history reload

After Phase 7 the inline FileCardStrip vanished as soon as a turn
finished: `filterTurnsPersistedInHistory` dropped the optimistic
turn once history reloaded, and history items don't carry
`producedFiles`. So the user could see a file produced inside an
assistant message but no card to open it.

Two fixes in tandem so the strip survives both the just-finished
case AND a fresh page load:

- New `selectStripOnlyTurns` keeps persisted turns that still
  carry `producedFiles`. `ConversationMessage` learns a
  `stripOnly` mode that renders only the trailing strip (no
  duplicate user/assistant bubbles, since those are rendered by
  `ClawChatMessage`).
- `AgentCommandConversation` now also calls `useAgentOutputs` and
  passes `tailStripGroups` to `ClawChat`. Each rail group not
  already covered by a live or strip-only turn renders as its own
  tail `FileCardStrip` after history. Dedup keys on `turnId` so
  the same turn never doubles up.

Adapter-gated upstream — claude / codex agents skip the
useAgentOutputs fetch entirely. The card click still opens the
preview Sheet directly; View / +N still deep-link to the rail at
the matching turn group.

* fix(agent-files): per-turn association + cache invalidation

Two fixes for the inline file-card strip:

1. Strips were stacking at the conversation tail because every
   produced-files group rendered as a tail strip after history.
   New `mapHistoryToProducedFilesGroups` matches each group to
   the assistant history message that came from its turn — by
   `group.turnPrompt` vs the first non-blank line of the
   preceding user message — and ClawChat renders the strip
   directly under that bubble. Groups that don't match any
   history pair (orphans) still fall through to the tail.

2. `useInvalidateAgentOutputs` was passing `undefined` as the
   baseUrl placeholder to `invalidateQueries({ queryKey })` —
   react-query's positional partial-match doesn't treat
   undefined as a wildcard, so the cache stayed stale until the
   query refetched on its own (e.g. window focus). Switched to
   predicate-based invalidation that matches by [agentOutputs
   marker, agentId] regardless of baseUrl. Same for the per-turn
   files key.

Net effect: send a turn that produces files → strip appears
under the just-finished assistant message; reload the page →
strips still appear under the right bubbles, not bunched at
the bottom.

* fix(agent-files): review feedback — name guard, RFC 5987, limit cap

Three review-flagged issues:

1. Path traversal via agent display name — `getHostWorkspaceDir`
   accepted any string and `path.join`'d it, so a name like
   `../../tmp` escaped `.openclaw`. The pre-turn snapshot would
   then walk that escaped directory and attribute every file to
   the new turn; resolveSafeWorkspacePath's containment check is
   relative to the same escaped root so it would later serve
   arbitrary host paths. Added `isAgentWorkspaceNameSafe` (rejects
   `..`, separators, control chars, leading dots, empty); the
   builder now throws on unsafe names plus a defensive
   realpath-style containment check after the join. Harness
   wraps the call so the path-traversal trip just disables file
   attribution for the turn instead of failing the whole send.
   Six-case regression test pinned.

2. `encodeRfc6266Filename` JSDoc claimed an RFC 5987
   `filename*=UTF-8''<percent-encoded>` fallback but the impl
   only stripped CRLFs/quotes. Now actually emits the fallback
   when non-ASCII is present; helper returns the full
   `filename="…"; filename*=UTF-8''…` attribute pair so the call
   site doesn't have to wrap in quotes.

3. `/agents/:agentId/files` `?limit=` was forwarded to the DB
   uncapped — extracted `parseAgentFilesLimit` that clamps to
   [1, 500] before forwarding.

Also extracted `resolveSafeWorkspaceDir` + `snapshotWorkspaceForTurn`
helpers off `runDetachedTurn` so the new safety branch doesn't
push it past biome's cognitive-complexity cap.
2026-05-05 19:48:28 +05:30
Dani Akash
fbae45eb97 feat(agent): calm composer + redesigned hero (#931)
* feat(agent): calm composer + redesigned hero on /home

Adopt the Variant A redesign aesthetic on /home — hero text and
composer styling only. shadcn primitives and CSS variables
unchanged; conversation-screen composer untouched.

Hero:
- Larger display title (clamp 36→56px, weight 600, tighter
  letter-spacing, balanced wrap).
- Italic muted span around "work on" — small typographic accent
  that makes the hero read as designed rather than default.

Composer (variant="home" only):
- Internal dashed divider between the typing area and the footer
  chip row. The visual cornerstone of the calm aesthetic.
- Footer chips become 24px pill-shaped (rounded-full), ghost-on-
  idle / muted-bg-on-hover. Workspace and Tabs show muted trailing
  values inline (none / 0).
- Agent selector on the far left of the footer gets a filled-pill
  trigger variant (bordered, accent/40 background, mono name) to
  visually anchor the row. AgentSelector exposes a triggerVariant
  prop (ghost | pill); chat surface keeps the existing ghost.
- Subtle 1px vertical divider between the agent pill and the rest.
- Right-aligned keyboard hint (↵ to run · ⇧↵ new line) using kbd
  elements with the existing accent/border tokens.
- Outer shell gains a soft accent-orange focus-within ring.

Out of scope (future PRs): TRY suggestion chips, eyebrow strip,
recent-agents redesign, activity log.

* fix(agent): textarea bg leaks in dark mode

* style(agent): paint hero italic span in accent orange

* feat(agent): adopt calm composer aesthetic on chat-screen too

Bring the calm-composer footer (dashed divider, pill chips,
keyboard hint) over from /home to /agents/:agentId so both
surfaces share one design language.

- Rename HomeContextControls → CalmContextControls; the agent
  selector is conditional via showAgentSelector, so chat hides it
  while home keeps the filled agent pill on the left.
- Drop the legacy ContextControls function entirely (~140 LOC) and
  collapse the variant branching at the call site to a single
  CalmContextControls render.
- Add the same focus-within accent ring to ConversationShell that
  HomeShell already has, so the focus signal is consistent.

The chat composer's Stop button (between textarea and voice mic)
is unchanged — it lives outside the footer chip row and only
surfaces while streaming.

---------

Co-authored-by: DaniAkash <DaniAkash@users.noreply.function>
2026-05-05 14:18:29 +05:30
Nikhil
554fcd7c06 fix: improve browseros-patch CLI ergonomics (#941)
* fix: make checkout detection errors actionable

* fix: clarify browseros-patch checkout terminology

* fix: add browseros-patch help examples

* fix: add browseros-patch llm quick reference

* test: cover patch CLI checkout ergonomics

* fix: address review feedback for PR #941
2026-05-04 18:37:19 -07:00
Nikhil
eed158eca0 fix(patch): handle canonical workspace paths (#940) 2026-05-04 18:09:51 -07:00
152 changed files with 6198 additions and 6233 deletions

View File

@@ -1,187 +0,0 @@
'use client'
import type { ToolUIPart } from 'ai'
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
} from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type ToolUIPartApproval =
| {
id: string
approved?: never
reason?: never
}
| {
id: string
approved: boolean
reason?: string
}
| {
id: string
approved: true
reason?: string
}
| {
id: string
approved: true
reason?: string
}
| {
id: string
approved: false
reason?: string
}
| undefined
// Additional states not covered by ToolUIPart - issue in AI Elements package
type OtherToolUIPartStates =
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
type ConfirmationContextValue = {
approval: ToolUIPartApproval
state: ToolUIPart['state'] | OtherToolUIPartStates
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null)
const useConfirmation = () => {
const context = useContext(ConfirmationContext)
if (!context) {
throw new Error('Confirmation components must be used within Confirmation')
}
return context
}
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval
state: ToolUIPart['state'] | OtherToolUIPartStates
}
/** @public */
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
if (!approval || state === 'input-streaming' || state === 'input-available') {
return null
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn('flex flex-col gap-2', className)} {...props} />
</ConfirmationContext.Provider>
)
}
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>
/** @public */
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn('inline', className)} {...props} />
)
export type ConfirmationRequestProps = {
children?: ReactNode
}
/** @public */
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== 'approval-requested') {
return null
}
return children
}
export type ConfirmationAcceptedProps = {
children?: ReactNode
}
/** @public */
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation()
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== 'approval-responded' &&
state !== 'output-denied' &&
state !== 'output-available')
) {
return null
}
return children
}
export type ConfirmationRejectedProps = {
children?: ReactNode
}
/** @public */
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation()
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== 'approval-responded' &&
state !== 'output-denied' &&
state !== 'output-available')
) {
return null
}
return children
}
export type ConfirmationActionsProps = ComponentProps<'div'>
/** @public */
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== 'approval-requested') {
return null
}
return (
<div
className={cn('flex items-center justify-end gap-2 self-end', className)}
{...props}
/>
)
}
export type ConfirmationActionProps = ComponentProps<typeof Button>
/** @public */
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
)

View File

@@ -38,30 +38,24 @@ export type ToolHeaderProps = {
}
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels: Record<ToolUIPart['state'], string> = {
const labels: Partial<Record<ToolUIPart['state'], string>> = {
'input-streaming': 'Pending',
'input-available': 'Running',
'approval-requested': 'Awaiting Approval',
'approval-responded': 'Responded',
'output-available': 'Completed',
'output-error': 'Error',
'output-denied': 'Denied',
}
const icons: Record<ToolUIPart['state'], ReactNode> = {
const icons: Partial<Record<ToolUIPart['state'], ReactNode>> = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'approval-requested': <ClockIcon className="size-4 text-yellow-600" />,
'approval-responded': <CheckCircleIcon className="size-4 text-blue-600" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
'output-denied': <XCircleIcon className="size-4 text-orange-600" />,
}
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
{labels[status] ?? status}
</Badge>
)
}

View File

@@ -3,8 +3,6 @@ import {
ChevronDown,
CircleDotDashed,
Clock3,
ShieldAlert,
ShieldCheck,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
@@ -27,10 +25,7 @@ const formatToolName = (name: string) =>
const formatStateLabel = (state: ExecutionStepRecord['state']) => {
if (state === 'input-streaming') return 'Preparing'
if (state === 'input-available') return 'Running'
if (state === 'approval-requested') return 'Approval Needed'
if (state === 'approval-responded') return 'Approval Responded'
if (state === 'output-available') return 'Completed'
if (state === 'output-denied') return 'Denied'
return 'Error'
}
@@ -39,22 +34,10 @@ const getStateIcon = (step: ExecutionStepRecord) => {
return <CheckCircle2 className="h-4 w-4 text-green-500" />
}
if (
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested'
) {
if (step.state === 'input-streaming' || step.state === 'input-available') {
return <Clock3 className="h-4 w-4 text-[var(--accent-orange)]" />
}
if (step.state === 'approval-responded') {
return <ShieldCheck className="h-4 w-4 text-blue-500" />
}
if (step.state === 'output-denied') {
return <ShieldAlert className="h-4 w-4 text-orange-500" />
}
if (step.state === 'output-error') {
return <XCircle className="h-4 w-4 text-destructive" />
}
@@ -62,26 +45,14 @@ const getStateIcon = (step: ExecutionStepRecord) => {
return <CircleDotDashed className="h-4 w-4 text-muted-foreground" />
}
const isAclBlocked = (step: ExecutionStepRecord) =>
Boolean(
step.errorText?.includes('Action blocked by ACL rule') ||
step.approval?.reason?.includes('Action blocked by ACL rule') ||
step.previewText === 'Blocked by ACL rule',
)
const shouldShowPreview = (step: ExecutionStepRecord) =>
step.state === 'input-streaming' ||
step.state === 'input-available' ||
step.state === 'approval-requested' ||
step.state === 'approval-responded'
step.state === 'input-streaming' || step.state === 'input-available'
export const ExecutionStepItem: FC<{
step: ExecutionStepRecord
defaultOpen?: boolean
}> = ({ step, defaultOpen = false }) => {
const [open, setOpen] = useState(defaultOpen)
const deniedReason =
step.state === 'output-denied' ? step.approval?.reason : undefined
return (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -100,9 +71,6 @@ export const ExecutionStepItem: FC<{
<Badge variant="secondary">
{formatStateLabel(step.state)}
</Badge>
{isAclBlocked(step) && (
<Badge variant="outline">ACL Blocked</Badge>
)}
</div>
{shouldShowPreview(step) && (
<p className="mt-1 text-muted-foreground text-xs">
@@ -120,22 +88,11 @@ export const ExecutionStepItem: FC<{
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t">
{step.input !== undefined && <ToolInput input={step.input} />}
{step.state === 'output-denied' ? (
<div className="space-y-2 p-4">
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Result
</h4>
<div className="rounded-md bg-orange-500/10 p-3 text-orange-700 text-sm dark:text-orange-300">
{deniedReason ?? 'The requested action was denied.'}
</div>
</div>
) : (
<ToolOutput
output={step.output}
errorText={step.errorText}
className="pt-0"
/>
)}
<ToolOutput
output={step.output}
errorText={step.errorText}
className="pt-0"
/>
</CollapsibleContent>
</div>
</Collapsible>

View File

@@ -103,11 +103,6 @@ export const ExecutionTaskCard: FC<{
<span>{formatDuration(task)}</span>
</>
)}
{task.deniedCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.deniedCount} denied
</Badge>
)}
{task.errorCount > 0 && (
<Badge variant="outline" className="h-5 rounded-full px-2">
{task.errorCount} error

View File

@@ -9,8 +9,6 @@ import {
RotateCcw,
Search,
Server,
ShieldAlert,
ShieldCheck,
} from 'lucide-react'
import type { FC } from 'react'
import { NavLink } from 'react-router'
@@ -80,19 +78,7 @@ const primarySettingsSections: NavSection[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{
name: 'Tool Approvals',
to: '/settings/approvals',
icon: ShieldCheck,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{
name: 'ACL Rules',
to: '/settings/acl',
icon: ShieldAlert,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{
name: 'Usage & Billing',
to: '/settings/usage',

View File

@@ -5,7 +5,6 @@ import {
Home,
PlugZap,
Settings,
Shield,
Sparkles,
Wand2,
} from 'lucide-react'
@@ -65,12 +64,6 @@ const primaryNavItems: NavItem[] = [
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{
name: 'Governance',
to: '/admin',
icon: Shield,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]

View File

@@ -10,8 +10,6 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
import { FeaturesPage } from '../onboarding/features/Features'
import { Onboarding } from '../onboarding/index/Onboarding'
import { StepsLayout } from '../onboarding/steps/StepsLayout'
import { AclSettingsPage } from './acl-settings/AclSettingsPage'
import { AdminDashboardPage } from './admin-dashboard/AdminDashboardPage'
import { AgentCommandConversation } from './agent-command/AgentCommandConversation'
import { AgentCommandHome } from './agent-command/AgentCommandHome'
import { AgentCommandLayout } from './agent-command/agent-command-layout'
@@ -34,7 +32,6 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
import { SearchProviderPage } from './search-provider/SearchProviderPage'
import { SkillsPage } from './skills/SkillsPage'
import { SoulPage } from './soul/SoulPage'
import { ToolApprovalsPage } from './tool-approvals/ToolApprovalsPage'
import { UsagePage } from './usage/UsagePage'
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
@@ -130,9 +127,6 @@ export const App: FC = () => {
</Route>
</>
) : null}
{alphaEnabled ? (
<Route path="admin" element={<AdminDashboardPage />} />
) : null}
</Route>
{/* Settings with dedicated sidebar */}
@@ -146,12 +140,6 @@ export const App: FC = () => {
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
{alphaEnabled ? (
<>
<Route path="acl" element={<AclSettingsPage />} />
<Route path="approvals" element={<ToolApprovalsPage />} />
</>
) : null}
</Route>
</Route>
@@ -186,18 +174,12 @@ export const App: FC = () => {
path="/settings/skills"
element={<Navigate to="/home/skills" replace />}
/>
<Route
path="/audit"
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
/>
<Route path="/audit" element={<Navigate to="/home" replace />} />
<Route
path="/observability"
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
/>
<Route
path="/executions"
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
element={<Navigate to="/home" replace />}
/>
<Route path="/executions" element={<Navigate to="/home" replace />} />
<Route path="/options/*" element={<OptionsRedirect />} />
{/* Fallback to home */}

View File

@@ -1,57 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Globe, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
interface AclRuleCardProps {
rule: AclRule
onToggle: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}
export const AclRuleCard: FC<AclRuleCardProps> = ({
rule,
onToggle,
onDelete,
}) => {
const summary =
rule.description ?? rule.textMatch ?? rule.selector ?? 'Block actions'
return (
<div
className={cn(
'flex items-center gap-4 rounded-xl border p-4 transition-all',
rule.enabled
? 'border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
: 'border-border bg-card opacity-60',
)}
>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => onToggle(rule.id, checked)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-sm">{summary}</span>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1 font-mono text-xs">
<Globe className="size-3" />
{rule.sitePattern}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(rule.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
)
}

View File

@@ -1,136 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { Plus, ShieldAlert } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { fetchServerAclRules, updateServerAclRules } from '@/lib/acl/api'
import { aclRulesStorage } from '@/lib/acl/storage'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { AclRuleCard } from './AclRuleCard'
import { NewAclRuleDialog } from './NewAclRuleDialog'
export const AclSettingsPage: FC = () => {
const [rules, setRules] = useState<AclRule[]>([])
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
useEffect(() => {
aclRulesStorage.getValue().then(setRules)
const unwatch = aclRulesStorage.watch(setRules)
return () => unwatch()
}, [])
useEffect(() => {
if (!baseUrl || urlLoading) return
const resolvedBaseUrl = baseUrl
let cancelled = false
async function bootstrapServerAcl() {
try {
const [localRules, serverRules] = await Promise.all([
aclRulesStorage.getValue(),
fetchServerAclRules(resolvedBaseUrl),
])
if (cancelled) return
if (
serverRules.length === 0 &&
localRules.some((rule) => rule.enabled)
) {
await updateServerAclRules(resolvedBaseUrl, localRules)
}
} catch (error) {
if (!cancelled) {
void error
}
}
}
void bootstrapServerAcl()
return () => {
cancelled = true
}
}, [baseUrl, urlLoading])
const saveRules = async (next: AclRule[]) => {
setRules(next)
await aclRulesStorage.setValue(next)
if (!baseUrl) return
try {
await updateServerAclRules(baseUrl, next)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Failed to sync ACL rules to the server',
)
}
}
const handleAddRule = (rule: AclRule) => {
void saveRules([...rules, rule])
}
const handleToggle = (id: string, enabled: boolean) => {
void saveRules(rules.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
const handleDelete = (id: string) => {
void saveRules(rules.filter((r) => r.id !== id))
}
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-semibold text-xl">ACL Rules</h1>
<p className="mt-1 text-muted-foreground text-sm">
Describe what the agent should avoid on a site and BrowserOS will
block matching actions.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button size="sm">
<Plus className="mr-1 size-4" />
Add Rule
</Button>
</NewAclRuleDialog>
</div>
{rules.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-12 text-center">
<ShieldAlert className="size-10 text-muted-foreground" />
<div>
<p className="font-medium">No ACL rules defined</p>
<p className="mt-1 text-muted-foreground text-sm">
Add a plain-English rule like &ldquo;payments and checkout&rdquo;
or &ldquo;send email&rdquo; and BrowserOS will apply broad safety
blocking on that site.
</p>
</div>
<NewAclRuleDialog onSave={handleAddRule}>
<Button variant="outline" size="sm">
<Plus className="mr-1 size-4" />
Add your first rule
</Button>
</NewAclRuleDialog>
</div>
) : (
<div className="flex flex-col gap-3">
{rules.map((rule) => (
<AclRuleCard
key={rule.id}
rule={rule}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,98 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { type FC, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface NewAclRuleDialogProps {
onSave: (rule: AclRule) => void
children: React.ReactNode
}
export const NewAclRuleDialog: FC<NewAclRuleDialogProps> = ({
onSave,
children,
}) => {
const [open, setOpen] = useState(false)
const [sitePattern, setSitePattern] = useState('')
const [intent, setIntent] = useState('')
const reset = () => {
setSitePattern('')
setIntent('')
}
const handleSave = () => {
if (!sitePattern.trim() || !intent.trim()) return
onSave({
id: crypto.randomUUID(),
sitePattern: sitePattern.trim(),
description: intent.trim(),
enabled: true,
})
reset()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add ACL Rule</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="site-pattern">
Domain <span className="text-destructive">*</span>
</Label>
<Input
id="site-pattern"
placeholder="amazon.com"
value={sitePattern}
onChange={(e) => setSitePattern(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Matches the domain and all subdomains.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="intent">
What should BrowserOS block?{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="intent"
placeholder="Payments and checkout"
value={intent}
onChange={(e) => setIntent(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
Use plain English. BrowserOS will block matching actions on this
site.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!sitePattern.trim() || !intent.trim()}
>
Add Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,41 +0,0 @@
import { Shield } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
interface AdminDashboardHeaderProps {
pendingCount: number
runningCount: number
}
export const AdminDashboardHeader: FC<AdminDashboardHeaderProps> = ({
pendingCount,
runningCount,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<Shield className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-xl">Governance</h2>
{pendingCount > 0 && (
<Badge className="gap-1.5 rounded-full bg-yellow-500/10 text-yellow-600">
{pendingCount} pending
</Badge>
)}
{runningCount > 0 && (
<Badge className="gap-1.5 rounded-full">
{runningCount} live
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
Control agent permissions and audit every action.
</p>
</div>
</div>
</div>
)
}

View File

@@ -1,199 +0,0 @@
import dayjs from 'dayjs'
import { Shield } from 'lucide-react'
import { type FC, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { ExecutionTaskCard } from '@/components/execution-history/ExecutionTaskCard'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
removeConversationExecutionTask,
useExecutionHistoryByConversation,
} from '@/lib/execution-history/storage'
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
import { pendingToolApprovalsStorage } from '@/lib/tool-approvals/approval-sync-storage'
import { AdminDashboardHeader } from './AdminDashboardHeader'
import { PendingApprovals } from './PendingApprovals'
type TaskGroup = {
label: string
tasks: ExecutionTaskRecord[]
}
function getGroupLabel(date: string) {
const startedAt = dayjs(date)
if (startedAt.isSame(dayjs(), 'day')) return 'Today'
if (startedAt.isSame(dayjs().subtract(1, 'day'), 'day')) return 'Yesterday'
return startedAt.format('MMMM D, YYYY')
}
function groupTasks(tasks: ExecutionTaskRecord[]): TaskGroup[] {
const grouped = new Map<string, ExecutionTaskRecord[]>()
for (const task of tasks) {
const label = getGroupLabel(task.startedAt)
const existing = grouped.get(label) ?? []
grouped.set(label, [...existing, task])
}
return Array.from(grouped.entries()).map(([label, groupItems]) => ({
label,
tasks: groupItems,
}))
}
export const AdminDashboardPage: FC = () => {
const [pendingCount, setPendingCount] = useState(0)
const historyByConversation = useExecutionHistoryByConversation()
const [taskToDelete, setTaskToDelete] = useState<ExecutionTaskRecord | null>(
null,
)
useEffect(() => {
pendingToolApprovalsStorage
.getValue()
.then((v) => setPendingCount(v.length))
const unwatch = pendingToolApprovalsStorage.watch((v) =>
setPendingCount(v.length),
)
return () => unwatch()
}, [])
const historyList = useMemo(
() => Object.values(historyByConversation),
[historyByConversation],
)
const tasks = useMemo(() => {
return historyList
.flatMap((history) => history.tasks)
.sort(
(left, right) =>
new Date(right.startedAt).getTime() -
new Date(left.startedAt).getTime(),
)
}, [historyList])
const groupedTasks = useMemo(() => groupTasks(tasks), [tasks])
const runningCount = useMemo(
() => tasks.filter((task) => task.status === 'running').length,
[tasks],
)
const conversationCount = historyList.length
const handleDeleteTask = async () => {
if (!taskToDelete) return
try {
await removeConversationExecutionTask({
conversationId: taskToDelete.conversationId,
taskId: taskToDelete.id,
})
toast.success('Run removed')
} catch {
toast.error('Failed to remove run')
} finally {
setTaskToDelete(null)
}
}
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<AdminDashboardHeader
pendingCount={pendingCount}
runningCount={runningCount}
/>
<section className="space-y-3">
<h3 className="font-semibold text-sm">Approvals</h3>
<PendingApprovals />
</section>
<section className="space-y-4">
<div>
<h3 className="font-semibold text-sm">Audit Trail</h3>
{tasks.length > 0 && (
<p className="mt-1 text-muted-foreground text-sm">
{tasks.length} recorded run{tasks.length === 1 ? '' : 's'}
{conversationCount > 1
? ` across ${conversationCount} chats`
: ''}
. Newest first.
</p>
)}
</div>
{tasks.length === 0 ? (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<Shield className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No agent runs yet</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
Run a task in BrowserOS and the execution history will appear
here.
</p>
</div>
) : (
<div className="space-y-6">
{groupedTasks.map((group, groupIndex) => (
<section key={group.label} className="space-y-3">
<div className="flex items-center gap-3">
<h4 className="font-medium text-muted-foreground text-xs">
{group.label}
</h4>
<div className="h-px flex-1 bg-border/60" />
<span className="text-muted-foreground text-xs">
{group.tasks.length} run
{group.tasks.length === 1 ? '' : 's'}
</span>
</div>
<div className="space-y-3">
{group.tasks.map((task, index) => (
<ExecutionTaskCard
key={task.id}
task={task}
defaultOpen={
task.status === 'running' ||
(groupIndex === 0 && index === 0)
}
onDelete={setTaskToDelete}
/>
))}
</div>
</section>
))}
</div>
)}
</section>
<AlertDialog
open={taskToDelete !== null}
onOpenChange={(open) => !open && setTaskToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Run</AlertDialogTitle>
<AlertDialogDescription>
Remove "{taskToDelete?.promptText}" from local history? This only
clears the recorded run on this device.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteTask}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,103 +0,0 @@
import { Clock, ShieldCheck, ShieldX } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
type ApprovalResponse,
approvalResponsesStorage,
type PendingApproval,
pendingToolApprovalsStorage,
queueApprovalResponse,
} from '@/lib/tool-approvals/approval-sync-storage'
const formatToolName = (name: string) =>
name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (s) => s.toUpperCase())
export const PendingApprovals: FC = () => {
const [pending, setPending] = useState<PendingApproval[]>([])
useEffect(() => {
pendingToolApprovalsStorage.getValue().then(setPending)
const unwatch = pendingToolApprovalsStorage.watch(setPending)
return () => unwatch()
}, [])
const respond = async (approvalId: string, approved: boolean) => {
const response: ApprovalResponse = {
approvalId,
approved,
timestamp: Date.now(),
}
const existing = (await approvalResponsesStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
queueApprovalResponse(existing, response),
)
}
if (pending.length === 0) {
return (
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
<ShieldCheck className="size-5 text-[var(--accent-orange)]" />
</div>
<h3 className="mb-1 font-medium text-lg">No pending approvals</h3>
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
When the agent needs permission to execute a tool, approval requests
will appear here.
</p>
</div>
)
}
return (
<div className="space-y-3">
{pending.map((item) => (
<div
key={item.approvalId}
className="flex items-start gap-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-yellow-500/10">
<Clock className="size-4 text-yellow-600" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{formatToolName(item.toolName)}
</span>
<Badge variant="outline" className="text-[10px]">
awaiting
</Badge>
</div>
{Object.keys(item.input).length > 0 && (
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted/50 p-2 font-mono text-muted-foreground text-xs">
{JSON.stringify(item.input, null, 2)}
</pre>
)}
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, true)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-3 text-xs"
onClick={() => respond(item.approvalId, false)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { ArrowLeft } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef } from 'react'
import { ArrowLeft, PanelRight } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import type {
@@ -16,8 +16,14 @@ import {
useUpdateHarnessAgent,
} from '@/entrypoints/app/agents/useAgents'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { AgentRail } from './AgentRail'
import { useAgentCommandData } from './agent-command-layout'
import {
OutputsRail,
useOutputsRailOpen,
} from './agent-conversation.outputs-rail'
import { ClawChat } from './ClawChat'
import { ConversationHeader } from './ConversationHeader'
import { ConversationInput } from './ConversationInput'
@@ -25,6 +31,8 @@ import {
buildChatHistoryFromClawMessages,
filterTurnsPersistedInHistory,
flattenHistoryPages,
mapHistoryToProducedFilesGroups,
selectStripOnlyTurns,
} from './claw-chat-types'
import { consumePendingInitialMessage } from './pending-initial-message'
import { QueuePanel } from './QueuePanel'
@@ -38,6 +46,7 @@ function AgentConversationController({
agents,
agentPathPrefix,
createAgentPath,
onOpenOutputsRail,
}: {
agentId: string
initialMessage: string | null
@@ -45,6 +54,7 @@ function AgentConversationController({
agents: AgentEntry[]
agentPathPrefix: string
createAgentPath: string
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
}) {
const navigate = useNavigate()
const initialMessageSentRef = useRef<string | null>(null)
@@ -76,6 +86,15 @@ function AgentConversationController({
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
const queue = harnessAgent?.queue ?? []
const activeTurnId = harnessAgent?.activeTurnId ?? null
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
// Used to surface produced-files strips on a fresh page load
// when there's no optimistic turn to carry the data. Disabled
// for non-openclaw adapters since they don't attribute files.
const { groups: agentOutputGroups } = useAgentOutputs(
agentId,
isOpenClawAgent,
)
const { turns, streaming, send } = useAgentConversation(agentId, {
runtime: 'agent-harness',
@@ -100,6 +119,44 @@ function AgentConversationController({
() => filterTurnsPersistedInHistory(turns, historyMessages),
[historyMessages, turns],
)
// Persisted turns that still need to surface their FileCardStrip
// — history items don't carry produced-files data, so without
// these the strip would vanish on history reload.
const stripOnlyTurns = useMemo(
() => selectStripOnlyTurns(turns, historyMessages),
[historyMessages, turns],
)
// Two outputs from the per-turn matcher:
// - filesByAssistantId → strip rendered directly under the
// matching assistant history bubble.
// - tailUnmatched → groups with no history pair (orphans);
// rendered at the conversation tail.
// Both are filtered to exclude turnIds already covered by a
// live or strip-only optimistic turn (those carry their own
// strip and history hasn't reloaded yet).
const { filesByAssistantId, tailStripGroups } = useMemo(() => {
if (!isOpenClawAgent) {
return {
filesByAssistantId: new Map<string, ProducedFilesRailGroup>(),
tailStripGroups: [] as ProducedFilesRailGroup[],
}
}
const coveredTurnIds = new Set<string>()
for (const turn of turns) {
if (turn.turnId) coveredTurnIds.add(turn.turnId)
}
const eligibleGroups = agentOutputGroups.filter(
(group) => !coveredTurnIds.has(group.turnId),
)
const { byAssistantMessageId, unmatched } = mapHistoryToProducedFilesGroups(
historyMessages,
eligibleGroups,
)
return {
filesByAssistantId: byAssistantMessageId,
tailStripGroups: unmatched,
}
}, [agentOutputGroups, isOpenClawAgent, historyMessages, turns])
onInitialMessageConsumedRef.current = onInitialMessageConsumed
const disabled = !agent
@@ -171,12 +228,16 @@ function AgentConversationController({
agentName={agentName}
historyMessages={historyMessages}
turns={visibleTurns}
stripOnlyTurns={stripOnlyTurns}
filesByAssistantId={filesByAssistantId}
tailStripGroups={tailStripGroups}
streaming={streaming}
isInitialLoading={harnessHistoryQuery.isLoading}
error={error}
hasNextPage={false}
isFetchingNextPage={false}
onFetchNextPage={() => {}}
onOpenOutputsRail={onOpenOutputsRail}
onRetry={() => {
void harnessHistoryQuery.refetch()
}}
@@ -287,6 +348,45 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
const isPageVariant = variant === 'page'
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
const [outputsRailOpen, setOutputsRailOpen] =
useOutputsRailOpen(resolvedAgentId)
const railVisible = isOpenClawAgent && outputsRailOpen
// Deep-link target for the rail. Set when (a) the user clicks
// View / +N on an inline file-card strip, or (b) an external nav
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
// itself once it has scrolled to + expanded the matching group.
const urlOutputsTurn = searchParams.get('outputsTurn')
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
// If the URL param flips while we're already on this agent, sync.
useEffect(() => {
if (!urlOutputsTurn) return
setFocusTurnId(urlOutputsTurn)
if (isOpenClawAgent) setOutputsRailOpen(true)
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
const handleOpenOutputsRail = (turnId?: string | null) => {
if (!isOpenClawAgent) return
setOutputsRailOpen(true)
setFocusTurnId(turnId ?? null)
}
const handleFocusTurnConsumed = () => {
setFocusTurnId(null)
if (urlOutputsTurn) {
// Drop the URL param so a back-nav doesn't re-trigger the
// scroll. `replace: true` keeps history clean.
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.delete('outputsTurn')
return next
},
{ replace: true },
)
}
}
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
const adapterId = harnessAgent?.adapter
if (!adapterId) return null
@@ -346,13 +446,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
onPinToggle={(next) =>
handlePinToggle(harnessAgent ?? null, next)
}
headerExtra={
isOpenClawAgent ? (
<Button
variant={railVisible ? 'secondary' : 'ghost'}
size="icon"
className="size-8 rounded-xl"
onClick={() => setOutputsRailOpen(!railVisible)}
title={railVisible ? 'Hide outputs' : 'Show outputs'}
>
<PanelRight className="size-4" />
</Button>
) : undefined
}
/>
</div>
</div>
{/* Body grid: rail list + chat. Both columns share the same
top edge (the band above) so headers can never drift. */}
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
{/* Body grid: rail list + chat (+ outputs rail when an
openclaw agent has it open). Columns share the same top
edge as the band above so headers can never drift. */}
<div
className={cn(
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
railVisible
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
)}
>
<AgentRail
agents={harnessAgents}
adapters={adapters}
@@ -367,13 +488,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
agentId={resolvedAgentId}
agents={agents}
initialMessage={initialMessage}
onInitialMessageConsumed={() =>
setSearchParams({}, { replace: true })
}
onInitialMessageConsumed={() => {
// Preserve the outputsTurn deep-link if present —
// dropping all params would erase the rail focus
// before it had a chance to consume.
setSearchParams(
(prev) => {
const next = new URLSearchParams()
const turn = prev.get('outputsTurn')
if (turn) next.set('outputsTurn', turn)
return next
},
{ replace: true },
)
}}
agentPathPrefix={agentPathPrefix}
createAgentPath={createAgentPath}
onOpenOutputsRail={isOpenClawAgent ? handleOpenOutputsRail : null}
/>
</div>
{railVisible ? (
<OutputsRail
agentId={resolvedAgentId}
onClose={() => setOutputsRailOpen(false)}
focusTurnId={focusTurnId}
onFocusTurnConsumed={handleFocusTurnConsumed}
/>
) : null}
</div>
</div>
</div>

View File

@@ -162,12 +162,16 @@ export const AgentCommandHome: FC = () => {
<>
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
<div className="space-y-3">
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
What should your agent work on next?
<h1 className="font-semibold text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.08] tracking-[-0.025em] [text-wrap:balance]">
What should your agent{' '}
<span className="font-medium text-[var(--accent-orange)] italic">
work on
</span>{' '}
next?
</h1>
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
Start with a task, continue a thread, or switch to another
agent without leaving the new tab.
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
Start a task, continue a thread, or hand off to a different
agent all without leaving this tab.
</p>
</div>

View File

@@ -27,6 +27,14 @@ interface AgentSelectorProps {
onSelectAgent: (agent: AgentEntry) => void
onCreateAgent?: () => void
status?: string
/**
* `'pill'` renders the filled-pill variant used by the calm
* composer on `/home` — bordered, slightly elevated background,
* mono agent name, used as the visual anchor on the left of the
* footer chip row. Default `'ghost'` keeps the existing flat
* shadcn ghost-button trigger used by the chat surface.
*/
triggerVariant?: 'ghost' | 'pill'
}
function getStatusDot(status?: string) {
@@ -42,31 +50,49 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
onSelectAgent,
onCreateAgent,
status,
triggerVariant = 'ghost',
}) => {
const [open, setOpen] = useState(false)
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
const triggerNode =
triggerVariant === 'pill' ? (
<button
type="button"
className={cn(
'inline-flex h-6 max-w-[180px] items-center gap-1.5 rounded-full border border-border bg-accent/40 pr-2 pl-2.5 text-[11.5px] text-foreground transition-colors',
'hover:border-border hover:bg-accent/70 data-[state=open]:border-border data-[state=open]:bg-accent/70',
)}
>
<span className={cn('size-1.5 rounded-full', getStatusDot(status))} />
<span className="truncate font-medium font-mono text-[11.5px] tracking-[-0.01em]">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
) : (
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-72 p-0">
<Command>
<CommandInput placeholder="Search agents..." className="h-9" />

View File

@@ -1,12 +1,14 @@
import { Bot, Loader2, RefreshCw } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { type FC, Fragment, useEffect, useRef } from 'react'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FileCardStrip } from './agent-conversation.file-card-strip'
import { ClawChatMessage } from './ClawChatMessage'
import { ConversationMessage } from './ConversationMessage'
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
@@ -15,6 +17,29 @@ interface ClawChatProps {
agentName: string
historyMessages: ClawChatMessageModel[]
turns: AgentConversationTurn[]
/**
* Persisted turns that still need to render their FileCardStrip
* because the history items they were filtered against don't
* carry produced-files data. Rendered between history and the
* live `turns` so the strip lands at the bottom of the
* corresponding assistant turn.
*/
stripOnlyTurns?: AgentConversationTurn[]
/**
* Maps each assistant history message id → the produced-files
* group that came from its turn. Built by
* `mapHistoryToProducedFilesGroups` upstream so the strip
* renders directly under the matching message instead of
* stacking at the conversation tail.
*/
filesByAssistantId?: Map<string, ProducedFilesRailGroup>
/**
* Produced-files groups that didn't match any persisted history
* pair (e.g. orphaned turns where history loaded after the
* group was attributed). Rendered at the conversation tail as
* a fallback so the user can still see them.
*/
tailStripGroups?: ReadonlyArray<ProducedFilesRailGroup>
streaming: boolean
isInitialLoading: boolean
error: Error | null
@@ -22,6 +47,8 @@ interface ClawChatProps {
isFetchingNextPage: boolean
onFetchNextPage: () => void
onRetry: () => void
/** Wired through to the inline file-card strip on each assistant turn. */
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
className?: string
}
@@ -78,6 +105,9 @@ export const ClawChat: FC<ClawChatProps> = ({
agentName,
historyMessages,
turns,
stripOnlyTurns,
filesByAssistantId,
tailStripGroups,
streaming,
isInitialLoading,
error,
@@ -85,6 +115,7 @@ export const ClawChat: FC<ClawChatProps> = ({
isFetchingNextPage,
onFetchNextPage,
onRetry,
onOpenOutputsRail,
className,
}) => {
const topSentinelRef = useRef<HTMLDivElement>(null)
@@ -147,14 +178,44 @@ export const ClawChat: FC<ClawChatProps> = ({
Start of conversation
</div>
) : null}
{historyMessages.map((message) => (
<ClawChatMessage key={message.id} message={message} />
{historyMessages.map((message) => {
const matched = filesByAssistantId?.get(message.id)
return (
<Fragment key={message.id}>
<ClawChatMessage message={message} />
{matched ? (
<FileCardStrip
turnId={matched.turnId}
files={matched.files}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
) : null}
</Fragment>
)
})}
{(tailStripGroups ?? []).map((group) => (
<FileCardStrip
key={`tail-strip-${group.turnId}`}
turnId={group.turnId}
files={group.files}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
))}
{(stripOnlyTurns ?? []).map((turn) => (
<ConversationMessage
key={`strip-${turn.id}`}
turn={turn}
streaming={false}
stripOnly
onOpenOutputsRail={onOpenOutputsRail}
/>
))}
{turns.map((turn, index) => (
<ConversationMessage
key={turn.id}
turn={turn}
streaming={streaming && index === turns.length - 1}
onOpenOutputsRail={onOpenOutputsRail}
/>
))}
{error ? (

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, Home } from 'lucide-react'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
@@ -20,6 +20,8 @@ interface ConversationHeaderProps {
backTarget: 'home' | 'page'
onGoHome: () => void
onPinToggle: (next: boolean) => void
/** Optional trailing slot — currently used for the Outputs rail toggle. */
headerExtra?: ReactNode
}
/**
@@ -40,6 +42,7 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
backTarget,
onGoHome,
onPinToggle,
headerExtra,
}) => {
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
const adapter = agent?.adapter ?? fallbackAdapter
@@ -90,16 +93,21 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<StatusPill
status={status}
hasActiveTurn={Boolean(agent?.activeTurnId)}
/>
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
<span className="truncate">
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
</span>
<div className="flex shrink-0 items-center gap-3">
<div className="flex shrink-0 flex-col items-end gap-1">
<StatusPill
status={status}
hasActiveTurn={Boolean(agent?.activeTurnId)}
/>
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
<span className="truncate">
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
</span>
</div>
</div>
{headerExtra ? (
<div className="flex shrink-0 items-center">{headerExtra}</div>
) : null}
</div>
</div>
)

View File

@@ -164,7 +164,16 @@ function VoiceButton({
)
}
function ContextControls({
/**
* Calm-composer footer shared by both `/home` (`variant="home"`) and
* the chat surface at `/agents/:agentId` (`variant="conversation"`).
* Pill-shaped chips on an internal dashed divider, with a right-
* aligned keyboard hint. The agent selector is conditional via
* `showAgentSelector`: home shows it as a filled pill on the left,
* the chat surface hides it (the agent is locked once you're in the
* conversation).
*/
function CalmContextControls({
agents,
onCreateAgent,
onSelectAgent,
@@ -201,110 +210,128 @@ function ContextControls({
)?.is_authenticated
})
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
return (
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
<div className="flex items-center gap-1">
{showAgentSelector ? (
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
{showAgentSelector ? (
<>
<AgentSelector
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={onSelectAgent}
onCreateAgent={onCreateAgent}
status={status}
triggerVariant="pill"
/>
) : null}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
<WorkspaceSelector>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Folder className="h-4 w-4" />
<span>{selectedFolder?.name || 'Add workspace'}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<Button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
selectedTabs.length > 0
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
<span
aria-hidden="true"
className="mx-1 inline-block h-3.5 w-px shrink-0 bg-border"
/>
</>
) : null}
{showWorkspace ? (
<WorkspaceSelector>
<button
type="button"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
<Layers className="h-4 w-4" />
<span>Tabs</span>
</Button>
</TabPickerPopover>
<Button
<Folder className="size-3" />
<span>Workspace</span>
<span className="font-mono text-[10.5px] text-muted-foreground/70">
{selectedFolder?.name ?? 'none'}
</span>
</button>
</WorkspaceSelector>
) : null}
<TabPickerPopover
variant="selector"
selectedTabs={selectedTabs}
onToggleTab={onToggleTab}
>
<button
type="button"
variant="ghost"
onClick={onAttachClick}
disabled={attachDisabled || !attachmentsEnabled}
title="Attach files"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] transition-colors data-[state=open]:bg-accent data-[state=open]:text-foreground',
selectedTabs.length > 0
? 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<Paperclip className="h-4 w-4" />
<span>Attach</span>
</Button>
</div>
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
<div className="ml-auto flex items-center gap-1.5">
<AppSelector side="bottom">
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<div className="flex items-center -space-x-1.5">
<Layers className="size-3" />
<span>Tabs</span>
<span
className={cn(
'font-mono text-[10.5px]',
selectedTabs.length > 0
? 'text-white/80'
: 'text-muted-foreground/70',
)}
>
{selectedTabs.length}
</span>
</button>
</TabPickerPopover>
<button
type="button"
onClick={onAttachClick}
disabled={attachDisabled || !attachmentsEnabled}
title="Attach files"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<Paperclip className="size-3" />
<span>Attach</span>
</button>
{showApps ? (
<AppSelector side="bottom">
<button
type="button"
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
{connectedManagedServers.length > 0 ? (
<span className="flex items-center -space-x-1.5">
{connectedManagedServers.slice(0, 4).map((server) => (
<div
<span
key={server.id}
className="rounded-full ring-2 ring-card"
>
<McpServerIcon
serverName={server.managedServerName ?? ''}
size={16}
size={12}
/>
</div>
</span>
))}
</div>
{connectedManagedServers.length > 4 ? (
<span className="text-xs">
+{connectedManagedServers.length - 4}
</span>
) : null}
<span>Apps</span>
<ChevronDown className="h-3 w-3" />
</Button>
</AppSelector>
</div>
</span>
) : (
<FileText className="size-3" />
)}
<span>Apps</span>
<ChevronDown className="size-3" />
</button>
</AppSelector>
) : null}
<div className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground/70">
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<span>to run</span>
<span className="text-muted-foreground/40">·</span>
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
</kbd>
<span>new line</span>
</div>
</div>
)
}
function HomeShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_1px_2px_rgba(15,23,42,0.04)]">
{children}
</div>
)
@@ -312,7 +339,7 @@ function HomeShell({ children }: { children: ReactNode }) {
function ConversationShell({ children }: { children: ReactNode }) {
return (
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_10px_30px_rgba(15,23,42,0.06)]">
{children}
</div>
)
@@ -542,7 +569,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
}
disabled={disabled || voice.isTranscribing}
className={cn(
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent',
'[field-sizing:fixed]',
variant === 'home'
? 'min-h-[40px] py-2 leading-6'
@@ -583,7 +610,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
{voice.error}
</div>
) : null}
<ContextControls
<CalmContextControls
agents={agents}
onCreateAgent={onCreateAgent}
onSelectAgent={onSelectAgent}

View File

@@ -22,10 +22,26 @@ import type {
AgentConversationTurn,
ToolEntry,
} from '@/lib/agent-conversations/types'
import { FileCardStrip } from './agent-conversation.file-card-strip'
interface ConversationMessageProps {
turn: AgentConversationTurn
streaming: boolean
/**
* Forwarded to the inline file-card strip's "View" / "+N"
* button. Wired up by AgentCommandConversation so the strip can
* deep-link straight into the Outputs rail at the matching turn
* group. `null` here disables the strip's deep-link affordance
* — the cards still open the preview Sheet directly.
*/
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
/**
* Render only the trailing FileCardStrip for this turn — used
* when the turn's user / assistant text is already rendered
* elsewhere (e.g. by `ClawChatMessage` from persisted history)
* but the produced-files affordance would otherwise be lost.
*/
stripOnly?: boolean
}
interface RenderEntry {
@@ -88,9 +104,22 @@ function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
export const ConversationMessage: FC<ConversationMessageProps> = ({
turn,
streaming,
onOpenOutputsRail,
stripOnly,
}) => {
const entries = useMemo(() => buildRenderEntries(turn), [turn])
if (stripOnly) {
if (!turn.producedFiles || turn.producedFiles.length === 0) return null
return (
<FileCardStrip
turnId={turn.turnId ?? null}
files={turn.producedFiles}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
)
}
return (
<div className="space-y-3">
<Message from="user">
@@ -185,6 +214,14 @@ export const ConversationMessage: FC<ConversationMessageProps> = ({
</Message>
)}
{turn.producedFiles && turn.producedFiles.length > 0 ? (
<FileCardStrip
turnId={turn.turnId ?? null}
files={turn.producedFiles}
onOpenRail={onOpenOutputsRail ?? (() => {})}
/>
) : null}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">

View File

@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* @deprecated Replaced by `FileCardStrip` in
* `agent-conversation.file-card-strip.tsx`. Kept temporarily so
* any in-flight callers don't fail to import; remove in a
* follow-up once nothing external references it.
*
* Compact "Files produced" card rendered under an assistant turn.
*/
import { FileText, Image as ImageIcon, Paperclip } from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
export interface ProducedFileLike {
id: string
path: string
size: number
}
interface ArtifactCardProps {
files: ReadonlyArray<ProducedFileLike>
className?: string
}
const MAX_INLINE_ROWS = 4
export const ArtifactCard: FC<ArtifactCardProps> = ({ files, className }) => {
const [openFileId, setOpenFileId] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const sortedFiles = useMemo(
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
[files],
)
if (sortedFiles.length === 0) return null
const visible = expanded ? sortedFiles : sortedFiles.slice(0, MAX_INLINE_ROWS)
const hiddenCount = sortedFiles.length - visible.length
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
return (
<div
className={cn(
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
className,
)}
>
<div className="mb-2 flex items-center gap-2 text-muted-foreground text-xs">
<Paperclip className="size-3.5" />
<span className="font-medium text-foreground">
{sortedFiles.length === 1
? '1 file produced'
: `${sortedFiles.length} files produced`}
</span>
</div>
<ul className="flex flex-col gap-1">
{visible.map((file) => (
<li key={file.id}>
<ArtifactRow file={file} onOpen={() => setOpenFileId(file.id)} />
</li>
))}
</ul>
{hiddenCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1.5 h-7 px-2 text-xs"
onClick={() => setExpanded(true)}
>
Show {hiddenCount} more
</Button>
) : null}
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFileId)}
onOpenChange={(next) => {
if (!next) setOpenFileId(null)
}}
/>
</div>
)
}
function ArtifactRow({
file,
onOpen,
}: {
file: ProducedFileLike
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* "Files produced" strip rendered at the bottom of any assistant
* turn that produced files (openclaw only). Replaces Phase 5.3's
* row-list ArtifactCard with small horizontal cards for a lighter
* visual treatment.
*
* Click semantics:
* - Card → opens FilePreviewSheet directly (preview + download).
* - View → emits onOpenRail(turnId); the parent opens the rail
* and scrolls to the matching turn group.
* - +N → same as View (the user is asking to see what was
* overflowed).
*/
import { ChevronRight, FileText, Image as ImageIcon } from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
export interface CardStripFile {
id: string
path: string
size: number
}
interface FileCardStripProps {
/**
* The turn id that produced these files. Forwarded to
* `onOpenRail` so the rail can scroll/expand the matching group.
* Optional because the live `produced_files` event lands before
* the harness has stamped a server-issued turn id on the
* optimistic turn — in that brief window, View falls back to
* just opening the rail at the top.
*/
turnId?: string | null
files: ReadonlyArray<CardStripFile>
/** Caller wires this to `setOutputsRailOpen(true)` + deep-link. */
onOpenRail: (turnId?: string | null) => void
className?: string
}
const MAX_VISIBLE = 4
export const FileCardStrip: FC<FileCardStripProps> = ({
turnId,
files,
onOpenRail,
className,
}) => {
const [openFileId, setOpenFileId] = useState<string | null>(null)
const sortedFiles = useMemo(
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
[files],
)
if (sortedFiles.length === 0) return null
const visible = sortedFiles.slice(0, MAX_VISIBLE)
const hiddenCount = sortedFiles.length - visible.length
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
return (
<div
className={cn(
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
className,
)}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{sortedFiles.length === 1
? 'File produced'
: `Files produced (${sortedFiles.length})`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1 px-2 text-xs"
onClick={() => onOpenRail(turnId ?? null)}
>
View
<ChevronRight className="size-3" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{visible.map((file) => (
<FileCard
key={file.id}
file={file}
onOpen={() => setOpenFileId(file.id)}
/>
))}
{hiddenCount > 0 ? (
<button
type="button"
onClick={() => onOpenRail(turnId ?? null)}
className={cn(
'flex h-[56px] min-w-[56px] shrink-0 items-center justify-center rounded-lg border border-border/60 px-3 text-muted-foreground text-xs',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground',
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
)}
title={`See ${hiddenCount} more in the Outputs rail`}
>
+{hiddenCount}
</button>
) : null}
</div>
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFileId)}
onOpenChange={(next) => {
if (!next) setOpenFileId(null)
}}
/>
</div>
)
}
function FileCard({
file,
onOpen,
}: {
file: CardStripFile
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
title={file.path}
className={cn(
'flex h-[56px] w-[140px] shrink-0 flex-col justify-between rounded-lg border border-border/60 bg-background px-2.5 py-1.5 text-left',
'transition-colors hover:border-border hover:bg-accent/40',
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium text-xs">
{name}
</span>
</div>
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}

View File

@@ -0,0 +1,283 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Shared preview drawer used by the inline artifact card AND the
* Outputs rail. Branches on the FilePreview discriminated union and
* renders the appropriate body. Always opens via a controlled
* `open`/`onOpenChange` pair so the parent owns the selected file.
*/
import { Download, FileWarning, Loader2 } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { MessageResponse } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
basenameOf,
buildFileDownloadUrl,
extensionOf,
type FilePreview,
formatFileSize,
useFilePreview,
} from '@/lib/agent-files'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { cn } from '@/lib/utils'
interface FilePreviewSheetProps {
fileId: string | null
filePath: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdx'])
export const FilePreviewSheet: FC<FilePreviewSheetProps> = ({
fileId,
filePath,
open,
onOpenChange,
}) => {
const { baseUrl } = useAgentServerUrl()
const { preview, loading, error } = useFilePreview(fileId, open)
const fileName = filePath ? basenameOf(filePath) : 'File preview'
const downloadUrl = useMemo(() => {
if (!baseUrl || !fileId) return null
return buildFileDownloadUrl(baseUrl, fileId)
}, [baseUrl, fileId])
// Surface preview-load failures in a toast in addition to the
// inline error block — the inline UI lives at the bottom of the
// sheet and is easy to miss when scrolled into the body.
const lastToastedFileIdRef = useRef<string | null>(null)
useEffect(() => {
if (!open) {
lastToastedFileIdRef.current = null
return
}
if (!error || !fileId) return
if (lastToastedFileIdRef.current === fileId) return
lastToastedFileIdRef.current = fileId
toast.error('Could not load preview', { description: error.message })
}, [open, error, fileId])
const handleDownload = () => {
if (!downloadUrl) {
toast.error("Couldn't reach the agent server", {
description: 'Reconnect to BrowserOS and try again.',
})
return
}
// Manually trigger the download so any future failure (e.g. the
// server returns 404 because the file was removed) can be
// surfaced via toast — the bare <a download> path swallows
// these errors silently.
const link = document.createElement('a')
link.href = downloadUrl
link.download = fileName
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
link.remove()
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
>
<SheetHeader className="border-border/60 border-b px-5 py-4">
<SheetTitle className="truncate pr-8">{fileName}</SheetTitle>
<SheetDescription className="truncate">
{filePath ?? ''}
</SheetDescription>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="px-5 py-4">
{loading ? (
<PreviewSkeleton />
) : error ? (
<PreviewError message={error.message} />
) : preview ? (
<PreviewBody
preview={preview}
filePath={filePath}
downloadUrl={downloadUrl}
/>
) : null}
</div>
</ScrollArea>
{fileId ? (
<div className="border-border/60 border-t bg-background/90 px-5 py-3 backdrop-blur">
<Button
type="button"
size="sm"
className="w-full gap-2"
onClick={handleDownload}
>
<Download className="size-3.5" />
Download
</Button>
</div>
) : null}
</SheetContent>
</Sheet>
)
}
function PreviewSkeleton() {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2 className="size-3.5 animate-spin" />
Loading preview...
</div>
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
)
}
function PreviewError({ message }: { message: string }) {
return (
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
<div className="flex items-center gap-2 font-medium">
<FileWarning className="size-4" />
Could not load preview
</div>
<p className="text-destructive/80 text-xs">{message}</p>
</div>
)
}
function PreviewBody({
preview,
filePath,
downloadUrl,
}: {
preview: FilePreview
filePath: string | null
downloadUrl: string | null
}) {
if (preview.kind === 'missing') {
return (
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
This file is no longer in the workspace. The agent may have moved or
deleted it after the turn finished.
</div>
)
}
if (preview.kind === 'image') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/30">
<img
src={preview.dataUrl}
alt={filePath ?? 'preview'}
className="block max-h-[60vh] w-full object-contain"
/>
</div>
</div>
)
}
if (preview.kind === 'pdf') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
PDF previews aren't supported inline yet. Use Download to open this
file in your default PDF viewer.
</div>
</div>
)
}
if (preview.kind === 'binary') {
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
No inline preview for this file type.
{downloadUrl ? ' Use Download to save it locally.' : null}
</div>
</div>
)
}
return <TextPreviewBody preview={preview} filePath={filePath} />
}
function TextPreviewBody({
preview,
filePath,
}: {
preview: Extract<FilePreview, { kind: 'text' }>
filePath: string | null
}) {
const ext = filePath ? extensionOf(filePath).toLowerCase() : ''
const renderAsMarkdown = MARKDOWN_EXTENSIONS.has(ext)
return (
<div className="flex flex-col gap-3">
<PreviewMeta preview={preview} />
{renderAsMarkdown ? (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rounded-lg border border-border/60 bg-muted/30 px-4 py-3',
"[&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='code-block']]:overflow-x-auto",
)}
>
<MessageResponse mode="static" parseIncompleteMarkdown={false}>
{preview.snippet}
</MessageResponse>
</div>
) : (
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs leading-relaxed">
<code className="font-mono text-foreground">{preview.snippet}</code>
</pre>
)}
{preview.truncated ? (
<div className="text-muted-foreground text-xs">
Showing the first part of this file. Download to see the full
contents.
</div>
) : null}
</div>
)
}
function PreviewMeta({
preview,
}: {
preview: Exclude<FilePreview, { kind: 'missing' }>
}) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
<span className="font-medium text-foreground">
{formatFileSize(preview.size)}
</span>
<span>·</span>
<span className="font-mono">{preview.mimeType || 'unknown'}</span>
</div>
)
}

View File

@@ -0,0 +1,338 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Per-agent right-side "Outputs" panel. Lists every file the harness
* has attributed to this agent, grouped by the turn that produced
* them. Click a row to open the shared preview Sheet.
*
* Lifecycle:
* - Open/closed state is controlled by the parent and persisted via
* `useOutputsRailOpen(agentId)` so each agent remembers its
* preference independently.
* - Data refreshes whenever a turn finishes (the conversation hook
* fires `useInvalidateAgentOutputs` from its finally block).
* - Manual "Refresh" button is wired to `useRefreshAgentOutputs`
* for users who navigate in mid-turn.
*/
import {
ChevronDown,
ChevronRight,
FileText,
Image as ImageIcon,
Inbox,
Loader2,
PanelRightClose,
RefreshCw,
} from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import {
basenameOf,
formatFileSize,
inferFileKind,
type ProducedFilesRailGroup,
useAgentOutputs,
useRefreshAgentOutputs,
} from '@/lib/agent-files'
import { cn } from '@/lib/utils'
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
interface OutputsRailProps {
agentId: string
onClose: () => void
/**
* When set, the rail scrolls the matching `RailTurnGroup` into
* view and force-opens its `Collapsible`. Used by the inline
* file-card strip's "View" / "+N" deep-link path. Cleared by
* the parent (via `onFocusTurnConsumed`) once the rail has
* acknowledged the deep-link so subsequent renders don't keep
* re-scrolling the same group.
*/
focusTurnId?: string | null
onFocusTurnConsumed?: () => void
}
const RAIL_LOCAL_STORAGE_PREFIX = 'browseros:outputs-rail:'
/**
* Controlled open/close state with per-agent localStorage memory.
* Returns a tuple compatible with React's useState shape so the
* parent can pass it straight into the rail without an extra effect.
*/
export function useOutputsRailOpen(
agentId: string,
): [boolean, (next: boolean) => void] {
const [open, setOpen] = useState(false)
useEffect(() => {
if (typeof window === 'undefined' || !agentId) return
try {
const stored = window.localStorage.getItem(
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
)
setOpen(stored === '1')
} catch {
// localStorage may be unavailable (private mode, locked-down
// contexts) — fall back to closed.
}
}, [agentId])
const update = (next: boolean) => {
setOpen(next)
if (typeof window === 'undefined' || !agentId) return
try {
window.localStorage.setItem(
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
next ? '1' : '0',
)
} catch {
// Best-effort persistence.
}
}
return [open, update]
}
export const OutputsRail: FC<OutputsRailProps> = ({
agentId,
onClose,
focusTurnId,
onFocusTurnConsumed,
}) => {
const { groups, loading, error } = useAgentOutputs(agentId)
const refresh = useRefreshAgentOutputs(agentId)
const [openFile, setOpenFile] = useState<{
id: string
path: string
} | null>(null)
const totalFiles = useMemo(
() => groups.reduce((sum, group) => sum + group.files.length, 0),
[groups],
)
return (
<aside className="flex h-full min-h-0 w-full flex-col border-border/50 border-l bg-background">
<header className="flex shrink-0 items-center gap-2 border-border/50 border-b px-3 py-3">
<span className="font-semibold text-[13px] uppercase tracking-wide">
Outputs
</span>
{totalFiles > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{totalFiles}
</span>
) : null}
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
refresh.mutate(undefined, {
onError: (err) =>
toast.error('Refresh failed', {
description:
err instanceof Error ? err.message : String(err),
}),
})
}
disabled={refresh.isPending}
title="Refresh"
>
{refresh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={onClose}
title="Hide outputs"
>
<PanelRightClose className="size-3.5" />
</Button>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<div className="px-2 py-2">
{loading && groups.length === 0 ? (
<RailSkeleton />
) : error ? (
<RailError message={error.message} />
) : groups.length === 0 ? (
<RailEmpty />
) : (
<ul className="flex flex-col gap-2">
{groups.map((group) => (
<li key={group.turnId}>
<RailTurnGroup
group={group}
focused={
Boolean(focusTurnId) && focusTurnId === group.turnId
}
onFocusConsumed={onFocusTurnConsumed}
onOpenFile={(file) =>
setOpenFile({ id: file.id, path: file.path })
}
/>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
<FilePreviewSheet
fileId={openFile?.id ?? null}
filePath={openFile?.path ?? null}
open={Boolean(openFile)}
onOpenChange={(next) => {
if (!next) setOpenFile(null)
}}
/>
</aside>
)
}
function RailTurnGroup({
group,
focused,
onFocusConsumed,
onOpenFile,
}: {
group: ProducedFilesRailGroup
focused: boolean
onFocusConsumed?: () => void
onOpenFile: (file: { id: string; path: string }) => void
}) {
const [open, setOpen] = useState(true)
const headerLabel = group.turnPrompt.trim() || 'Turn'
const containerRef = useRef<HTMLDivElement>(null)
// Deep-link consumption: when the parent passes `focused=true`,
// expand the collapsible (in case the user had collapsed it
// earlier) and scroll into view. Fire `onFocusConsumed` so the
// parent can drop the URL param and we don't re-scroll on every
// render after that.
useEffect(() => {
if (!focused) return
setOpen(true)
containerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
onFocusConsumed?.()
}, [focused, onFocusConsumed])
return (
<div ref={containerRef}>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-muted-foreground text-xs',
'transition-colors hover:bg-accent/40 hover:text-foreground',
)}
>
{open ? (
<ChevronDown className="size-3 shrink-0" />
) : (
<ChevronRight className="size-3 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate font-medium">
{headerLabel}
</span>
<span className="shrink-0 tabular-nums">{group.files.length}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-1 ml-1 flex flex-col gap-0.5 border-border/40 border-l pl-2">
{group.files.map((file) => (
<li key={file.id}>
<RailFileRow file={file} onOpen={() => onOpenFile(file)} />
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</div>
)
}
function RailFileRow({
file,
onOpen,
}: {
file: ProducedFilesRailGroup['files'][number]
onOpen: () => void
}) {
const name = basenameOf(file.path)
const kind = inferFileKind(file.path)
const Icon = kind === 'image' ? ImageIcon : FileText
return (
<button
type="button"
onClick={onOpen}
className={cn(
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors',
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
)}
title={file.path}
>
<Icon className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{name}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{formatFileSize(file.size)}
</span>
</button>
)
}
function RailSkeleton() {
return (
<div className="flex flex-col gap-2 px-1.5 py-1">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-5/6" />
</div>
)
}
function RailEmpty() {
return (
<div className="mx-2 my-3 flex flex-col items-center gap-1.5 rounded-lg border border-border/60 border-dashed bg-muted/20 px-3 py-6 text-center text-muted-foreground text-xs">
<Inbox className="size-4" />
<p className="font-medium">No outputs yet</p>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
Files this agent creates will appear here, grouped by the turn that made
them.
</p>
</div>
)
}
function RailError({ message }: { message: string }) {
return (
<div className="mx-2 my-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
{message}
</div>
)
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
export type ClawChatRole = 'user' | 'assistant'
@@ -234,6 +235,30 @@ export function filterTurnsPersistedInHistory(
)
}
/**
* Persisted turns that still carry `producedFiles` — once history
* reloads, the assistant text is rendered by `ClawChatMessage` and
* the optimistic turn is filtered out by
* `filterTurnsPersistedInHistory`. The historical message has no
* `producedFiles` field (history items don't carry that), so the
* inline file-card strip would vanish on history reload.
*
* Returning these here lets the caller render a strip-only entry
* after the corresponding history bubble — full message stays as
* the persisted history pair, but the produced-files affordance
* survives.
*/
export function selectStripOnlyTurns(
turns: AgentConversationTurn[],
historyMessages: ClawChatMessage[],
): AgentConversationTurn[] {
return turns.filter(
(turn) =>
Boolean(turn.producedFiles && turn.producedFiles.length > 0) &&
isTurnPersistedInHistory(turn, historyMessages),
)
}
function isTurnPersistedInHistory(
turn: AgentConversationTurn,
historyMessages: ClawChatMessage[],
@@ -285,3 +310,59 @@ function getClawMessageText(message: ClawChatMessage): string {
.join('')
.trim()
}
function firstNonBlankLine(value: string): string {
for (const raw of value.split('\n')) {
const trimmed = raw.trim()
if (trimmed) return trimmed
}
return ''
}
/**
* Map each assistant history message to the produced-files group
* that came from its turn. Match key is `group.turnPrompt` (first
* non-blank line of the user prompt that initiated the turn) vs.
* the first non-blank line of the user message that immediately
* preceded this assistant message — the same shape the server
* emits when storing turnPrompt.
*
* Walks history forward (oldest-first per `flattenHistoryPages`)
* and consumes groups in chronological order. A group can only
* match once — if two turns share the same prompt the earlier
* one wins, and the later assistant message stays unassociated
* (those land back in `tailStripGroups` at the conversation tail).
*/
export function mapHistoryToProducedFilesGroups(
historyMessages: ClawChatMessage[],
groups: ReadonlyArray<ProducedFilesRailGroup>,
): {
byAssistantMessageId: Map<string, ProducedFilesRailGroup>
unmatched: ProducedFilesRailGroup[]
} {
const byAssistantMessageId = new Map<string, ProducedFilesRailGroup>()
if (groups.length === 0) {
return { byAssistantMessageId, unmatched: [] }
}
// Oldest-first so the iteration order matches history.
const remaining = [...groups].sort((a, b) => a.createdAt - b.createdAt)
let pendingPrompt: string | null = null
for (const message of historyMessages) {
if (message.role === 'user') {
pendingPrompt = firstNonBlankLine(getClawMessageText(message))
continue
}
if (message.role !== 'assistant' || !pendingPrompt) continue
const matchIndex = remaining.findIndex(
(group) => group.turnPrompt === pendingPrompt,
)
if (matchIndex >= 0) {
const [match] = remaining.splice(matchIndex, 1)
byAssistantMessageId.set(message.id, match)
}
pendingPrompt = null
}
return { byAssistantMessageId, unmatched: remaining }
}

View File

@@ -10,9 +10,11 @@ import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpe
import type {
AgentConversationTurn,
AssistantPart,
ConversationTurnFile,
ToolEntry,
UserAttachmentPreview,
} from '@/lib/agent-conversations/types'
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
import type { ServerAttachmentPayload } from '@/lib/attachments'
import { consumeSSEStream } from '@/lib/sse'
import { buildToolLabel } from '@/lib/tool-labels'
@@ -53,6 +55,12 @@ export function useAgentConversation(
) {
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
const [streaming, setStreaming] = useState(false)
const invalidateAgentOutputs = useInvalidateAgentOutputs()
// Stable ref so the resume effect doesn't re-subscribe on every
// render (the hook's returned callable is freshly closured each
// time, but the underlying queryClient is stable).
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
invalidateAgentOutputsRef.current = invalidateAgentOutputs
const sessionKeyRef = useRef(options.sessionKey ?? '')
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
const textAccRef = useRef('')
@@ -152,6 +160,17 @@ export function useAgentConversation(
})
}
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
// Replace, don't merge: the server's diff is authoritative for
// the just-completed turn — duplicate events shouldn't grow the
// list, and a re-attribution should overwrite an earlier one.
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
})
}
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
if (event.type !== 'tool_call') return
const rawName = event.title || event.rawType || 'tool call'
@@ -208,6 +227,9 @@ export function useAgentConversation(
case 'tool_call':
upsertAgentHarnessTool(event)
break
case 'produced_files':
setProducedFilesOnCurrentTurn(event.files)
break
case 'done':
markCurrentTurnDone()
break
@@ -259,6 +281,7 @@ export function useAgentConversation(
...prev,
{
id: crypto.randomUUID(),
turnId: active.turnId,
userText: active.prompt ?? '',
parts: [],
done: false,
@@ -304,9 +327,14 @@ export function useAgentConversation(
// When `cancelled` is true the next run will set these
// itself, so resetting here would only cause a brief flicker.
if (!cancelled && weStartedStream) {
const finishedTurnId = turnIdRef.current
turnIdRef.current = null
lastSeqRef.current = null
setStreaming(false)
void invalidateAgentOutputsRef.current(
agentId,
finishedTurnId ?? undefined,
)
}
}
}
@@ -318,6 +346,60 @@ export function useAgentConversation(
}
}, [agentId, activeTurnIdDep])
/**
* Send the chat request and follow the 409-active-turn redirect
* once. Pulled out of `send` to keep its cognitive complexity in
* check — the retry adds a branch that biome counts heavily.
*/
const openSendStream = async (
targetAgentId: string,
text: string,
attachments: ServerAttachmentPayload[],
signal: AbortSignal,
): Promise<Response> => {
const initial = await chatWithHarnessAgent(
targetAgentId,
text,
signal,
attachments,
)
if (initial.status !== 409) return initial
// 409 means the server already has an active turn for this agent
// (a previous tab kicked one off and we're a fresh mount that
// missed the resume window). Attach to it instead of double-sending.
const body = (await initial.json()) as { turnId?: string }
if (!body.turnId) return initial
return attachToHarnessTurn(targetAgentId, {
turnId: body.turnId,
signal,
})
}
/**
* Pull session-key / turn-id off response headers and propagate to
* refs + the optimistic turn. Stamping `turnId` here lets the
* inline artifact card fall back to /files/turn/<id> on a resumed
* mount that missed the live `produced_files` event.
*/
const applyResponseHeadersToTurn = (response: Response) => {
const responseSessionKey =
response.headers.get('X-Session-Key') ??
response.headers.get('X-Session-Id')
if (responseSessionKey) {
sessionKeyRef.current = responseSessionKey
onSessionKeyChangeRef.current?.(responseSessionKey)
}
const responseTurnId = response.headers.get('X-Turn-Id')
if (!responseTurnId) return
turnIdRef.current = responseTurnId
lastSeqRef.current = null
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
})
}
const send = async (input: string | SendInput) => {
const normalized: SendInput =
typeof input === 'string' ? { text: input } : input
@@ -346,37 +428,13 @@ export function useAgentConversation(
streamAbortRef.current = abortController
try {
let response = await chatWithHarnessAgent(
const response = await openSendStream(
agentId,
trimmed,
abortController.signal,
attachments,
abortController.signal,
)
// 409 means the server already has an active turn for this
// agent (e.g. a previous tab kicked one off and we're a fresh
// mount that missed the resume window). Attach to it instead of
// double-sending.
if (response.status === 409) {
const body = (await response.json()) as { turnId?: string }
if (body.turnId) {
response = await attachToHarnessTurn(agentId, {
turnId: body.turnId,
signal: abortController.signal,
})
}
}
const responseSessionKey =
response.headers.get('X-Session-Key') ??
response.headers.get('X-Session-Id')
if (responseSessionKey) {
sessionKeyRef.current = responseSessionKey
onSessionKeyChangeRef.current?.(responseSessionKey)
}
const responseTurnId = response.headers.get('X-Turn-Id')
if (responseTurnId) {
turnIdRef.current = responseTurnId
lastSeqRef.current = null
}
applyResponseHeadersToTurn(response)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
@@ -404,10 +462,15 @@ export function useAgentConversation(
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
// Capture before nulling — the invalidation needs the turn id so
// useAgentTurnFiles consumers also flush, not just the agent-wide
// rail query.
const finishedTurnId = turnIdRef.current
turnIdRef.current = null
lastSeqRef.current = null
onCompleteRef.current?.()
setStreaming(false)
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
}
}

View File

@@ -2,6 +2,21 @@ import type { AgentEntry } from './useOpenClaw'
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
/**
* One file the harness attributed to the assistant turn that just
* finished. Mirrors the server-side `ProducedFileEventEntry` shape so
* the inline artifact card can render alongside the streamed text the
* user just watched complete. Only present for openclaw adapter
* turns; claude / codex don't produce these events in v1.
*/
export interface HarnessProducedFile {
id: string
/** Workspace-relative POSIX path. */
path: string
size: number
mtimeMs: number
}
export type AgentHarnessStreamEvent =
| {
type: 'text_delta'
@@ -22,6 +37,10 @@ export type AgentHarnessStreamEvent =
text: string
rawType?: string
}
| {
type: 'produced_files'
files: HarnessProducedFile[]
}
| {
type: 'done'
text?: string

View File

@@ -25,12 +25,18 @@ interface HarnessAgentsResponse {
export type { AgentHarnessStreamEvent }
const AGENT_QUERY_KEYS = {
export const AGENT_QUERY_KEYS = {
adapters: 'agent-harness-adapters',
agents: 'agent-harness-agents',
/** Outputs-rail data for one agent — `[agentOutputs, baseUrl, agentId]`. */
agentOutputs: 'agent-harness-agent-outputs',
/** Per-turn artifact-card files — `[agentTurnFiles, baseUrl, agentId, turnId]`. */
agentTurnFiles: 'agent-harness-agent-turn-files',
/** Single-file preview payload — `[filePreview, baseUrl, fileId]`. */
filePreview: 'agent-harness-file-preview',
} as const
async function agentsFetch<T>(
export async function agentsFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,

View File

@@ -1,120 +0,0 @@
import {
Bot,
Camera,
Code,
Database,
Eye,
Hand,
MousePointerClick,
Navigation,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import {
TOOL_CATEGORIES,
type ToolApprovalConfig,
} from '@/lib/tool-approvals/types'
const CATEGORY_ICONS: Record<string, typeof Hand> = {
input: MousePointerClick,
navigation: Navigation,
observation: Eye,
screenshots: Camera,
scripts: Code,
'data-modification': Database,
assistant: Bot,
}
export const ToolApprovalsPage: FC = () => {
const [config, setConfig] = useState<ToolApprovalConfig>({ categories: {} })
useEffect(() => {
const applyConfig = (value: ToolApprovalConfig) =>
setConfig(normalizeToolApprovalConfig(value))
toolApprovalConfigStorage.getValue().then(applyConfig)
const unwatch = toolApprovalConfigStorage.watch(applyConfig)
return () => unwatch()
}, [])
const allEnabled =
TOOL_CATEGORIES.length > 0 &&
TOOL_CATEGORIES.every((category) => config.categories[category.id] === true)
const toggleCategory = (categoryId: string, enabled: boolean) => {
const next = {
...config,
categories: { ...config.categories, [categoryId]: enabled },
}
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
const toggleAll = (enabled: boolean) => {
const categories: Record<string, boolean> = {}
for (const cat of TOOL_CATEGORIES) {
categories[cat.id] = enabled
}
const next = { ...config, categories }
setConfig(next)
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold text-xl tracking-tight">Tool Approvals</h2>
<p className="text-muted-foreground text-sm">
Require human approval before the agent executes certain actions.
Changes apply immediately.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div className="space-y-0.5">
<div className="font-medium text-sm">Require approval for all</div>
<div className="text-muted-foreground text-xs">
Toggle all categories at once
</div>
</div>
<Switch checked={allEnabled} onCheckedChange={toggleAll} />
</div>
<div className="space-y-3">
{TOOL_CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.id] ?? Hand
const enabled = config.categories[category.id] ?? false
return (
<div
key={category.id}
className="flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors"
>
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{category.name}</span>
</div>
<p className="text-muted-foreground text-xs">
{category.description}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
toggleCategory(category.id, checked)
}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -43,7 +43,6 @@ export const Chat = () => {
disliked,
onClickDislike,
isRestoringConversation,
addToolApprovalResponse,
} = useChatSessionContext()
const {
@@ -223,12 +222,6 @@ export const Chat = () => {
showDontShowAgain={showDontShowAgain}
onTakeSurvey={onTakeSurvey}
onDismissJtbdPopup={onDismissJtbdPopup}
onToolApprove={(id) =>
addToolApprovalResponse({ id, approved: true })
}
onToolDeny={(id) =>
addToolApprovalResponse({ id, approved: false })
}
/>
)}
{agentUrlError && (

View File

@@ -37,8 +37,6 @@ interface ChatMessagesProps {
showDontShowAgain: boolean
onTakeSurvey: (opts?: { dontShowAgain?: boolean }) => void
onDismissJtbdPopup: (dontShowAgain: boolean) => void
onToolApprove?: (approvalId: string) => void
onToolDeny?: (approvalId: string) => void
}
export const ChatMessages: FC<ChatMessagesProps> = ({
@@ -53,8 +51,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
showDontShowAgain,
onTakeSurvey,
onDismissJtbdPopup,
onToolApprove,
onToolDeny,
}) => {
const isStreaming = status === 'streaming' || status === 'submitted'
@@ -118,8 +114,6 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
isLastBatch={segment.key === lastToolBatchKey}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
onApprove={onToolApprove}
onDeny={onToolDeny}
/>
)
case 'nudge':

View File

@@ -2,10 +2,7 @@ import {
BotIcon,
CheckCircle2,
CircleDashed,
Clock,
Loader2,
ShieldCheck,
ShieldX,
XCircle,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
@@ -15,7 +12,6 @@ import {
TaskItem,
TaskTrigger,
} from '@/components/ai-elements/task'
import { Button } from '@/components/ui/button'
import type {
ToolInvocationInfo,
ToolInvocationState,
@@ -26,8 +22,6 @@ interface ToolBatchProps {
isLastBatch: boolean
isLastMessage: boolean
isStreaming: boolean
onApprove?: (approvalId: string) => void
onDeny?: (approvalId: string) => void
}
export const ToolBatch: FC<ToolBatchProps> = ({
@@ -35,20 +29,12 @@ export const ToolBatch: FC<ToolBatchProps> = ({
isLastBatch,
isLastMessage,
isStreaming,
onApprove,
onDeny,
}) => {
const hasPendingApproval = tools.some((t) => t.state === 'approval-requested')
const shouldBeOpen =
(isLastMessage && isLastBatch && isStreaming) || hasPendingApproval
const shouldBeOpen = isLastMessage && isLastBatch && isStreaming
const [isOpen, setIsOpen] = useState(shouldBeOpen)
const [hasUserInteracted, setHasUserInteracted] = useState(false)
useEffect(() => {
if (hasPendingApproval) {
setIsOpen(true)
return
}
if (isLastMessage && !hasUserInteracted) {
if (isLastBatch) {
setIsOpen(isStreaming)
@@ -56,18 +42,10 @@ export const ToolBatch: FC<ToolBatchProps> = ({
setIsOpen(false)
}
}
}, [
isStreaming,
isLastMessage,
isLastBatch,
hasUserInteracted,
hasPendingApproval,
])
}, [isStreaming, isLastMessage, isLastBatch, hasUserInteracted])
const completedCount = tools.filter((t) => isToolCompleted(t.state)).length
const triggerTitle = hasPendingApproval
? 'Waiting for approval...'
: `${completedCount}/${tools.length} actions completed`
const triggerTitle = `${completedCount}/${tools.length} actions completed`
const onManualToggle = (newState: boolean) => {
setHasUserInteracted(true)
@@ -84,14 +62,6 @@ export const ToolBatch: FC<ToolBatchProps> = ({
<ToolStatusIcon state={tool.state} />
<span className="flex-1">{formatToolName(tool.toolName)}</span>
</TaskItem>
{tool.state === 'approval-requested' &&
tool.approval?.id != null && (
<ApprovalButtons
approvalId={tool.approval.id}
onApprove={onApprove}
onDeny={onDeny}
/>
)}
</div>
))}
</TaskContent>
@@ -114,47 +84,10 @@ const isToolInProgress = (state: ToolInvocationState) =>
const isToolError = (state: ToolInvocationState) => state === 'output-error'
const isToolDenied = (state: ToolInvocationState) => state === 'output-denied'
const isToolApprovalPending = (state: ToolInvocationState) =>
state === 'approval-requested'
const ApprovalButtons: FC<{
approvalId: string
onApprove?: (id: string) => void
onDeny?: (id: string) => void
}> = ({ approvalId, onApprove, onDeny }) => (
<div className="mt-1 mb-2 ml-6 flex items-center gap-2">
<Button
size="sm"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onApprove?.(approvalId)}
>
<ShieldCheck className="size-3" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2.5 text-xs"
onClick={() => onDeny?.(approvalId)}
>
<ShieldX className="size-3" />
Deny
</Button>
</div>
)
const ToolStatusIcon: FC<{ state: ToolInvocationState }> = ({ state }) => {
if (isToolCompleted(state)) {
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
}
if (isToolApprovalPending(state)) {
return <Clock className="h-3.5 w-3.5 text-yellow-500" />
}
if (isToolDenied(state)) {
return <ShieldX className="h-3.5 w-3.5 text-red-400" />
}
if (isToolInProgress(state)) {
return (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--accent-orange)]" />

View File

@@ -8,9 +8,6 @@ export type ToolInvocationState =
| 'input-available'
| 'output-available'
| 'output-error'
| 'approval-requested'
| 'approval-responded'
| 'output-denied'
export interface ToolInvocationInfo {
state: ToolInvocationState
@@ -18,7 +15,6 @@ export interface ToolInvocationInfo {
toolName: string
input: Record<string, unknown>
output: unknown[]
approval?: { id: string; approved?: boolean; reason?: string }
}
export type NudgeType = 'schedule_suggestion' | 'app_connection'
@@ -110,7 +106,6 @@ export const getMessageSegments = (
state: ToolInvocationState
input: Record<string, unknown>
output: unknown
approval?: { id: string; approved?: boolean; reason?: string }
}
const toolName = toolPart.type?.replace('tool-', '')
@@ -132,7 +127,6 @@ export const getMessageSegments = (
toolName,
input: toolPart?.input ?? {},
output: (toolPart?.output as unknown[]) ?? [],
approval: toolPart?.approval,
})
}
}

View File

@@ -46,9 +46,6 @@ describe('buildSidepanelPreparedSendMessagesRequest', () => {
target: acpTarget,
fallbackProvider,
message: 'Inspect the current tab',
approvalResponses: [
{ approvalId: 'approval-1', approved: true, reason: 'ok' },
],
...commonRequestInput(),
})
@@ -71,26 +68,6 @@ describe('buildSidepanelPreparedSendMessagesRequest', () => {
},
})
})
it('keeps tool approval retry payloads scoped to LLM chat', () => {
const request = buildSidepanelPreparedSendMessagesRequest({
agentServerUrl: 'http://127.0.0.1:5151',
target: llmTarget,
fallbackProvider,
approvalResponses: [
{ approvalId: 'approval-1', approved: false, reason: 'no' },
],
...commonRequestInput(),
})
expect(request.api).toBe('http://127.0.0.1:5151/chat')
expect(request.body).toMatchObject({
message: '',
toolApprovalResponses: [
{ approvalId: 'approval-1', approved: false, reason: 'no' },
],
})
})
})
function commonRequestInput() {
@@ -107,13 +84,11 @@ function commonRequestInput() {
{ role: 'assistant' as const, content: 'Prior answer' },
],
declinedApps: ['gmail'],
aclRules: [{ id: 'rule-1', sitePattern: '*://*/*', enabled: true }],
selectedText: 'selected text',
selectedTextSource: {
url: 'https://example.com',
title: 'Example',
},
toolApprovalConfig: { categories: { navigation: true } },
}
}

View File

@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { aclRulesStorage } from '@/lib/acl/storage'
import { Capabilities, Feature } from '@/lib/browseros/capabilities'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { ChatAction } from '@/lib/chat-actions/types'
@@ -26,28 +25,12 @@ import { useInvalidateCredits } from '@/lib/credits/useCredits'
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
import type {
ApprovalResponseData,
ChatRequestBrowserContext,
} from '@/lib/messaging/server/buildChatRequestBody'
import type { ChatRequestBrowserContext } from '@/lib/messaging/server/buildChatRequestBody'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
import { sentry } from '@/lib/sentry/sentry'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import {
type ApprovalResponse,
approvalResponsesStorage,
extractPendingApprovals,
pendingToolApprovalsStorage,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from '@/lib/tool-approvals/approval-sync-storage'
import {
normalizeToolApprovalConfig,
toolApprovalConfigStorage,
} from '@/lib/tool-approvals/storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
@@ -61,29 +44,6 @@ import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
import { useNotifyActiveTab } from './useNotifyActiveTab'
import { useRemoteConversationSave } from './useRemoteConversationSave'
const extractApprovalResponses = (
messages: UIMessage[],
): ApprovalResponseData[] | null => {
const lastMsg = messages[messages.length - 1]
if (lastMsg?.role !== 'assistant') return null
const approvals: ApprovalResponseData[] = []
for (const part of lastMsg.parts) {
const p = part as {
state?: string
approval?: { id: string; approved?: boolean; reason?: string }
}
if (p.state === 'approval-responded' && p.approval?.approved != null) {
approvals.push({
approvalId: p.approval.id,
approved: p.approval.approved,
reason: p.approval.reason,
})
}
}
return approvals.length > 0 ? approvals : null
}
const getLastMessageText = (messages: UIMessage[]) => {
const lastMessage = messages[messages.length - 1]
if (!lastMessage) return ''
@@ -280,7 +240,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
}
const modeRef = useRef<ChatMode>(mode)
const approvalJustRespondedRef = useRef(false)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(undefined)
const selectionMapRef = useRef<
@@ -338,7 +297,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
status,
stop,
error: chatError,
addToolApprovalResponse,
} = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: async ({ messages }) => {
@@ -366,11 +324,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
})
const declinedApps = await declinedAppsStorage.getValue()
const allAclRules = await aclRulesStorage.getValue()
const enabledAclRules = allAclRules.filter((r) => r.enabled)
const approvalConfig = normalizeToolApprovalConfig(
await toolApprovalConfigStorage.getValue(),
)
const supportsArrayConversation = await Capabilities.supports(
Feature.PREVIOUS_CONVERSATION_ARRAY,
@@ -400,20 +353,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
userWorkingDir: workingDirRef.current,
previousConversation,
declinedApps,
aclRules: enabledAclRules,
toolApprovalConfig: approvalConfig,
}
const approvalResponses =
target?.kind === 'acp' ? null : extractApprovalResponses(messages)
if (approvalResponses) {
return buildSidepanelPreparedSendMessagesRequest({
agentServerUrl: agentUrlRef.current ?? undefined,
target,
fallbackProvider,
...commonRequest,
approvalResponses,
})
}
const message = getLastMessageText(messages)
@@ -440,13 +379,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return result
},
}),
sendAutomaticallyWhen: () => {
if (approvalJustRespondedRef.current) {
approvalJustRespondedRef.current = false
return selectedChatTargetRef.current?.kind !== 'acp'
}
return false
},
sendAutomaticallyWhen: () => false,
onFinish: async ({ message, isAbort, isError }) => {
await finishExecutionTask({
responseText: getLastMessageText([message]),
@@ -575,69 +508,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
if (chatError) invalidateCredits()
}, [chatError, invalidateCredits])
// Sync pending tool approvals to shared storage for the admin dashboard
useEffect(() => {
let isCancelled = false
const syncPendingApprovals = async () => {
const pending = extractPendingApprovals(
messages,
conversationIdRef.current,
)
const current = (await pendingToolApprovalsStorage.getValue()) ?? []
if (isCancelled) return
await pendingToolApprovalsStorage.setValue(
replacePendingApprovalsForConversation(
current,
conversationIdRef.current,
pending,
),
)
}
syncPendingApprovals()
return () => {
isCancelled = true
}
}, [messages])
// Watch for approval responses from the admin dashboard
// biome-ignore lint/correctness/useExhaustiveDependencies: only set up once
useEffect(() => {
const handleResponses = async (responses: ApprovalResponse[]) => {
if (!responses?.length) return
try {
for (const resp of responses) {
respondToToolApproval({
id: resp.approvalId,
approved: resp.approved,
reason: resp.reason,
})
}
const approvalIds = responses.map((resp) => resp.approvalId)
const currentResponses =
(await approvalResponsesStorage.getValue()) ?? []
const currentPending =
(await pendingToolApprovalsStorage.getValue()) ?? []
await approvalResponsesStorage.setValue(
removeApprovalResponsesById(currentResponses, approvalIds),
)
await pendingToolApprovalsStorage.setValue(
removePendingApprovalsById(currentPending, approvalIds),
)
} catch {
// Leave storage intact so the dashboard can retry
}
}
approvalResponsesStorage.getValue().then(handleResponses)
const unwatch = approvalResponsesStorage.watch(handleResponses)
return () => unwatch()
}, [])
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
const pendingMessageRef = useRef<{
@@ -736,15 +606,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
return () => unwatch()
}, [])
const respondToToolApproval = (params: {
id: string
approved: boolean
reason?: string
}) => {
approvalJustRespondedRef.current = true
addToolApprovalResponse(params)
}
const resetConversationState = () => {
stop()
void finishExecutionTask({ isAbort: true })
@@ -834,6 +695,5 @@ export const useChatSession = (options?: ChatSessionOptions) => {
disliked,
onClickDislike,
conversationId,
addToolApprovalResponse: respondToToolApproval,
}
}

View File

@@ -1,9 +1,6 @@
import type { Provider } from '../../../components/chat/chatComponentTypes'
import type { LlmProviderConfig } from '../../../lib/llm-providers/types'
import {
type ApprovalResponseData,
buildChatRequestBody,
} from '../../../lib/messaging/server/buildChatRequestBody'
import { buildChatRequestBody } from '../../../lib/messaging/server/buildChatRequestBody'
import {
type SidepanelChatTarget,
toLlmProviderConfig,
@@ -13,7 +10,7 @@ type LlmChatRequestBodyInput = Parameters<typeof buildChatRequestBody>[0]
type CommonSidepanelRequestInput = Omit<
LlmChatRequestBodyInput,
'provider' | 'message' | 'toolApprovalResponses' | 'isScheduledTask'
'provider' | 'message' | 'isScheduledTask'
>
interface BuildSidepanelPreparedSendMessagesRequestInput
@@ -22,7 +19,6 @@ interface BuildSidepanelPreparedSendMessagesRequestInput
target: SidepanelChatTarget | undefined
fallbackProvider: LlmProviderConfig
message?: string
approvalResponses?: ApprovalResponseData[] | null
}
export function buildSidepanelPreparedSendMessagesRequest({
@@ -30,7 +26,6 @@ export function buildSidepanelPreparedSendMessagesRequest({
target,
fallbackProvider,
message,
approvalResponses,
...common
}: BuildSidepanelPreparedSendMessagesRequestInput) {
if (target?.kind === 'acp') {
@@ -55,7 +50,6 @@ export function buildSidepanelPreparedSendMessagesRequest({
...common,
provider,
message,
toolApprovalResponses: approvalResponses ?? undefined,
}),
}
}

View File

@@ -30,8 +30,6 @@ function createTask(input: StartExecutionTaskInput): ExecutionTaskRecord {
startedAt: new Date().toISOString(),
status: 'running',
actionCount: 0,
approvalCount: 0,
deniedCount: 0,
errorCount: 0,
steps: [],
}
@@ -117,8 +115,6 @@ export function useExecutionHistoryTracker() {
responsePreview:
getResponsePreview(assistantMessage) || activeTask.responsePreview,
actionCount: normalized.actionCount,
approvalCount: normalized.approvalCount,
deniedCount: normalized.deniedCount,
errorCount: normalized.errorCount,
steps: normalized.steps,
})

View File

@@ -1,40 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
type AclRulesResponse = {
aclRules: AclRule[]
}
async function parseJsonResponse(
response: Response,
): Promise<Record<string, unknown>> {
return response.json().catch(() => ({}))
}
export async function fetchServerAclRules(baseUrl: string): Promise<AclRule[]> {
const response = await fetch(`${baseUrl}/acl-rules`)
if (!response.ok) {
const data = await parseJsonResponse(response)
throw new Error(String(data.error ?? `HTTP ${response.status}`))
}
const data = (await response.json()) as AclRulesResponse
return data.aclRules
}
export async function updateServerAclRules(
baseUrl: string,
aclRules: AclRule[],
): Promise<AclRule[]> {
const response = await fetch(`${baseUrl}/acl-rules`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aclRules }),
})
if (!response.ok) {
const data = await parseJsonResponse(response)
throw new Error(String(data.error ?? `HTTP ${response.status}`))
}
const data = (await response.json()) as AclRulesResponse
return data.aclRules
}

View File

@@ -1,7 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { storage } from '#imports'
export const aclRulesStorage = storage.defineItem<AclRule[]>(
'local:acl-rules',
{ fallback: [] },
)

View File

@@ -42,11 +42,34 @@ export interface UserAttachmentPreview {
dataUrl?: string
}
/**
* Files attributed to this turn by the harness's per-turn workspace
* diff. Populated either via the live `produced_files` SSE event or
* (on resume) the `useAgentTurnFiles` fallback. Mirrors the wire
* shape from `agent-harness-types.HarnessProducedFile` minus the
* stream-only fields the inline card doesn't need.
*/
export interface ConversationTurnFile {
id: string
path: string
size: number
mtimeMs: number
}
export interface AgentConversationTurn {
id: string
/**
* Server-issued turn id, set as soon as the response headers arrive
* (`X-Turn-Id`) for fresh sends, or from the active-turn payload on
* resume. Required for the historic-files fallback fetch; absent on
* the brief optimistic window before the first header.
*/
turnId?: string | null
userText: string
userAttachments?: UserAttachmentPreview[]
parts: AssistantPart[]
/** Files produced during this turn (openclaw only in v1). */
producedFiles?: ConversationTurnFile[]
done: boolean
timestamp: number
}

View File

@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pure helpers used by the artifact card and the Outputs rail.
* Display formatting only — no React, no fetch, no DOM. Anything
* stateful belongs in `./useAgentOutputs` or `./useFilePreview`.
*/
import { buildAgentApiUrl } from '@/entrypoints/app/agents/agent-api-url'
/**
* Coarse classification of a file's intended preview / icon path.
* Mirrors the server-side `FilePreviewKind` minus `missing` — the
* client only ever computes a kind for a row it already has.
*/
export type FileKind = 'text' | 'image' | 'pdf' | 'binary'
const TEXT_EXTENSIONS = new Set([
'txt',
'md',
'markdown',
'json',
'jsonl',
'csv',
'tsv',
'xml',
'yaml',
'yml',
'toml',
'ini',
'log',
'html',
'htm',
'css',
'js',
'mjs',
'cjs',
'ts',
'tsx',
'jsx',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'c',
'h',
'cpp',
'hpp',
'sh',
'zsh',
'bash',
'sql',
'svg',
])
const IMAGE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'bmp',
'ico',
'heic',
'heif',
])
/** Best-effort kind based on extension only. Server's preview API
* is the source of truth for actual rendering — this is just for
* picking an icon / sort hint without a network round-trip. */
export function inferFileKind(path: string): FileKind {
const ext = extensionOf(path).toLowerCase()
if (ext === 'pdf') return 'pdf'
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
if (TEXT_EXTENSIONS.has(ext)) return 'text'
return 'binary'
}
/** Plain extension without the leading dot. Empty string when none. */
export function extensionOf(path: string): string {
const dot = path.lastIndexOf('.')
if (dot === -1) return ''
const slash = path.lastIndexOf('/')
if (dot < slash) return ''
return path.slice(dot + 1)
}
/** File name (final path segment), no directory prefix. */
export function basenameOf(path: string): string {
const slash = path.lastIndexOf('/')
return slash === -1 ? path : path.slice(slash + 1)
}
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
/** "2.4 MB" / "340 KB" / "78 B" — for the artifact card's right-side
* metadata. Not localised; the rail uses one space + the unit. */
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
if (bytes < 1024) return `${bytes} ${SIZE_UNITS[0]}`
let value = bytes
let unit = 0
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024
unit += 1
}
// 1-digit precision below 10, integer above — feels less noisy.
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString()
return `${formatted} ${SIZE_UNITS[unit]}`
}
/**
* Build the per-file download URL using the same agent-api root the
* rest of the harness hits. Returned URL is already absolute.
*/
export function buildFileDownloadUrl(baseUrl: string, fileId: string): string {
return buildAgentApiUrl(
baseUrl,
`/files/${encodeURIComponent(fileId)}/download`,
)
}

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export {
basenameOf,
buildFileDownloadUrl,
extensionOf,
type FileKind,
formatFileSize,
inferFileKind,
} from './file-helpers'
export type {
BinaryFilePreview,
FilePreview,
FilePreviewKind,
ImageFilePreview,
MissingFilePreview,
PdfFilePreview,
ProducedFile,
ProducedFilesRailGroup,
TextFilePreview,
} from './types'
export {
useAgentOutputs,
useAgentTurnFiles,
useInvalidateAgentOutputs,
useRefreshAgentOutputs,
} from './useAgentOutputs'
export { useFilePreview } from './useFilePreview'

View File

@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Wire types shared by the inline artifact card and the per-agent
* Outputs rail. These mirror `ProducedFileEntry` /
* `ProducedFilesRailGroup` on the server and the `FilePreview`
* discriminated union from `apps/server/src/api/services/openclaw/file-preview.ts`.
*
* The schema mirror is deliberate (vs sharing a workspace package)
* because the server keeps the on-disk row shape — `agentDefinitionId`,
* `sessionKey` — out of the wire payload. Dropping those columns at the
* type boundary keeps the client honest about what it can refer to.
*/
export interface ProducedFile {
id: string
/** Workspace-relative POSIX path. */
path: string
size: number
mtimeMs: number
/** Server clock when the file was first attributed to its turn. */
createdAt: number
detectedBy: 'diff' | 'tool'
}
export interface ProducedFilesRailGroup {
turnId: string
/** First non-blank line of the user prompt that initiated this turn. */
turnPrompt: string
createdAt: number
files: ProducedFile[]
}
export type FilePreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
interface BasePreview {
kind: FilePreviewKind
mimeType: string
size: number
mtimeMs: number
}
export interface TextFilePreview extends BasePreview {
kind: 'text'
snippet: string
/** True when the on-disk file is larger than the server's snippet cap. */
truncated: boolean
}
export interface ImageFilePreview extends BasePreview {
kind: 'image'
/** Base64 data URL (incl. `data:` prefix). Suitable for `<img src>`. */
dataUrl: string
}
export interface PdfFilePreview extends BasePreview {
kind: 'pdf'
}
export interface BinaryFilePreview extends BasePreview {
kind: 'binary'
}
export interface MissingFilePreview {
kind: 'missing'
}
export type FilePreview =
| TextFilePreview
| ImageFilePreview
| PdfFilePreview
| BinaryFilePreview
| MissingFilePreview

View File

@@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* React Query hooks backing the per-agent Outputs rail and the
* inline artifact card.
*
* Live updates: the consumer of `useAgentConversation` (see Phase 5)
* is expected to call `useInvalidateAgentOutputs(agentId)` whenever
* an assistant turn completes, so the rail picks up the new
* `produced_files` rows the server attributed during that turn.
* No SSE channel here — invalidation off the existing chat-stream
* completion is enough for v1.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
AGENT_QUERY_KEYS,
agentsFetch,
} from '@/entrypoints/app/agents/useAgents'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { ProducedFile, ProducedFilesRailGroup } from './types'
interface OutputsResponse {
groups: ProducedFilesRailGroup[]
}
interface TurnFilesResponse {
files: ProducedFile[]
}
export function useAgentOutputs(agentId: string, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<ProducedFilesRailGroup[], Error>({
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
queryFn: async () => {
const data = await agentsFetch<OutputsResponse>(
baseUrl as string,
`/${encodeURIComponent(agentId)}/files`,
)
return data.groups ?? []
},
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
})
return {
groups: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
/**
* Per-turn fetch for the inline artifact card. Used both as the
* fallback when an SSE `produced_files` event was missed, and to
* rehydrate a turn the user scrolled back to.
*/
export function useAgentTurnFiles(
agentId: string,
turnId: string | null,
enabled = true,
) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<ProducedFile[], Error>({
queryKey: [AGENT_QUERY_KEYS.agentTurnFiles, baseUrl, agentId, turnId],
queryFn: async () => {
const data = await agentsFetch<TurnFilesResponse>(
baseUrl as string,
`/${encodeURIComponent(agentId)}/files/turn/${encodeURIComponent(
turnId as string,
)}`,
)
return data.files ?? []
},
enabled:
Boolean(baseUrl) &&
!urlLoading &&
enabled &&
Boolean(agentId) &&
Boolean(turnId),
})
return {
files: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
/**
* Returns a callable that invalidates outputs / turn-files queries
* for one agent across any baseUrl. Call after an assistant turn
* completes so the rail (and the inline file-card strip) pick up
* the new attributed rows. Cheap when the queries aren't mounted
* — react-query just marks the cached value stale.
*
* Implementation note: react-query's `invalidateQueries({ queryKey })`
* does positional partial-match, so passing `undefined` as the
* baseUrl placeholder does NOT match a cached `[…, baseUrl, …]`
* key — the cache stayed stale. Use a predicate so we ignore the
* baseUrl position entirely.
*/
export function useInvalidateAgentOutputs() {
const queryClient = useQueryClient()
return async (agentId: string, turnId?: string) => {
await Promise.all([
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey
return (
Array.isArray(key) &&
key[0] === AGENT_QUERY_KEYS.agentOutputs &&
key[2] === agentId
)
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey
if (
!Array.isArray(key) ||
key[0] !== AGENT_QUERY_KEYS.agentTurnFiles ||
key[2] !== agentId
) {
return false
}
// When a turnId was supplied, scope to just that turn's
// entry. Otherwise flush every cached turn for this agent.
return turnId ? key[3] === turnId : true
},
}),
])
}
}
/**
* Tiny mutation wrapper so the Outputs rail's "Refresh" button can
* surface an `isPending` indicator while the new query is in flight.
* No body — just triggers `refetch` on the rail's query for this
* agent and resolves when it settles.
*/
export function useRefreshAgentOutputs(agentId: string) {
const queryClient = useQueryClient()
const { baseUrl } = useAgentServerUrl()
return useMutation({
mutationFn: async () => {
await queryClient.refetchQueries({
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
exact: true,
})
},
})
}

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Single-file preview hook used by the inline artifact card and the
* Outputs rail's preview Sheet. Always opt-in (`enabled`) — the
* preview is fetched only when the user clicks a row, never
* eagerly.
*/
import { useQuery } from '@tanstack/react-query'
import {
AGENT_QUERY_KEYS,
agentsFetch,
} from '@/entrypoints/app/agents/useAgents'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type { FilePreview } from './types'
export function useFilePreview(fileId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<FilePreview, Error>({
queryKey: [AGENT_QUERY_KEYS.filePreview, baseUrl, fileId],
queryFn: async () => {
return agentsFetch<FilePreview>(
baseUrl as string,
`/files/${encodeURIComponent(fileId as string)}/preview`,
)
},
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(fileId),
// Previews are immutable for a given fileId — once loaded, never
// refetch on focus / reconnect. They go stale only when the
// underlying file is removed (rare in v1; no rename / delete).
staleTime: Infinity,
gcTime: 5 * 60 * 1000,
})
return {
preview: query.data ?? null,
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}

View File

@@ -112,30 +112,6 @@ describe('normalizeExecutionSteps', () => {
expect(normalized.steps[0]?.previewText).toBe('Completed successfully')
})
it('surfaces ACL blocks as a compact issue label', () => {
const normalized = normalizeExecutionSteps({
assistantMessage: createAssistantMessage([
asMessagePart({
type: 'tool-click',
toolCallId: 'tool-1',
state: 'output-available',
input: { x: 10, y: 20 },
output: {
content: [
{
type: 'text',
text: "Action blocked by ACL rule: 'add to cart'. The element on this page is restricted.",
},
],
},
}),
]),
nowIso: '2026-03-26T10:00:00.000Z',
})
expect(normalized.steps[0]?.previewText).toBe('Blocked by ACL rule')
})
})
describe('execution history text helpers', () => {

View File

@@ -1,15 +1,10 @@
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
import type {
ExecutionStepApproval,
ExecutionStepRecord,
ExecutionStepState,
} from './types'
import type { ExecutionStepRecord, ExecutionStepState } from './types'
const NUDGE_TOOL_NAMES = new Set(['suggest_schedule', 'suggest_app_connection'])
const TERMINAL_STEP_STATES = new Set<ExecutionStepState>([
'output-available',
'output-error',
'output-denied',
])
const MAX_PREVIEW_CHARS = 180
@@ -20,62 +15,6 @@ function truncateText(value: string): string {
return `${value.slice(0, MAX_PREVIEW_CHARS - 3)}...`
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value
if (value == null) return ''
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function getNestedText(value: unknown, depth = 0): string | undefined {
if (depth > 5 || value == null) return undefined
if (typeof value === 'string') {
const text = normalizeText(value)
return text || undefined
}
if (Array.isArray(value)) {
for (const item of value) {
const text = getNestedText(item, depth + 1)
if (text) return text
}
return undefined
}
if (typeof value !== 'object') return undefined
const record = value as Record<string, unknown>
for (const key of ['text', 'message', 'reason', 'content']) {
const text = getNestedText(record[key], depth + 1)
if (text) return text
}
for (const nestedValue of Object.values(record)) {
const text = getNestedText(nestedValue, depth + 1)
if (text) return text
}
return undefined
}
function getCompactIssueLabel(value?: string): string | undefined {
if (!value) return undefined
if (value.includes('Action blocked by ACL rule')) {
return 'Blocked by ACL rule'
}
return undefined
}
function getToolName(part: ToolLikePart): string {
if (part.type === 'dynamic-tool') {
return part.toolName
@@ -96,29 +35,12 @@ function isExecutionToolPart(
}
function getPreviewText(part: ToolLikePart): string {
if (part.state === 'approval-requested') {
return 'Waiting for approval'
}
if (part.state === 'approval-responded') {
return part.approval?.approved === false
? 'Approval rejected'
: 'Approval granted'
}
if (part.state === 'output-denied') {
return getCompactIssueLabel(part.approval?.reason) ?? 'Action denied'
}
if (part.state === 'output-error') {
return getCompactIssueLabel(part.errorText) ?? 'Action failed'
return 'Action failed'
}
if (part.state === 'output-available') {
const preview =
getCompactIssueLabel(getNestedText(part.output)) ??
getCompactIssueLabel(stringifyValue(part.output))
return preview ?? 'Completed successfully'
return 'Completed successfully'
}
if (part.state === 'input-available') {
@@ -128,16 +50,6 @@ function getPreviewText(part: ToolLikePart): string {
return 'Preparing action'
}
function getApproval(part: ToolLikePart): ExecutionStepApproval | undefined {
return part.approval
? {
id: part.approval.id,
approved: part.approval.approved,
reason: part.approval.reason,
}
: undefined
}
function getCompletedAt(
existingStep: ExecutionStepRecord | undefined,
state: ExecutionStepState,
@@ -166,7 +78,6 @@ function createStepRecord(
output: 'output' in part ? part.output : undefined,
errorText: 'errorText' in part ? part.errorText : undefined,
previewText: getPreviewText(part),
approval: getApproval(part),
}
}
@@ -208,8 +119,6 @@ export function normalizeExecutionSteps(args: {
assistantMessageId: assistantMessage?.id,
steps,
actionCount: steps.length,
approvalCount: steps.filter((step) => step.approval).length,
deniedCount: steps.filter((step) => step.state === 'output-denied').length,
errorCount: steps.filter((step) => step.state === 'output-error').length,
}
}

View File

@@ -8,17 +8,8 @@ export type ExecutionTaskStatus =
export type ExecutionStepState =
| 'input-streaming'
| 'input-available'
| 'approval-requested'
| 'approval-responded'
| 'output-available'
| 'output-error'
| 'output-denied'
export interface ExecutionStepApproval {
id: string
approved?: boolean
reason?: string
}
export interface ExecutionStepRecord {
id: string
@@ -31,7 +22,6 @@ export interface ExecutionStepRecord {
output?: unknown
errorText?: string
previewText: string
approval?: ExecutionStepApproval
}
export interface ExecutionTaskRecord {
@@ -46,8 +36,6 @@ export interface ExecutionTaskRecord {
responseText?: string
responsePreview?: string
actionCount: number
approvalCount: number
deniedCount: number
errorCount: number
steps: ExecutionStepRecord[]
}

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from 'bun:test'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
import { buildChatRequestBody } from './buildChatRequestBody'
const provider: LlmProviderConfig = {
@@ -16,19 +15,7 @@ const provider: LlmProviderConfig = {
}
describe('buildChatRequestBody', () => {
it('preserves approval config and browser context on approval resumes', () => {
const toolApprovalConfig: ToolApprovalConfig = {
categories: {
input: true,
navigation: true,
observation: true,
screenshots: true,
scripts: true,
'data-modification': true,
assistant: true,
},
}
it('omits unshipped governance controls from chat requests', () => {
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
@@ -43,16 +30,22 @@ describe('buildChatRequestBody', () => {
enabledMcpServers: ['slack'],
},
userSystemPrompt: 'Stay in the current tab.',
toolApprovalConfig,
aclRules: [
{
id: 'checkout',
sitePattern: 'https://example.com/*',
enabled: true,
},
],
toolApprovalConfig: { categories: { input: true } },
toolApprovalResponses: [
{
approvalId: 'approval-1',
approved: true,
},
],
})
} as Parameters<typeof buildChatRequestBody>[0])
expect(body.toolApprovalConfig).toEqual(toolApprovalConfig)
expect(body.browserContext).toEqual({
windowId: 2,
activeTab: {
@@ -62,23 +55,9 @@ describe('buildChatRequestBody', () => {
},
enabledMcpServers: ['slack'],
})
expect(body.toolApprovalResponses).toEqual([
{
approvalId: 'approval-1',
approved: true,
},
])
})
it('omits empty approval configs from requests', () => {
const body = buildChatRequestBody({
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
provider,
toolApprovalConfig: {
categories: {},
},
})
expect(body.toolApprovalConfig).toBeUndefined()
const bodyRecord = body as Record<string, unknown>
expect(bodyRecord.aclRules).toBeUndefined()
expect(bodyRecord.toolApprovalConfig).toBeUndefined()
expect(bodyRecord.toolApprovalResponses).toBeUndefined()
})
})

View File

@@ -1,13 +1,5 @@
import type { AclRule } from '@browseros/shared/types/acl'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
export interface ApprovalResponseData {
approvalId: string
approved: boolean
reason?: string
}
export interface ChatHistoryEntry {
role: 'user' | 'assistant'
@@ -44,26 +36,14 @@ interface ChatRequestBodyParams {
supportsImages?: boolean
previousConversation?: ChatHistoryEntry[] | string
declinedApps?: string[]
aclRules?: AclRule[]
selectedText?: string
selectedTextSource?: {
url: string
title: string
}
toolApprovalConfig?: ToolApprovalConfig
toolApprovalResponses?: ApprovalResponseData[]
isScheduledTask?: boolean
}
export const toRequestToolApprovalConfig = (
approvalConfig?: ToolApprovalConfig,
): ToolApprovalConfig | undefined => {
if (!approvalConfig) return undefined
return Object.values(approvalConfig.categories).some(Boolean)
? approvalConfig
: undefined
}
export const buildChatRequestBody = ({
conversationId,
provider,
@@ -75,11 +55,8 @@ export const buildChatRequestBody = ({
supportsImages,
previousConversation,
declinedApps,
aclRules,
selectedText,
selectedTextSource,
toolApprovalConfig,
toolApprovalResponses,
isScheduledTask,
}: ChatRequestBodyParams) => ({
message,
@@ -106,10 +83,7 @@ export const buildChatRequestBody = ({
supportsImages: supportsImages ?? provider.supportsImages,
previousConversation,
declinedApps: declinedApps?.length ? declinedApps : undefined,
aclRules: aclRules?.length ? aclRules : undefined,
selectedText,
selectedTextSource,
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
toolApprovalResponses,
isScheduledTask,
})

View File

@@ -1,84 +0,0 @@
import type { UIMessage } from 'ai'
import type { ApprovalResponse, PendingApproval } from './approval-sync-storage'
export function extractPendingApprovals(
messages: UIMessage[],
conversationId: string,
timestamp = Date.now(),
): PendingApproval[] {
const pending: PendingApproval[] = []
for (const msg of messages) {
for (const part of msg.parts) {
const toolPart = part as {
type?: string
state?: string
toolCallId?: string
input?: Record<string, unknown>
approval?: { id: string }
}
if (
toolPart.state === 'approval-requested' &&
toolPart.approval?.id &&
toolPart.toolCallId
) {
pending.push({
approvalId: toolPart.approval.id,
toolCallId: toolPart.toolCallId,
toolName: (toolPart.type ?? '').replace('tool-', ''),
input: toolPart.input ?? {},
conversationId,
timestamp,
})
}
}
}
return pending
}
export function replacePendingApprovalsForConversation(
existing: PendingApproval[],
conversationId: string,
next: PendingApproval[],
): PendingApproval[] {
const existingByApprovalId = new Map(
existing.map((item) => [item.approvalId, item]),
)
const preserved = next.map((item) => {
const current = existingByApprovalId.get(item.approvalId)
return current ? { ...item, timestamp: current.timestamp } : item
})
return [
...existing.filter((item) => item.conversationId !== conversationId),
...preserved,
]
}
export function queueApprovalResponse(
existing: ApprovalResponse[],
response: ApprovalResponse,
): ApprovalResponse[] {
return [
...existing.filter((item) => item.approvalId !== response.approvalId),
response,
]
}
export function removePendingApprovalsById(
existing: PendingApproval[],
approvalIds: string[],
): PendingApproval[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}
export function removeApprovalResponsesById(
existing: ApprovalResponse[],
approvalIds: string[],
): ApprovalResponse[] {
const ids = new Set(approvalIds)
return existing.filter((item) => !ids.has(item.approvalId))
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessage } from 'ai'
import {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
describe('approval sync storage helpers', () => {
it('extracts pending approvals from assistant tool parts', () => {
const messages = [
{
id: 'assistant-1',
role: 'assistant',
parts: [
{
type: 'tool-click',
state: 'approval-requested',
toolCallId: 'tool-1',
input: { selector: '#buy-now' },
approval: { id: 'approval-1' },
},
],
},
] as UIMessage[]
expect(extractPendingApprovals(messages, 'conversation-1', 123)).toEqual([
{
approvalId: 'approval-1',
toolCallId: 'tool-1',
toolName: 'click',
input: { selector: '#buy-now' },
conversationId: 'conversation-1',
timestamp: 123,
},
])
})
it('replaces pending approvals for one conversation without clearing others', () => {
const existing = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'navigate_page',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(
replacePendingApprovalsForConversation(existing, 'conversation-a', []),
).toEqual([existing[1]])
})
it('queues and removes approval responses by approval id', () => {
const queued = queueApprovalResponse(
[
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
],
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
)
expect(queued).toEqual([
{
approvalId: 'approval-a',
approved: true,
timestamp: 1,
},
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
expect(removeApprovalResponsesById(queued, ['approval-a'])).toEqual([
{
approvalId: 'approval-b',
approved: false,
timestamp: 2,
},
])
})
it('removes only handled pending approvals', () => {
const pending = [
{
approvalId: 'approval-a',
toolCallId: 'tool-a',
toolName: 'click',
input: {},
conversationId: 'conversation-a',
timestamp: 1,
},
{
approvalId: 'approval-b',
toolCallId: 'tool-b',
toolName: 'fill',
input: {},
conversationId: 'conversation-b',
timestamp: 2,
},
]
expect(removePendingApprovalsById(pending, ['approval-b'])).toEqual([
pending[0],
])
})
})

View File

@@ -1,47 +0,0 @@
import { storage } from '@wxt-dev/storage'
export {
extractPendingApprovals,
queueApprovalResponse,
removeApprovalResponsesById,
removePendingApprovalsById,
replacePendingApprovalsForConversation,
} from './approval-sync-helpers'
export interface PendingApproval {
approvalId: string
toolCallId: string
toolName: string
input: Record<string, unknown>
conversationId: string
timestamp: number
}
export interface ApprovalResponse {
approvalId: string
approved: boolean
reason?: string
timestamp: number
}
export interface ToolExecutionLogEntry {
toolCallId: string
toolName: string
status: 'auto-allowed' | 'approved' | 'denied' | 'error'
conversationId: string
timestamp: number
input?: Record<string, unknown>
}
export const pendingToolApprovalsStorage = storage.defineItem<
PendingApproval[]
>('local:pending-tool-approvals', { fallback: [] })
export const approvalResponsesStorage = storage.defineItem<ApprovalResponse[]>(
'local:approval-responses',
{ fallback: [] },
)
export const toolExecutionLogStorage = storage.defineItem<
ToolExecutionLogEntry[]
>('local:tool-execution-log', { fallback: [] })

View File

@@ -1,38 +0,0 @@
import { storage } from '@wxt-dev/storage'
import type { ToolApprovalCategoryId, ToolApprovalConfig } from './types'
export const toolApprovalConfigStorage = storage.defineItem<ToolApprovalConfig>(
'local:tool-approval-config',
{
fallback: {
categories: {},
},
},
)
const LEGACY_ALL_CATEGORY_IDS: ToolApprovalCategoryId[] = [
'input',
'navigation',
'screenshots',
'scripts',
'data-modification',
]
const NEW_CATEGORY_IDS: ToolApprovalCategoryId[] = ['observation', 'assistant']
export function normalizeToolApprovalConfig(
config: ToolApprovalConfig,
): ToolApprovalConfig {
const categories = { ...config.categories }
const shouldMigrateLegacyAll =
LEGACY_ALL_CATEGORY_IDS.every((id) => categories[id] === true) &&
NEW_CATEGORY_IDS.every((id) => categories[id] === undefined)
if (shouldMigrateLegacyAll) {
for (const id of NEW_CATEGORY_IDS) {
categories[id] = true
}
}
return { categories }
}

View File

@@ -1,7 +0,0 @@
export {
TOOL_APPROVAL_CATEGORIES as TOOL_CATEGORIES,
TOOL_APPROVAL_CATEGORIES,
type ToolApprovalCategory,
type ToolApprovalCategoryId,
type ToolApprovalConfig,
} from '@browseros/shared/constants/tool-approval'

View File

@@ -24,7 +24,6 @@
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
"test:tools:input": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/input.test.ts",
"test:cleanup": "./tests/__helpers__/cleanup.sh",

View File

@@ -6,7 +6,6 @@ import type {
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import type { AclRule } from '@browseros/shared/types/acl'
import {
type LanguageModel,
type ModelMessage,
@@ -50,7 +49,6 @@ export interface AiSdkAgentConfig {
klavisRef?: KlavisProxyRef
browserosId?: string
aiSdkDevtoolsEnabled?: boolean
aclRules?: AclRule[]
}
export class AiSdkAgent {
@@ -60,7 +58,6 @@ export class AiSdkAgent {
private _mcpClients: Array<{ close(): Promise<void> }>,
private conversationId: string,
private _toolNames: Set<string>,
private toolContext: ToolContext,
) {}
/** Tool names registered on this agent — used to sanitize messages during session rebuilds. */
@@ -102,13 +99,8 @@ export class AiSdkAgent {
origin: config.resolvedConfig.origin,
originPageId,
},
aclRules: config.aclRules,
}
const allBrowserTools = buildBrowserToolSet(
config.registry,
toolContext,
config.resolvedConfig.toolApprovalConfig,
)
const allBrowserTools = buildBrowserToolSet(config.registry, toolContext)
const browserTools = config.resolvedConfig.chatMode
? Object.fromEntries(
Object.entries(allBrowserTools).filter(([name]) =>
@@ -292,7 +284,6 @@ export class AiSdkAgent {
clients,
config.resolvedConfig.conversationId,
new Set(Object.keys(tools)),
toolContext,
)
}
@@ -316,10 +307,6 @@ export class AiSdkAgent {
})
}
updateAclRules(rules?: AclRule[]): void {
this.toolContext.aclRules = rules
}
async dispose(): Promise<void> {
for (const client of this._mcpClients) {
await client.close().catch(() => {})

View File

@@ -172,9 +172,6 @@ function estimateAssistantContent(content: AssistantContent): {
images += estimate.images
break
}
case 'tool-approval-request':
chars += part.approvalId.length + part.toolCallId.length
break
case 'file':
images++
break
@@ -196,11 +193,6 @@ function estimateToolContent(content: ToolContent): {
const estimate = estimateToolResultOutput(part.output)
chars += estimate.chars
images += estimate.images
} else {
chars += part.approvalId.length
if (part.reason) {
chars += part.reason.length
}
}
}

View File

@@ -11,8 +11,6 @@ export interface AgentSession {
mcpServerKey?: string
/** Workspace directory when the session was created, for change detection. */
workingDir?: string
/** Tool approval category key for change detection. */
approvalConfigKey?: string
}
export class SessionStore {

View File

@@ -1,5 +1,4 @@
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider'
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
import { type ToolSet, tool } from 'ai'
import { logger } from '../lib/logger'
import { metrics } from '../lib/metrics'
@@ -35,21 +34,9 @@ function contentToModelOutput(
}
}
export function getApprovedBrowserToolNames(
registry: ToolRegistry,
approvalConfig?: ToolApprovalConfig,
): string[] {
if (!approvalConfig) return []
return registry
.all()
.filter((def) => approvalConfig.categories[def.approvalCategory] === true)
.map((def) => def.name)
}
export function buildBrowserToolSet(
registry: ToolRegistry,
ctx: ToolContext,
approvalConfig?: ToolApprovalConfig,
): ToolSet {
const toolSet: ToolSet = {}
@@ -57,7 +44,6 @@ export function buildBrowserToolSet(
toolSet[def.name] = tool({
description: def.description,
inputSchema: def.input,
needsApproval: approvalConfig?.categories[def.approvalCategory] === true,
execute: async (params) => {
const startTime = performance.now()
try {

View File

@@ -3,7 +3,6 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
import type { LLMProvider } from '@browseros/shared/schemas/llm'
export interface ProviderConfig {
@@ -51,6 +50,4 @@ export interface ResolvedAgentConfig {
origin?: 'sidepanel' | 'newtab'
/** BrowserOS installation ID for credit-based tracking. */
browserosId?: string
/** Tool approval configuration — which categories require human approval. */
toolApprovalConfig?: ToolApprovalConfig
}

View File

@@ -1,36 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
const AclRuleSchema = z.object({
id: z.string(),
sitePattern: z.string(),
selector: z.string().optional(),
textMatch: z.string().optional(),
description: z.string().optional(),
enabled: z.boolean(),
})
const PutAclRulesSchema = z.object({
aclRules: z.array(AclRuleSchema),
})
interface AclRouteDeps {
policyService: GlobalAclPolicyService
}
export function createAclRoutes(deps: AclRouteDeps) {
return new Hono()
.get('/', async (c) => {
return c.json({ aclRules: deps.policyService.getRules() })
})
.put('/', zValidator('json', PutAclRulesSchema), async (c) => {
const { aclRules } = c.req.valid('json')
const savedRules = await deps.policyService.setRules(
aclRules as AclRule[],
)
return c.json({ aclRules: savedRules })
})
}

View File

@@ -39,11 +39,13 @@ import {
MessageQueueFullError,
type OpenClawProvisioner,
OpenClawProvisionerUnavailableError,
type ProducedFileEntry,
type ProducedFilesRailGroup,
type QueuedMessage,
TurnAlreadyActiveError,
UnknownAgentError,
} from '../services/agents/agent-harness-service'
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
import type { FilePreview } from '../services/openclaw/file-preview'
import type { Env } from '../types'
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
@@ -95,6 +97,23 @@ type AgentRouteService = {
messageId: string
}): Promise<boolean>
listQueuedMessages(agentId: string): Promise<QueuedMessage[]>
// Files API — Phase 3 of TKT-762.
listAgentFiles(
agentId: string,
options?: { limit?: number },
): Promise<ProducedFilesRailGroup[]>
listAgentFilesForTurn(
agentId: string,
turnId: string,
): Promise<ProducedFileEntry[]>
previewProducedFile(fileId: string): Promise<FilePreview | null>
resolveProducedFileForDownload(fileId: string): Promise<{
absolutePath: string
fileName: string
mimeType: string
size: number
} | null>
}
type AgentRouteDeps = {
@@ -109,18 +128,19 @@ type AgentRouteDeps = {
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. Enables the image-attachment carve-out for OpenClaw
* agents — image-bearing turns route through the gateway HTTP
* `/v1/chat/completions` instead of the ACP bridge (which drops
* image content blocks).
*/
openclawGatewayChat?: OpenClawGatewayChatClient
/**
* Required to dual-create/delete `openclaw` adapter agents on the
* gateway side. Without this, openclaw create requests fail with 503.
*/
openclawProvisioner?: OpenClawProvisioner
/** Optional override; defaults to a fresh in-memory checker. */
adapterHealth?: AdapterHealthChecker
/**
* Optional listener attached to the constructed harness. Receives
* turn lifecycle events for every running agent. Wired by the server
* to feed OpenClaw's ClawSession dashboard from the same stream the
* chat panel sees, so no second WS observer is needed.
*/
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
}
type SidepanelAgentChatRequest = {
@@ -139,267 +159,381 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
new AgentHarnessService({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawGatewayChat: deps.openclawGatewayChat,
openclawProvisioner: deps.openclawProvisioner,
})
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
service.onTurnLifecycle(deps.onTurnLifecycle)
}
// One checker per route mount. Cached probes refresh every 5min;
// tests can swap in an alternate via deps if needed.
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
return new Hono<Env>()
.get('/adapters', async (c) => {
const adapters = await Promise.all(
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
...descriptor,
health: await adapterHealth.getHealth(descriptor.id),
})),
)
return c.json({ adapters })
})
.get('/', async (c) => {
// Single round-trip the agents page consumes: enriched agents
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
// to fetch from `/claw/status`. Lets the page poll one endpoint.
const [agents, gateway] = await Promise.all([
service.listAgentsWithActivity(),
service.getGatewayStatus(),
])
return c.json({ agents, gateway })
})
.post('/', async (c) => {
const parsed = await parseCreateAgentBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
return c.json({ agent: await service.createAgent(parsed) })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/sidepanel/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseSidepanelAgentChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const agent = await service.getAgent(agentId)
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
let browserContext = parsed.browserContext
if (deps.browser) {
browserContext = await resolveBrowserContextPageIds(
deps.browser,
browserContext,
)
}
const userContent = formatUserMessage(
parsed.message,
browserContext,
parsed.selectedText,
parsed.selectedTextSource,
return (
new Hono<Env>()
.get('/adapters', async (c) => {
const adapters = await Promise.all(
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
...descriptor,
health: await adapterHealth.getHealth(descriptor.id),
})),
)
const message = parsed.userSystemPrompt?.trim()
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
: userContent
return c.json({ adapters })
})
.get('/', async (c) => {
// Single round-trip the agents page consumes: enriched agents
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
// to fetch from `/claw/status`. Lets the page poll one endpoint.
const [agents, gateway] = await Promise.all([
service.listAgentsWithActivity(),
service.getGatewayStatus(),
])
return c.json({ agents, gateway })
})
.post('/', async (c) => {
const parsed = await parseCreateAgentBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
return c.json({ agent: await service.createAgent(parsed) })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/sidepanel/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseSidepanelAgentChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const agent = await service.getAgent(agentId)
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
let browserContext = parsed.browserContext
if (deps.browser) {
browserContext = await resolveBrowserContextPageIds(
deps.browser,
browserContext,
)
}
const userContent = formatUserMessage(
parsed.message,
browserContext,
parsed.selectedText,
parsed.selectedTextSource,
)
const message = parsed.userSystemPrompt?.trim()
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
: userContent
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
try {
started = await service.startTurn({
agentId: agent.id,
message,
cwd: parsed.userWorkingDir,
})
} catch (err) {
if (err instanceof TurnAlreadyActiveError) {
return c.json(
{
error: 'Turn already active',
turnId: err.turnId,
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
},
409,
)
}
throw err
}
let didRequestCancel = false
const cancelStartedTurn = () => {
if (didRequestCancel) return
didRequestCancel = true
service.cancelTurn({
agentId: agent.id,
turnId: started.turnId,
reason: 'sidepanel stream cancelled',
})
}
if (c.req.raw.signal.aborted) {
cancelStartedTurn()
} else {
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
once: true,
})
}
const events = turnFramesToAgentEvents(started.frames, {
onCancel: cancelStartedTurn,
})
return createAcpUIMessageStreamResponse(events, {
headers: {
'X-Session-Id': 'main',
'X-Turn-Id': started.turnId,
},
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId', async (c) => {
try {
const agent = await service.getAgent(c.req.param('agentId'))
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
return c.json({ agent })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.delete('/:agentId', async (c) => {
try {
return c.json({
success: await service.deleteAgent(c.req.param('agentId')),
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.patch('/:agentId', async (c) => {
const parsed = await parseAgentPatchBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const agent = await service.updateAgent(
c.req.param('agentId'),
parsed.patch,
)
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
return c.json({ agent })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId/sessions/main/history', async (c) => {
try {
return c.json(await service.getHistory(c.req.param('agentId')))
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
try {
started = await service.startTurn({
agentId: agent.id,
message,
cwd: parsed.userWorkingDir,
agentId,
message: parsed.message,
attachments: parsed.attachments,
cwd: parsed.cwd,
})
} catch (err) {
if (err instanceof TurnAlreadyActiveError) {
// Caller can attach via GET /chat/stream?turnId=… instead.
return c.json(
{
error: 'Turn already active',
turnId: err.turnId,
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
},
409,
)
}
throw err
return handleAgentRouteError(c, err)
}
let didRequestCancel = false
const cancelStartedTurn = () => {
if (didRequestCancel) return
didRequestCancel = true
service.cancelTurn({
agentId: agent.id,
turnId: started.turnId,
reason: 'sidepanel stream cancelled',
})
}
if (c.req.raw.signal.aborted) {
cancelStartedTurn()
} else {
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
once: true,
})
}
const events = turnFramesToAgentEvents(started.frames, {
onCancel: cancelStartedTurn,
return streamTurnFrames(c, started.frames, {
turnId: started.turnId,
})
return createAcpUIMessageStreamResponse(events, {
headers: {
'X-Session-Id': 'main',
'X-Turn-Id': started.turnId,
},
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId', async (c) => {
try {
const agent = await service.getAgent(c.req.param('agentId'))
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
return c.json({ agent })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.delete('/:agentId', async (c) => {
try {
return c.json({
success: await service.deleteAgent(c.req.param('agentId')),
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.patch('/:agentId', async (c) => {
const parsed = await parseAgentPatchBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const agent = await service.updateAgent(
c.req.param('agentId'),
parsed.patch,
)
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
return c.json({ agent })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId/sessions/main/history', async (c) => {
try {
return c.json(await service.getHistory(c.req.param('agentId')))
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
try {
started = await service.startTurn({
agentId,
message: parsed.message,
attachments: parsed.attachments,
cwd: parsed.cwd,
})
} catch (err) {
if (err instanceof TurnAlreadyActiveError) {
// Caller can attach via GET /chat/stream?turnId=… instead.
return c.json(
{
error: 'Turn already active',
turnId: err.turnId,
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
},
409,
)
}
return handleAgentRouteError(c, err)
}
return streamTurnFrames(c, started.frames, {
turnId: started.turnId,
})
})
.get('/:agentId/chat/active', (c) => {
const agentId = c.req.param('agentId')
const info = service.getActiveTurn(agentId, 'main')
return c.json({ active: info })
})
.get('/:agentId/chat/stream', (c) => {
const agentId = c.req.param('agentId')
const url = new URL(c.req.url)
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
const turnId =
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
if (!turnId) {
return c.json({ error: 'No active turn for this agent' }, 404)
}
const lastEventId =
c.req.header('Last-Event-ID') ??
url.searchParams.get('lastSeq') ??
undefined
const lastSeq = parseLastSeq(lastEventId)
const frames = service.attachTurn({ turnId, lastSeq })
if (!frames) {
return c.json({ error: 'Unknown turn' }, 404)
}
return streamTurnFrames(c, frames, { turnId })
})
.post('/:agentId/chat/cancel', async (c) => {
const agentId = c.req.param('agentId')
const body = await readJsonBody(c)
const turnId =
'value' in body && typeof body.value.turnId === 'string'
? body.value.turnId.trim() || undefined
: undefined
const reason =
'value' in body && typeof body.value.reason === 'string'
? body.value.reason
: undefined
const cancelled = service.cancelTurn({ agentId, turnId, reason })
return c.json({ cancelled })
})
.get('/:agentId/queue', async (c) => {
try {
const queue = await service.listQueuedMessages(c.req.param('agentId'))
return c.json({ queue })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/queue', async (c) => {
const parsed = await parseEnqueueBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const queued = await service.enqueueMessage({
agentId: c.req.param('agentId'),
message: parsed.message,
attachments: parsed.attachments,
})
return c.json({ queued })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.delete('/:agentId/queue/:messageId', async (c) => {
try {
const removed = await service.removeQueuedMessage({
agentId: c.req.param('agentId'),
messageId: c.req.param('messageId'),
})
if (!removed) return c.json({ error: 'Queued message not found' }, 404)
return c.json({ removed })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId/chat/active', (c) => {
const agentId = c.req.param('agentId')
const info = service.getActiveTurn(agentId, 'main')
return c.json({ active: info })
})
.get('/:agentId/chat/stream', (c) => {
const agentId = c.req.param('agentId')
const url = new URL(c.req.url)
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
const turnId =
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
if (!turnId) {
return c.json({ error: 'No active turn for this agent' }, 404)
}
const lastEventId =
c.req.header('Last-Event-ID') ??
url.searchParams.get('lastSeq') ??
undefined
const lastSeq = parseLastSeq(lastEventId)
const frames = service.attachTurn({ turnId, lastSeq })
if (!frames) {
return c.json({ error: 'Unknown turn' }, 404)
}
return streamTurnFrames(c, frames, { turnId })
})
.post('/:agentId/chat/cancel', async (c) => {
const agentId = c.req.param('agentId')
const body = await readJsonBody(c)
const turnId =
'value' in body && typeof body.value.turnId === 'string'
? body.value.turnId.trim() || undefined
: undefined
const reason =
'value' in body && typeof body.value.reason === 'string'
? body.value.reason
: undefined
const cancelled = service.cancelTurn({ agentId, turnId, reason })
return c.json({ cancelled })
})
.get('/:agentId/queue', async (c) => {
try {
const queue = await service.listQueuedMessages(c.req.param('agentId'))
return c.json({ queue })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/queue', async (c) => {
const parsed = await parseEnqueueBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
const queued = await service.enqueueMessage({
agentId: c.req.param('agentId'),
message: parsed.message,
attachments: parsed.attachments,
})
return c.json({ queued })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.delete('/:agentId/queue/:messageId', async (c) => {
try {
const removed = await service.removeQueuedMessage({
agentId: c.req.param('agentId'),
messageId: c.req.param('messageId'),
})
if (!removed)
return c.json({ error: 'Queued message not found' }, 404)
return c.json({ removed })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
// ── Files (TKT-762) ────────────────────────────────────────────
//
// V1 surfaces files OpenClaw agents produce inside their workspace
// dir (`~/.browseros/vm/openclaw/.openclaw/workspace[-<name>]/`)
// as outputs, attributed back to the chat turn that produced them
// by the per-turn workspace diff in
// `agent-harness-service.runDetachedTurn`. Adapter-gated to
// openclaw on the service side; for claude / codex these endpoints
// simply return empty lists.
//
// The file-id-scoped endpoints (`/files/:fileId/{preview,download}`)
// accept an opaque `fileId` and resolve the on-disk path
// server-side, so the client never sees a raw path and traversal
// is impossible by construction.
.get('/:agentId/files', async (c) => {
try {
const groups = await service.listAgentFiles(
c.req.param('agentId'),
parseAgentFilesLimit(c.req.query('limit')),
)
return c.json({ groups })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId/files/turn/:turnId', async (c) => {
try {
const files = await service.listAgentFilesForTurn(
c.req.param('agentId'),
c.req.param('turnId'),
)
return c.json({ files })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/files/:fileId/preview', async (c) => {
try {
const preview = await service.previewProducedFile(
c.req.param('fileId'),
)
if (!preview || preview.kind === 'missing') {
return c.json({ error: 'File not found' }, 404)
}
return c.json(preview)
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/files/:fileId/download', async (c) => {
try {
const resolved = await service.resolveProducedFileForDownload(
c.req.param('fileId'),
)
if (!resolved) return c.json({ error: 'File not found' }, 404)
// Stream raw bytes via Bun's lazy file handle. Sets
// Content-Disposition so browsers save instead of preview.
const file = Bun.file(resolved.absolutePath)
return new Response(file.stream(), {
headers: {
'Content-Type': resolved.mimeType,
'Content-Length': String(resolved.size),
'Content-Disposition': `attachment; ${encodeRfc6266Filename(resolved.fileName)}`,
'Cache-Control': 'no-store',
},
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
)
}
/** Hard cap on `?limit=` for /agents/:id/files — guards against
* a caller-supplied huge value forcing a per-agent table scan. */
const MAX_FILES_LIMIT = 500
/**
* Parse + clamp the `limit` query for /agents/:id/files. Returns
* `undefined` when the param is absent or unparseable so the
* service falls back to its own default.
*/
function parseAgentFilesLimit(
raw: string | undefined,
): { limit: number } | undefined {
if (!raw) return undefined
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return undefined
return { limit: Math.min(Math.max(1, parsed), MAX_FILES_LIMIT) }
}
/**
* RFC 6266 / RFC 5987 filename attributes for `Content-Disposition`.
* Returns the `filename="..."` attribute (always) plus a
* percent-encoded `filename*=UTF-8''…` attribute when the name
* contains non-ASCII characters, so browsers download with the
* original name even on stricter HTTP clients.
*/
function encodeRfc6266Filename(filename: string): string {
// Strip CRLFs and quotes (header injection guard).
const safe = filename.replace(/["\r\n]/g, '_')
// Detect non-ASCII; emit the RFC 5987 fallback attribute when
// present. `encodeURIComponent` is the standard browser-safe
// percent-encoder for this purpose.
const hasNonAscii = /[^ -~]/.test(safe)
if (!hasNonAscii) return `filename="${safe}"`
return `filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`
}
function turnFramesToAgentEvents(

View File

@@ -12,8 +12,6 @@ import { metrics } from '../../lib/metrics'
import { Sentry } from '../../lib/sentry'
import { getMonitoringService } from '../../monitoring/service'
import type { ToolRegistry } from '../../tools/tool-registry'
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
import type { KlavisProxyRef } from '../services/klavis/strata-proxy'
import { createMcpServer } from '../services/mcp/mcp-server'
import type { Env } from '../types'
@@ -24,7 +22,6 @@ interface McpRouteDeps {
browser: Browser
executionDir: string
resourcesDir: string
policyService: GlobalAclPolicyService
klavisRef?: KlavisProxyRef
}
@@ -49,9 +46,6 @@ export function createMcpRoutes(deps: McpRouteDeps) {
monitoringService.resolveSessionForMcpRequest(explicitAgentId)
const agentId = activeSession?.agentId
metrics.log('mcp.request', { scopeId })
const aclRules = await resolveAclPolicyForMcpRequest({
policyService: deps.policyService,
})
const monitoringSessionId = activeSession?.monitoringSessionId
const observer =
monitoringSessionId && agentId
@@ -62,7 +56,6 @@ export function createMcpRoutes(deps: McpRouteDeps) {
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
const mcpServer = createMcpServer({
...deps,
aclRules,
observer,
})
const transport = new StreamableHTTPTransport({

View File

@@ -23,7 +23,6 @@ import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
import { createAclRoutes } from './routes/acl'
import { createAgentRoutes } from './routes/agents'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
@@ -41,12 +40,11 @@ import { createSkillsRoutes } from './routes/skills'
import { createSoulRoutes } from './routes/soul'
import { createStatusRoute } from './routes/status'
import { createTerminalRoutes } from './routes/terminal'
import { GlobalAclPolicyService } from './services/acl/global-acl-policy'
import {
connectKlavisInBackground,
type KlavisProxyRef,
} from './services/klavis/strata-proxy'
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
import { getOpenClawService } from './services/openclaw/openclaw-service'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
@@ -93,9 +91,6 @@ export async function createHttpServer(config: HttpServerConfig) {
: null
if (!browserosId) shutdownOAuth()
const aclPolicyService = new GlobalAclPolicyService()
await aclPolicyService.load()
// Connect Klavis proxy in background with retry — browser tools available immediately
const klavisRef: KlavisProxyRef = { handle: null }
const stopKlavisBackground = browserosId
@@ -121,10 +116,6 @@ export async function createHttpServer(config: HttpServerConfig) {
}),
)
const aclRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createAclRoutes({ policyService: aclPolicyService }))
const monitoringRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createMonitoringRoutes())
@@ -137,16 +128,11 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosServerPort: port,
browser,
openclawGateway: {
getGatewayToken: () => getOpenClawService().getGatewayToken(),
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
getLimaHomeDir: () => getLimaHomeDir(),
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
getVmName: () => VM_NAME,
},
openclawGatewayChat: new OpenClawGatewayChatClient(
() => getOpenClawService().getPort(),
async () => getOpenClawService().getGatewayToken(),
),
openclawProvisioner: {
createAgent: (input) => getOpenClawService().createAgent(input),
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
@@ -159,6 +145,23 @@ export async function createHttpServer(config: HttpServerConfig) {
}))
},
getStatus: () => getOpenClawService().getStatus(),
getAgentHistory: async (agentId) => {
// Aggregated across the agent's main + every sub-session
// (cron / hook / channel) so autonomous turns surface in
// the chat panel alongside user-initiated ones.
const raw = await getOpenClawService().getSessionHistory(
`agent:${agentId}:main`,
)
return convertOpenClawHistoryToAgentHistory(agentId, raw)
},
},
onTurnLifecycle: (agent, event) => {
if (agent.adapter !== 'openclaw') return
getOpenClawService().recordAgentTurnEvent(
agent.id,
agent.sessionKey,
event,
)
},
}),
)
@@ -186,7 +189,6 @@ export async function createHttpServer(config: HttpServerConfig) {
.route('/memory', createMemoryRoutes())
.route('/skills', createSkillsRoutes())
.route('/monitoring', monitoringRoutes)
.route('/acl-rules', aclRoutes)
.route('/test-provider', createProviderRoutes({ browserosId }))
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))
.route(
@@ -215,7 +217,6 @@ export async function createHttpServer(config: HttpServerConfig) {
browser,
executionDir,
resourcesDir,
policyService: aclPolicyService,
klavisRef,
}),
)

View File

@@ -1,61 +0,0 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { AclRule } from '@browseros/shared/types/acl'
import { getBrowserosDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
const ACL_RULES_FILE_NAME = 'acl-rules.json'
type StoredAclRules = {
aclRules?: AclRule[]
}
function cloneRules(rules: AclRule[]): AclRule[] {
return rules.map((rule) => ({ ...rule }))
}
export class GlobalAclPolicyService {
private rules: AclRule[] = []
readonly filePath = join(getBrowserosDir(), ACL_RULES_FILE_NAME)
async load(): Promise<void> {
try {
const raw = await readFile(this.filePath, 'utf8')
const parsed = JSON.parse(raw) as StoredAclRules
this.rules = this.normalizeRules(parsed.aclRules ?? [])
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('Failed to load global ACL rules, starting empty', {
error: error instanceof Error ? error.message : String(error),
filePath: this.filePath,
})
}
this.rules = []
}
}
getRules(): AclRule[] {
return cloneRules(this.rules)
}
getEnabledRules(): AclRule[] {
return cloneRules(this.rules.filter((rule) => rule.enabled))
}
async setRules(rules: AclRule[]): Promise<AclRule[]> {
this.rules = this.normalizeRules(rules)
await mkdir(dirname(this.filePath), { recursive: true })
const tempPath = `${this.filePath}.tmp`
const content = `${JSON.stringify({ aclRules: this.rules }, null, 2)}\n`
await writeFile(tempPath, content, 'utf8')
await rename(tempPath, this.filePath)
return this.getRules()
}
private normalizeRules(rules: AclRule[]): AclRule[] {
return cloneRules(rules)
}
}

View File

@@ -1,8 +0,0 @@
import type { AclRule } from '@browseros/shared/types/acl'
import type { GlobalAclPolicyService } from './global-acl-policy'
export async function resolveAclPolicyForMcpRequest(input: {
policyService: GlobalAclPolicyService
}): Promise<AclRule[]> {
return input.policyService.getEnabledRules()
}

View File

@@ -31,14 +31,26 @@ export {
type QueuedMessageAttachment,
} from '../../../lib/agents/message-queue'
import { basename } from 'node:path'
import type {
AgentHistoryPage,
AgentRowSnapshot,
AgentRuntime,
AgentStreamEvent,
} from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
import {
buildFilePreview,
detectMimeType,
type FilePreview,
} from '../openclaw/file-preview'
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
import {
type FileSnapshot,
type ProducedFileRow,
ProducedFilesStore,
} from '../openclaw/produced-files-store'
export type AgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
@@ -120,6 +132,15 @@ export interface OpenClawProvisioner {
* gateway is not configured at all).
*/
getStatus?(): Promise<GatewayStatusSnapshot | null>
/**
* Optional. When wired, the harness uses this for `getHistory` on
* openclaw-adapter agents so the chat panel sees autonomous
* (cron / hook / channel) turns alongside user-typed turns. Without
* this, history reads come from AcpxRuntime's local session record
* which only contains user-initiated turns — autonomous activity
* fires correctly but stays invisible to the panel.
*/
getAgentHistory?(agentId: string): Promise<AgentHistoryPage>
}
/**
@@ -152,12 +173,41 @@ export interface GatewayStatusSnapshot {
| null
}
/**
* Per-turn event the harness emits to subscribers. Lets services that
* want to track liveness for a specific adapter (e.g. OpenClaw's
* ClawSession dashboard) react to the same stream the chat panel sees,
* without each adapter spawning its own gateway-side observer.
*/
export type TurnLifecycleEvent =
| { type: 'turn_started' }
| { type: 'turn_event'; event: AgentStreamEvent }
| { type: 'turn_ended'; error?: string }
export type TurnLifecycleListener = (
agent: {
id: string
adapter: AgentDefinition['adapter']
sessionKey: string
},
event: TurnLifecycleEvent,
) => void
export class AgentHarnessService {
private readonly agentStore: AgentStore
private readonly runtime: AgentRuntime
private readonly openclawProvisioner: OpenClawProvisioner | null
private readonly turnRegistry: TurnRegistry
private readonly messageQueue: FileMessageQueue
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
/**
* Lazy-initialised so tests that swap in a fake `agentStore` don't
* eagerly hit `getDb()` (which throws when the test harness hasn't
* called `initializeDb`). Tests that exercise file attribution can
* inject an explicit store via `deps.producedFilesStore`.
*/
private explicitProducedFilesStore: ProducedFilesStore | null = null
private cachedProducedFilesStore: ProducedFilesStore | null = null
private inFlightReconcile: Promise<void> | null = null
// In-memory liveness tracker. Lost on server restart (acceptable —
// `lastUsedAt` survives via the acpx session record's `lastUsedAt`,
@@ -174,10 +224,10 @@ export class AgentHarnessService {
runtime?: AgentRuntime
browserosServerPort?: number
openclawGateway?: OpenclawGatewayAccessor
openclawGatewayChat?: OpenClawGatewayChatClient
openclawProvisioner?: OpenClawProvisioner
turnRegistry?: TurnRegistry
messageQueue?: FileMessageQueue
producedFilesStore?: ProducedFilesStore
} = {},
) {
this.agentStore = deps.agentStore ?? new DbAgentStore()
@@ -186,11 +236,13 @@ export class AgentHarnessService {
new AcpxRuntime({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawGatewayChat: deps.openclawGatewayChat,
})
this.openclawProvisioner = deps.openclawProvisioner ?? null
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
if (deps.producedFilesStore) {
this.explicitProducedFilesStore = deps.producedFilesStore
}
// Drain any agents whose queue file survived a restart. The check
// for `getActiveFor` inside `maybeStartNextFromQueue` guards
// against double-firing if the in-memory turn registry happens to
@@ -314,6 +366,39 @@ export class AgentHarnessService {
}
}
/**
* Subscribe to turn lifecycle events for every running agent. Returns
* an unsubscribe function. Listeners are best-effort: a throwing
* listener does not break the turn.
*/
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
this.turnLifecycleListeners.add(listener)
return () => this.turnLifecycleListeners.delete(listener)
}
private emitTurnLifecycle(
agent: AgentDefinition,
event: TurnLifecycleEvent,
): void {
if (this.turnLifecycleListeners.size === 0) return
const summary = {
id: agent.id,
adapter: agent.adapter,
sessionKey: agent.sessionKey,
}
for (const listener of this.turnLifecycleListeners) {
try {
listener(summary, event)
} catch (err) {
logger.warn('Turn lifecycle listener threw', {
agentId: agent.id,
eventType: event.type,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/** Mark `agentId` as actively running a turn. */
notifyTurnStarted(agentId: string): void {
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
@@ -599,9 +684,112 @@ export class AgentHarnessService {
async getHistory(agentId: string): Promise<AgentHistoryPage> {
const agent = await this.requireAgent(agentId)
// OpenClaw agents persist conversation in the gateway, not in the
// AcpxRuntime's local session record. Reading the local record
// would miss autonomous (cron / hook / channel) turns. Route
// through the provisioner so the panel sees the full history.
if (
agent.adapter === 'openclaw' &&
this.openclawProvisioner?.getAgentHistory
) {
return this.openclawProvisioner.getAgentHistory(agentId)
}
return this.runtime.getHistory({ agent, sessionId: 'main' })
}
// ── Produced files (Files rail / inline artifact card) ───────────
/**
* Outputs-rail data for one agent. Returns groups of files keyed
* by the assistant turn that produced them, newest first. Empty
* array when the agent hasn't produced anything yet, or when the
* adapter doesn't track outputs (claude / codex — see Phase 2
* commit).
*/
async listAgentFiles(
agentId: string,
options: { limit?: number } = {},
): Promise<ProducedFilesRailGroup[]> {
const agent = await this.requireAgent(agentId)
const store = this.tryGetProducedFilesStore()
if (!store) return []
const rows = await store.listByAgent(agent.id, options)
return store
.groupByTurn(rows)
.map(({ turnId, turnPrompt, createdAt, files }) => ({
turnId,
turnPrompt,
createdAt,
files: files.map(toProducedFileEntry),
}))
}
/**
* Inline-card data for one assistant turn. Used by the SSE
* `produced_files` event consumer to refresh metadata after the
* turn completes; also handy for direct fetches by clients that
* missed the live event.
*/
async listAgentFilesForTurn(
agentId: string,
turnId: string,
): Promise<ProducedFileEntry[]> {
await this.requireAgent(agentId)
const store = this.tryGetProducedFilesStore()
if (!store) return []
const rows = await store.listByTurn(turnId)
return rows.map(toProducedFileEntry)
}
/**
* Build a preview payload for a single file. Returns null when the
* file id is unknown OR the on-disk path no longer exists. The
* route layer maps null → 404.
*/
async previewProducedFile(fileId: string): Promise<FilePreview | null> {
const store = this.tryGetProducedFilesStore()
if (!store) return null
const row = await store.findById(fileId)
if (!row) return null
const agent = await this.agentStore.get(row.agentDefinitionId)
if (!agent || agent.adapter !== 'openclaw') return null
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
if (!resolved) return null
return buildFilePreview(resolved.absolutePath)
}
/**
* Resolve a file id to an absolute on-disk path + metadata for the
* download route to stream. Null when the file id is unknown or
* the path escaped the workspace root (containment check happens
* inside `producedFilesStore.resolveFilePath`).
*/
async resolveProducedFileForDownload(fileId: string): Promise<{
absolutePath: string
fileName: string
mimeType: string
size: number
} | null> {
const store = this.tryGetProducedFilesStore()
if (!store) return null
const row = await store.findById(fileId)
if (!row) return null
const agent = await this.agentStore.get(row.agentDefinitionId)
if (!agent || agent.adapter !== 'openclaw') return null
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
if (!resolved) return null
const mimeType = await detectMimeType(resolved.absolutePath)
const fileName = basename(row.path)
return {
absolutePath: resolved.absolutePath,
fileName,
mimeType,
size: row.size,
}
}
/**
* Kick off a new agent turn that survives the caller's HTTP lifetime.
* Events are pushed into a per-turn buffer; the returned `frames`
@@ -627,6 +815,7 @@ export class AgentHarnessService {
prompt: input.message,
})
this.notifyTurnStarted(agent.id)
this.emitTurnLifecycle(agent, { type: 'turn_started' })
// Kick off the runtime call in the background. The per-turn
// AbortController — NOT the HTTP request signal — is what cancels
@@ -728,6 +917,26 @@ export class AgentHarnessService {
const turn = this.turnRegistry.get(turnId)
if (!turn) return
let lastErrorMessage: string | undefined
// Bracket openclaw turns with a workspace snapshot so any file the
// agent produces during the turn is attributable back to it (rail
// + inline artifact UX). Adapter-gated for v1 — Claude / Codex
// write to the user's host filesystem and don't need this; their
// outputs are already visible via the user's own tools.
const isOpenclaw = agent.adapter === 'openclaw'
const workspaceDir = isOpenclaw ? this.resolveSafeWorkspaceDir(agent) : null
const producedFilesStore = workspaceDir
? this.tryGetProducedFilesStore()
: null
const workspaceSnapshot =
workspaceDir && producedFilesStore
? await this.snapshotWorkspaceForTurn(
agent,
workspaceDir,
producedFilesStore,
)
: null
try {
const upstream = await this.runtime.send({
agent,
@@ -746,6 +955,7 @@ export class AgentHarnessService {
if (done) break
if (value.type === 'error') lastErrorMessage = value.message
this.turnRegistry.pushEvent(turnId, value)
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
}
} finally {
try {
@@ -782,10 +992,141 @@ export class AgentHarnessService {
})
}
} finally {
// Attribute any files the agent produced during this turn. We
// run on success, error, AND inside `finally` so an upstream
// failure mid-turn that still managed to write files doesn't
// lose them. We skip only when the user explicitly cancelled —
// in that case the side effects shouldn't be surfaced as
// "outputs you asked for."
if (
workspaceDir &&
workspaceSnapshot !== null &&
producedFilesStore &&
!turn.abortController.signal.aborted
) {
await this.attributeTurnFiles({
producedFilesStore,
workspaceDir,
before: workspaceSnapshot,
agent,
turnId,
turnPrompt: input.message,
})
}
this.notifyTurnEnded(agent.id, {
ok: lastErrorMessage === undefined,
error: lastErrorMessage,
})
this.emitTurnLifecycle(agent, {
type: 'turn_ended',
error: lastErrorMessage,
})
}
}
/**
* Compute the host-side workspace dir for an openclaw agent,
* returning `null` when the agent's display name fails the
* path-traversal guard. Logs a warning so the safety-disabled
* case is observable in production.
*/
private resolveSafeWorkspaceDir(agent: AgentDefinition): string | null {
try {
return getHostWorkspaceDir(getOpenClawDir(), agent.name)
} catch (err) {
logger.warn('Skipping openclaw file attribution: unsafe agent name', {
agentId: agent.id,
agentName: agent.name,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
/**
* Pre-turn workspace snapshot. Returns `null` on any failure so
* the rest of the turn flow continues without file attribution.
*/
private async snapshotWorkspaceForTurn(
agent: AgentDefinition,
workspaceDir: string,
producedFilesStore: ProducedFilesStore,
): Promise<FileSnapshot | null> {
try {
return await producedFilesStore.snapshotWorkspace(workspaceDir)
} catch (err) {
logger.warn(
'Failed to snapshot openclaw workspace; file attribution disabled for this turn',
{
agentId: agent.id,
workspaceDir,
error: err instanceof Error ? err.message : String(err),
},
)
return null
}
}
/**
* Lazily resolve the produced-files store. Returns `null` if the
* SQLite handle isn't initialised yet — keeps the harness usable in
* tests + during early server boot, where chat turns are unlikely
* but allowed.
*/
private tryGetProducedFilesStore(): ProducedFilesStore | null {
if (this.explicitProducedFilesStore) return this.explicitProducedFilesStore
if (this.cachedProducedFilesStore) return this.cachedProducedFilesStore
try {
this.cachedProducedFilesStore = new ProducedFilesStore()
return this.cachedProducedFilesStore
} catch (err) {
logger.warn(
'Produced-files store unavailable; turn-level file attribution disabled',
{ error: err instanceof Error ? err.message : String(err) },
)
return null
}
}
/**
* Diff the workspace, persist new/modified files, and emit a
* `produced_files` event so subscribers can render the inline
* artifact card. Tolerant of all errors — a failure here must
* never block the rest of the turn-end bookkeeping.
*/
private async attributeTurnFiles(input: {
producedFilesStore: ProducedFilesStore
workspaceDir: string
before: FileSnapshot
agent: AgentDefinition
turnId: string
turnPrompt: string
}): Promise<void> {
try {
const rows = await input.producedFilesStore.finalizeTurn({
agentDefinitionId: input.agent.id,
sessionKey: input.agent.sessionKey,
turnId: input.turnId,
turnPrompt: input.turnPrompt,
workspaceDir: input.workspaceDir,
before: input.before,
})
if (rows.length === 0) return
this.turnRegistry.pushEvent(input.turnId, {
type: 'produced_files',
files: rows.map((row) => ({
id: row.id,
path: row.path,
size: row.size,
mtimeMs: row.mtimeMs,
})),
})
} catch (err) {
logger.warn('Failed to attribute produced files for turn', {
agentId: input.agent.id,
turnId: input.turnId,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -860,3 +1201,38 @@ export class TurnAlreadyActiveError extends Error {
this.name = 'TurnAlreadyActiveError'
}
}
// ── Files API DTO ────────────────────────────────────────────────
/**
* Wire shape for one produced-file entry returned by the rail and
* inline-card endpoints. Trimmed from the on-disk row — clients
* never see `agentDefinitionId` or `sessionKey`.
*/
export interface ProducedFileEntry {
id: string
path: string
size: number
mtimeMs: number
createdAt: number
detectedBy: 'diff' | 'tool'
}
export interface ProducedFilesRailGroup {
turnId: string
/** First non-blank line of the user prompt that initiated this turn. */
turnPrompt: string
createdAt: number
files: ProducedFileEntry[]
}
function toProducedFileEntry(row: ProducedFileRow): ProducedFileEntry {
return {
id: row.id,
path: row.path,
size: row.size,
mtimeMs: row.mtimeMs,
createdAt: row.createdAt,
detectedBy: row.detectedBy,
}
}

View File

@@ -65,7 +65,6 @@ export class ChatService {
origin: request.origin,
declinedApps: request.declinedApps,
browserosId: this.deps.browserosId,
toolApprovalConfig: request.toolApprovalConfig,
}
let session = sessionStore.get(request.conversationId)
@@ -74,9 +73,6 @@ export class ChatService {
// Build stable keys for change detection
const mcpServerKey = this.buildMcpServerKey(request.browserContext)
const approvalConfigKey = this.buildApprovalConfigKey(
request.toolApprovalConfig,
)
// Detect MCP config change mid-conversation → rebuild session
if (session && session.mcpServerKey !== mcpServerKey) {
@@ -165,20 +161,6 @@ export class ChatService {
}
}
// Detect approval config change mid-conversation → rebuild session
if (session && session.approvalConfigKey !== approvalConfigKey) {
logger.info(
'Approval config changed mid-conversation, rebuilding session',
{ conversationId: request.conversationId },
)
session = await this.rebuildSession(
session,
request,
agentConfig,
mcpServerKey,
)
}
if (!session) {
isNewSession = true
let hiddenPageId: number | undefined
@@ -240,7 +222,6 @@ export class ChatService {
klavisRef: this.deps.klavisRef,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
aclRules: request.aclRules,
})
session = {
agent,
@@ -248,13 +229,10 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
approvalConfigKey,
}
sessionStore.set(request.conversationId, session)
}
session.agent.updateAclRules(request.aclRules)
if (isNewSession && request.previousConversation?.length) {
for (const msg of request.previousConversation) {
if (!msg.content.trim()) continue
@@ -270,26 +248,6 @@ export class ChatService {
})
}
// Handle tool approval responses: patch the agent's messages and re-run
if (request.toolApprovalResponses?.length) {
this.applyToolApprovalResponses(
session.agent.messages,
request.toolApprovalResponses,
)
logger.info('Applied tool approval responses', {
conversationId: request.conversationId,
count: request.toolApprovalResponses.length,
})
return createAgentUIStreamResponse({
agent: session.agent.toolLoopAgent,
uiMessages: filterValidMessages(session.agent.messages),
abortSignal,
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
session.agent.messages = filterValidMessages(messages)
},
})
}
const messageContext = request.isScheduledTask
? (session.browserContext ?? request.browserContext)
: request.browserContext
@@ -416,7 +374,6 @@ export class ChatService {
klavisRef: this.deps.klavisRef,
browserosId: this.deps.browserosId,
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
aclRules: request.aclRules,
})
const newSession: AgentSession = {
agent,
@@ -424,9 +381,6 @@ export class ChatService {
browserContext,
mcpServerKey,
workingDir: request.userWorkingDir,
approvalConfigKey: this.buildApprovalConfigKey(
request.toolApprovalConfig,
),
}
newSession.agent.messages = sanitizeMessagesForToolset(
previousMessages,
@@ -436,51 +390,6 @@ export class ChatService {
return newSession
}
private applyToolApprovalResponses(
messages: UIMessage[],
responses: Array<{
approvalId: string
approved: boolean
reason?: string
}>,
): void {
const responseMap = new Map(responses.map((r) => [r.approvalId, r]))
for (const msg of messages) {
if (msg.role !== 'assistant') continue
for (const part of msg.parts) {
const toolPart = part as {
state?: string
approval?: { id: string; approved?: boolean; reason?: string }
}
if (
toolPart.state === 'approval-requested' &&
toolPart.approval?.id &&
responseMap.has(toolPart.approval.id)
) {
const resp = responseMap.get(toolPart.approval.id)
if (!resp) continue
toolPart.state = 'approval-responded'
toolPart.approval = {
...toolPart.approval,
approved: resp.approved,
reason: resp.reason,
}
}
}
}
}
private buildApprovalConfigKey(config?: {
categories: Record<string, boolean>
}): string {
if (!config) return ''
return Object.entries(config.categories)
.filter(([, v]) => v)
.map(([k]) => k)
.sort()
.join(',')
}
private buildMcpServerKey(browserContext?: BrowserContext): string {
const managed = browserContext?.enabledMcpServers?.slice().sort() ?? []
const custom =

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AclRule } from '@browseros/shared/types/acl'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Browser } from '../../../browser/browser'
@@ -23,7 +22,6 @@ export interface McpServiceDeps {
browser: Browser
executionDir: string
resourcesDir: string
aclRules?: AclRule[]
klavisRef?: KlavisProxyRef
observer?: ToolExecutionObserver
}
@@ -49,7 +47,6 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
workingDir: deps.executionDir,
resourcesDir: deps.resourcesDir,
},
aclRules: deps.aclRules,
observer: deps.observer,
})

View File

@@ -52,7 +52,6 @@ export type GatewayContainerSpec = {
hostPort: number
hostHome: string
envFilePath: string
gatewayToken?: string
timezone: string
}
@@ -414,9 +413,7 @@ export class ContainerRuntime {
TZ: input.timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
...(input.gatewayToken
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
: {}),
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}

View File

@@ -0,0 +1,335 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Helpers used by the `/claw/files/:id/preview` and
* `/claw/files/:id/download` routes:
*
* - MIME-type detection (extension first, magic-byte fallback for
* ambiguous extensions).
* - Bounded text-snippet reader for inline previews.
* - Image bytes reader for the rail's thumbnails.
*
* No streaming code lives here — the download route streams via Hono
* directly. This module only handles the small in-memory reads the
* preview UX needs.
*/
import { open, stat } from 'node:fs/promises'
import { extname } from 'node:path'
/** Hard cap on the inline text snippet returned by the preview API. */
export const TEXT_PREVIEW_MAX_BYTES = 1 * 1024 * 1024 // 1 MB
/** Hard cap on inline image bytes returned as a base64 data URL. */
export const IMAGE_PREVIEW_MAX_BYTES = 4 * 1024 * 1024 // 4 MB
const MIME_BY_EXTENSION: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.markdown': 'text/markdown',
'.json': 'application/json',
'.jsonl': 'application/x-ndjson',
'.csv': 'text/csv',
'.tsv': 'text/tab-separated-values',
'.xml': 'application/xml',
'.yaml': 'application/yaml',
'.yml': 'application/yaml',
'.toml': 'application/toml',
'.ini': 'text/plain',
'.log': 'text/plain',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.cjs': 'text/javascript',
'.ts': 'text/typescript',
'.tsx': 'text/typescript',
'.jsx': 'text/javascript',
'.py': 'text/x-python',
'.rb': 'text/x-ruby',
'.go': 'text/x-go',
'.rs': 'text/x-rust',
'.java': 'text/x-java',
'.kt': 'text/x-kotlin',
'.swift': 'text/x-swift',
'.c': 'text/x-c',
'.h': 'text/x-c',
'.cpp': 'text/x-c++',
'.hpp': 'text/x-c++',
'.sh': 'application/x-sh',
'.zsh': 'application/x-sh',
'.bash': 'application/x-sh',
'.sql': 'application/sql',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.tgz': 'application/gzip',
'.bz2': 'application/x-bzip2',
'.7z': 'application/x-7z-compressed',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
}
/**
* Magic-byte signatures for cases where the extension is missing or
* misleading. Only covers the formats whose preview path differs from
* the default binary path (text vs image vs PDF vs other).
*/
const MAGIC_BYTE_SIGNATURES: Array<{
mime: string
matches: (head: Uint8Array) => boolean
}> = [
{
mime: 'image/png',
matches: (h) =>
h[0] === 0x89 &&
h[1] === 0x50 &&
h[2] === 0x4e &&
h[3] === 0x47 &&
h[4] === 0x0d &&
h[5] === 0x0a,
},
{
mime: 'image/jpeg',
matches: (h) => h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff,
},
{
mime: 'image/gif',
matches: (h) =>
h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46 && h[3] === 0x38,
},
{
mime: 'image/webp',
matches: (h) =>
h[0] === 0x52 &&
h[1] === 0x49 &&
h[2] === 0x46 &&
h[3] === 0x46 &&
h[8] === 0x57 &&
h[9] === 0x45 &&
h[10] === 0x42 &&
h[11] === 0x50,
},
{
mime: 'application/pdf',
matches: (h) =>
h[0] === 0x25 && h[1] === 0x50 && h[2] === 0x44 && h[3] === 0x46,
},
]
const MAGIC_BYTE_PROBE_LEN = 12
/**
* Best-effort MIME detection. Tries the extension map first, then
* falls back to magic-byte sniffing for the formats whose preview
* path differs from the default binary handling. Returns
* `application/octet-stream` when we can't tell.
*/
export async function detectMimeType(absolutePath: string): Promise<string> {
const fromExtension = MIME_BY_EXTENSION[extname(absolutePath).toLowerCase()]
if (fromExtension) return fromExtension
let head: Uint8Array
try {
const handle = await open(absolutePath, 'r')
try {
const buffer = new Uint8Array(MAGIC_BYTE_PROBE_LEN)
const { bytesRead } = await handle.read(
buffer,
0,
MAGIC_BYTE_PROBE_LEN,
0,
)
head = buffer.subarray(0, bytesRead)
} finally {
await handle.close()
}
} catch {
return 'application/octet-stream'
}
for (const sig of MAGIC_BYTE_SIGNATURES) {
if (sig.matches(head)) return sig.mime
}
if (looksLikeText(head)) return 'text/plain'
return 'application/octet-stream'
}
export type PreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
export interface BasePreview {
kind: PreviewKind
mimeType: string
size: number
mtimeMs: number
}
export interface TextPreview extends BasePreview {
kind: 'text'
snippet: string
/** True when the on-disk file is larger than `TEXT_PREVIEW_MAX_BYTES`. */
truncated: boolean
}
export interface ImagePreview extends BasePreview {
kind: 'image'
/** Base64 data URL (incl. `data:` prefix) suitable for `<img src>`. */
dataUrl: string
}
export interface PdfPreview extends BasePreview {
kind: 'pdf'
}
export interface BinaryPreview extends BasePreview {
kind: 'binary'
}
export interface MissingPreview {
kind: 'missing'
}
export type FilePreview =
| TextPreview
| ImagePreview
| PdfPreview
| BinaryPreview
| MissingPreview
/**
* Build a preview payload for the inline-card / rail preview Sheet.
* Reads at most `TEXT_PREVIEW_MAX_BYTES` (text) or
* `IMAGE_PREVIEW_MAX_BYTES` (image) into memory; everything else
* returns a metadata-only `binary` preview and the UI offers a
* download instead.
*/
export async function buildFilePreview(
absolutePath: string,
): Promise<FilePreview> {
let stats: Awaited<ReturnType<typeof stat>>
try {
stats = await stat(absolutePath)
} catch {
return { kind: 'missing' }
}
const mimeType = await detectMimeType(absolutePath)
const base = {
mimeType,
size: stats.size,
mtimeMs: stats.mtimeMs,
} as const
if (mimeType === 'application/pdf') {
return { kind: 'pdf', ...base }
}
if (isTextMime(mimeType)) {
return readTextPreview(absolutePath, base)
}
if (isImageMime(mimeType)) {
return readImagePreview(absolutePath, base)
}
return { kind: 'binary', ...base }
}
async function readTextPreview(
absolutePath: string,
base: { mimeType: string; size: number; mtimeMs: number },
): Promise<TextPreview> {
const handle = await open(absolutePath, 'r')
try {
const length = Math.min(base.size, TEXT_PREVIEW_MAX_BYTES)
const buffer = new Uint8Array(length)
const { bytesRead } = await handle.read(buffer, 0, length, 0)
const snippet = new TextDecoder('utf-8', { fatal: false }).decode(
buffer.subarray(0, bytesRead),
)
return {
kind: 'text',
...base,
snippet,
truncated: base.size > TEXT_PREVIEW_MAX_BYTES,
}
} finally {
await handle.close()
}
}
async function readImagePreview(
absolutePath: string,
base: { mimeType: string; size: number; mtimeMs: number },
): Promise<ImagePreview | BinaryPreview> {
if (base.size > IMAGE_PREVIEW_MAX_BYTES) {
// Too big to inline — let the user download.
return { kind: 'binary', ...base }
}
const handle = await open(absolutePath, 'r')
try {
const buffer = new Uint8Array(base.size)
await handle.read(buffer, 0, base.size, 0)
const dataUrl = `data:${base.mimeType};base64,${Buffer.from(buffer).toString('base64')}`
return { kind: 'image', ...base, dataUrl }
} finally {
await handle.close()
}
}
function isTextMime(mime: string): boolean {
if (mime.startsWith('text/')) return true
return (
mime === 'application/json' ||
mime === 'application/x-ndjson' ||
mime === 'application/xml' ||
mime === 'application/yaml' ||
mime === 'application/toml' ||
mime === 'application/sql' ||
mime === 'application/x-sh'
)
}
function isImageMime(mime: string): boolean {
return mime.startsWith('image/') && mime !== 'image/svg+xml'
// SVG is text — let it go through the text path so users can read
// markup, not view a base64 blob.
}
/**
* Crude text-vs-binary heuristic for files whose extension and magic
* bytes both fail to identify them. Counts NUL bytes — text files
* essentially never contain them; binaries usually do.
*/
function looksLikeText(head: Uint8Array): boolean {
if (head.length === 0) return true
let nulCount = 0
for (const byte of head) {
if (byte === 0) nulCount += 1
}
return nulCount === 0
}

View File

@@ -0,0 +1,311 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Converts an aggregated OpenClaw session history (rich content blocks
* across the agent's main + sub-sessions) into the flat AgentHistoryPage
* shape the chat panel consumes.
*
* Input: OpenClawSessionHistory.messages — each message has `content`
* that is either a string OR an array of typed blocks
* ({type: 'text'|'thinking'|'toolCall'|'toolResult'}). The HTTP endpoint
* returns the array form even though the type definition says string.
*
* Output: AgentHistoryEntry[] — flat text per entry, separate `reasoning`
* and `toolCalls` fields the UI renders as collapsible sections.
*
* Tool result pairing: `toolCall` blocks emit on assistant messages;
* the matching `toolResult` arrives in a later message (typically with
* role 'tool' or 'toolResult'). We pair them by `toolCallId` so the
* resulting AgentHistoryToolCall has both input and output.
*/
import { unwrapBrowserosAcpUserMessage } from '../../../lib/agents/acpx-runtime'
import type {
AgentHistoryEntry,
AgentHistoryToolCall,
} from '../../../lib/agents/agent-types'
import type { AgentHistoryPage } from '../../../lib/agents/types'
import type {
OpenClawSessionHistory,
OpenClawSessionHistoryMessage,
} from './openclaw-http-client'
const CRON_PROMPT_PREFIX_PATTERN =
/^\[cron:[0-9a-f-]+ ([^\]]+)\]\s*([\s\S]*?)\n*Current time:[^\n]*(?:\n[\s\S]*)?$/
const CRON_DELIVERY_TRAILER =
/\n*Use the message tool if you need to notify the user directly[\s\S]*$/
const QUEUED_MARKER_LINE =
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m
const SUBAGENT_CONTEXT_PREFIX = /^\[Subagent Context\][\s\S]*$/
// Emitted by OpenClaw's acp-cli ahead of the BrowserOS envelope. Three
// prefix shapes (any combination, in this stack order):
//
// 1. `[media attached: <internal-path> (<mime>)]` ← per attachment
// 2. `[<weekday> <YYYY-MM-DD HH:MM> <TZ>]` ← injectTimestamp
// 3. `[Working directory: <path>]` ← acp-cli prefixCwd
//
// Stacks #1 may appear multiple times (one per image). Stack #2 and #3
// can render on the same line separated by a space. Each known prefix is
// anchored on its content shape (not just `[…]`) to avoid clobbering
// user-typed lines that happen to start with a bracket.
const OPENCLAW_MEDIA_PREFIX_LINE = /^\[media attached:[^\]\n]*\]\n/
const OPENCLAW_TIMESTAMP_PREFIX =
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]\n]*\][ \t]*/
const OPENCLAW_WORKDIR_PREFIX = /^\[Working directory: [^\]\n]*\]\n+/
function stripOpenClawAcpCliEnvelope(value: string): string {
let s = value
while (OPENCLAW_MEDIA_PREFIX_LINE.test(s)) {
s = s.replace(OPENCLAW_MEDIA_PREFIX_LINE, '')
}
s = s.replace(OPENCLAW_TIMESTAMP_PREFIX, '')
s = s.replace(OPENCLAW_WORKDIR_PREFIX, '')
return s
}
/**
* Strip OpenClaw + BrowserOS scaffolding from a "user" message before
* showing it in the chat panel.
*
* BrowserOS-side envelope (`<role>…</role>\n\n<user_request>…</user_request>`)
* is delegated to `unwrapBrowserosAcpUserMessage`, which performs an
* exact-string match against the same constants `buildBrowserosAcpPrompt`
* uses to wrap. Matcher and wrapper live in the same repo, so the two
* always travel together.
*
* OpenClaw's acp-cli prepends a `[Working directory: <path>]\n\n` line
* before the BrowserOS envelope (see /app/dist/acp-cli-*.js, line 1361).
* We strip that single line up-front so the `^<role>` anchor in
* `unwrapBrowserosAcpUserMessage` matches.
*
* OpenClaw-injected scaffolding (cron prefix, queued-marker, subagent
* context) is still pattern-matched here. Removing those requires either
* an OpenClaw schema change exposing the structured trigger payload, or a
* BrowserOS-side side-channel (cache cron payloads on `cron.add` and look
* up by jobId). Tracked as the next cleanup; until then this is best-
* effort with text-level patterns.
*/
export function cleanHistoryUserText(raw: string): string {
if (!raw) return raw
// Queued-marker case: this is structurally a multi-message blob, so
// split first and recurse into each chunk. We keep the join character
// narrow (single newline) so e.g. five cron payloads render as five
// visually-separate lines rather than one wall of text.
if (QUEUED_MARKER_LINE.test(raw)) {
const chunks = raw
.split(
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m,
)
.map((chunk) => cleanSingleUserMessage(chunk))
.filter((chunk) => chunk.length > 0)
return chunks.join('\n')
}
return cleanSingleUserMessage(raw)
}
function cleanSingleUserMessage(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
// Subagent context seed: pure scaffolding, drop entirely. The real
// task lives in the subagent's system prompt; the user-message body
// is just framing the model never produced.
if (SUBAGENT_CONTEXT_PREFIX.test(trimmed)) {
return ''
}
const cronMatch = CRON_PROMPT_PREFIX_PATTERN.exec(trimmed)
if (cronMatch) {
const payload = cronMatch[2] ?? ''
return payload.replace(CRON_DELIVERY_TRAILER, '').trim()
}
// Strip OpenClaw's acp-cli envelope (media-attached lines + timestamp
// + workdir) before delegating, so the BrowserOS unwrap helper's
// `^<role>` anchor matches.
const withoutEnvelope = stripOpenClawAcpCliEnvelope(trimmed)
return unwrapBrowserosAcpUserMessage(withoutEnvelope).trim()
}
type RichBlock =
| { type: 'text'; text?: string }
| { type: 'thinking'; thinking?: string; text?: string }
| {
type: 'toolCall'
id?: string
toolCallId?: string
name?: string
arguments?: unknown
}
| {
type: 'toolResult'
toolCallId?: string
content?: unknown
isError?: boolean
}
| { type: string; [key: string]: unknown }
// We hold the AgentHistoryToolCall reference itself in `pending` so a
// later `toolResult` block mutates the same object that was already
// pushed onto the assistant entry's `toolCalls` array.
type PendingToolCall = AgentHistoryToolCall
export function convertOpenClawHistoryToAgentHistory(
agentId: string,
raw: OpenClawSessionHistory,
): AgentHistoryPage {
const items: AgentHistoryEntry[] = []
// Resolved tool calls keyed by toolCallId — used to attach `output`
// back to the assistant entry that issued the call once the tool
// result arrives in a subsequent message.
const pendingByToolCallId = new Map<string, PendingToolCall>()
let entryCounter = 0
const nextId = () => `${agentId}:hist:${entryCounter++}`
for (const message of raw.messages) {
const blocks = normalizeBlocks(message)
const role = normalizeRole(message.role)
if (!role) {
// 'system' / 'tool' messages aren't shown as their own chat entries;
// tool results get folded into the assistant entry they complete.
if (message.role === 'tool') {
applyToolResults(blocks, pendingByToolCallId)
}
continue
}
const rawText = collectText(blocks).trim()
const text = role === 'user' ? cleanHistoryUserText(rawText) : rawText
const reasoningText = collectThinking(blocks).trim()
const toolCallEntries = collectToolCalls(blocks, pendingByToolCallId)
// Skip empty entries. Two cases:
// - User: cleaner returned empty after stripping scaffolding (e.g.
// dropped Subagent Context message). No bubble to render.
// - Assistant: model returned only thinking blocks (common with
// MiniMax `thinking: minimal` for trivial prompts) and no text
// or tools. The empty bubble + dangling reasoning collapsible
// reads as broken UI; cleaner to drop the turn entirely.
if (!text && toolCallEntries.length === 0) continue
const entry: AgentHistoryEntry = {
id: message.messageId ?? nextId(),
agentId,
sessionId: 'main',
role,
text,
createdAt: message.timestamp ?? 0,
}
if (reasoningText) {
entry.reasoning = { text: reasoningText }
}
if (toolCallEntries.length > 0) {
entry.toolCalls = toolCallEntries
}
items.push(entry)
}
return {
agentId,
sessionId: 'main',
items,
}
}
function normalizeBlocks(message: OpenClawSessionHistoryMessage): RichBlock[] {
const content = (message as { content: unknown }).content
if (typeof content === 'string') {
return content ? [{ type: 'text', text: content }] : []
}
if (Array.isArray(content)) {
return content as RichBlock[]
}
return []
}
function normalizeRole(
role: OpenClawSessionHistoryMessage['role'],
): 'user' | 'assistant' | null {
if (role === 'user' || role === 'assistant') return role
return null
}
function collectText(blocks: RichBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.type === 'text' && typeof block.text === 'string') {
parts.push(block.text)
}
}
return parts.join('\n')
}
function collectThinking(blocks: RichBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.type === 'thinking') {
const value =
typeof block.thinking === 'string'
? block.thinking
: typeof block.text === 'string'
? block.text
: ''
if (value) parts.push(value)
}
}
return parts.join('\n\n')
}
function collectToolCalls(
blocks: RichBlock[],
pending: Map<string, PendingToolCall>,
): AgentHistoryToolCall[] {
const out: AgentHistoryToolCall[] = []
for (const block of blocks) {
if (block.type !== 'toolCall') continue
const callId =
typeof block.toolCallId === 'string'
? block.toolCallId
: typeof block.id === 'string'
? block.id
: undefined
if (!callId) continue
const toolName = typeof block.name === 'string' ? block.name : 'unknown'
const entry: AgentHistoryToolCall = {
toolCallId: callId,
toolName,
status: 'completed',
input: block.arguments,
}
out.push(entry)
// Hold the same reference so a later toolResult mutates the entry
// already pushed onto the assistant's toolCalls array.
pending.set(callId, entry)
}
return out
}
function applyToolResults(
blocks: RichBlock[],
pending: Map<string, PendingToolCall>,
): void {
for (const block of blocks) {
if (block.type !== 'toolResult') continue
const callId =
typeof block.toolCallId === 'string' ? block.toolCallId : undefined
if (!callId) continue
const entry = pending.get(callId)
if (!entry) continue
if (block.isError) {
entry.status = 'failed'
entry.error =
typeof block.content === 'string'
? block.content
: JSON.stringify(block.content)
} else {
entry.output = block.content
}
}
}

View File

@@ -4,10 +4,40 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { join } from 'node:path'
import { join, relative, resolve, sep } from 'node:path'
const STATE_DIR_NAME = '.openclaw'
/**
* Path-traversal guard for `agent.name` before it gets joined into
* the host workspace directory. The name is user-supplied at
* agent-create time, and `path.join` happily resolves `..` /
* absolute segments — so a name like `../../tmp` would point the
* workspace at the user's home directory, the harness's pre-turn
* snapshot would walk it, and `produced_files` rows would point at
* arbitrary host paths that subsequent download / preview routes
* would then serve as "agent outputs".
*
* Reject anything that isn't a flat, single-segment name composed
* of safe filename characters. The check is intentionally
* conservative — agent names are short slugs in practice.
*/
export function isAgentWorkspaceNameSafe(name: string): boolean {
if (typeof name !== 'string') return false
const trimmed = name.trim()
if (trimmed === '' || trimmed === '.' || trimmed === '..') return false
// No path separators, no NULs, no control chars (charCode < 0x20).
for (let i = 0; i < trimmed.length; i++) {
const code = trimmed.charCodeAt(i)
if (code < 0x20) return false
}
if (/[\\/]/.test(trimmed)) return false
// No `..` segments and no leading dot (avoid hidden / dotfile escapes).
if (trimmed.startsWith('.')) return false
if (trimmed.includes('..')) return false
return true
}
export function getOpenClawStateDir(openclawDir: string): string {
return join(openclawDir, STATE_DIR_NAME)
}
@@ -24,10 +54,27 @@ export function getHostWorkspaceDir(
openclawDir: string,
agentName: string,
): string {
return join(
getOpenClawStateDir(openclawDir),
if (agentName !== 'main' && !isAgentWorkspaceNameSafe(agentName)) {
throw new Error(
`Refusing to compute workspace dir for unsafe agent name: ${agentName}`,
)
}
const stateDir = getOpenClawStateDir(openclawDir)
const candidate = resolve(
stateDir,
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
// Defensive containment check: even with a safe-looking name the
// resolved path must live under the state dir. If it doesn't,
// refuse rather than return a path the caller would then trust.
const stateDirResolved = resolve(stateDir)
const rel = relative(stateDirResolved, candidate)
if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`)) {
throw new Error(
`Resolved workspace dir escapes openclaw state dir: ${candidate}`,
)
}
return candidate
}
export function mergeEnvContent(

View File

@@ -1,211 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Minimal OpenAI-compatible chat client against the OpenClaw gateway.
* Used exclusively by the harness's image carve-out: when the user
* attaches images to an OpenClaw agent, the harness diverts the turn
* here instead of through the ACP bridge (which silently drops image
* content blocks). The gateway's `/v1/chat/completions` endpoint
* accepts OpenAI-style multimodal `image_url` parts.
*
* Output is normalized to `AgentStreamEvent` so the rest of the harness
* pipeline (UI streaming, history persistence) doesn't care that the
* transport is HTTP rather than ACP for this turn.
*/
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { logger } from '../../../lib/logger'
export type OpenAIContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
export interface OpenAIChatMessage {
role: 'system' | 'user' | 'assistant'
content: string | OpenAIContentPart[]
}
export interface GatewayChatTurnInput {
/** Gateway-side agent name. Equal to the harness id post Step 9 backfill. */
agentId: string
sessionKey: string
messages: OpenAIChatMessage[]
signal?: AbortSignal
}
export class OpenClawGatewayChatClient {
constructor(
private readonly getHostPort: () => number,
private readonly getToken: () => Promise<string>,
) {}
async streamTurn(
input: GatewayChatTurnInput,
): Promise<ReadableStream<AgentStreamEvent>> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.getHostPort()}/v1/chat/completions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: input.messages,
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,
},
)
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(
detail || `OpenClaw gateway chat failed with status ${response.status}`,
)
}
const body = response.body
if (!body) {
throw new Error('OpenClaw gateway chat response had no body')
}
return new ReadableStream<AgentStreamEvent>({
start(controller) {
void pumpOpenAIChunks(body, controller, input.signal)
},
})
}
}
function resolveAgentModel(agentId: string): string {
// The gateway routes `openclaw` → its default `main` provider config,
// and `openclaw/<agentId>` → the per-agent provider config. Backfilled
// legacy agents (`main`, orphans) can use the unprefixed form.
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
}
async function pumpOpenAIChunks(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<AgentStreamEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let closed = false
let aborted = false
let stopReason: string | undefined
// Re-emit explicit signal aborts as a clean cancel rather than letting
// the underlying `reader.read()` reject — keeps the controller in a
// sensible state if the caller bails (e.g. tab close).
const onAbort = () => {
aborted = true
void reader.cancel().catch(() => {})
}
signal?.addEventListener('abort', onAbort, { once: true })
const flushLine = (line: string) => {
if (closed || !line.startsWith('data:')) return
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') {
finish()
return
}
let parsed: unknown
try {
parsed = JSON.parse(payload)
} catch {
controller.enqueue({
type: 'error',
message: 'Failed to parse OpenClaw gateway chunk',
})
finish()
return
}
const text = extractDeltaText(parsed)
if (text) {
controller.enqueue({
type: 'text_delta',
text,
stream: 'output',
rawType: 'agent_message_chunk',
})
}
const finishReason = extractFinishReason(parsed)
if (finishReason) {
stopReason = finishReason === 'stop' ? 'end_turn' : finishReason
finish()
}
}
const finish = () => {
if (closed) return
closed = true
controller.enqueue({ type: 'done', stopReason: stopReason ?? 'end_turn' })
controller.close()
}
try {
while (true) {
if (aborted) {
if (!closed) {
closed = true
controller.close()
}
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx = buffer.indexOf('\n\n')
while (idx >= 0) {
const event = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
for (const line of event.split('\n')) flushLine(line)
if (closed) return
idx = buffer.indexOf('\n\n')
}
}
if (!closed) {
// Stream ended without an explicit [DONE]. Treat as natural end.
finish()
}
} catch (err) {
if (closed || aborted) return
logger.warn('OpenClaw gateway chat stream errored', {
error: err instanceof Error ? err.message : String(err),
})
controller.enqueue({
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
closed = true
controller.close()
} finally {
signal?.removeEventListener('abort', onAbort)
reader.releaseLock()
}
}
interface OpenAIStreamChunk {
choices?: Array<{
delta?: { content?: unknown }
finish_reason?: string | null
}>
}
function extractDeltaText(value: unknown): string {
const chunk = value as OpenAIStreamChunk
const content = chunk?.choices?.[0]?.delta?.content
return typeof content === 'string' ? content : ''
}
function extractFinishReason(value: unknown): string | null {
const chunk = value as OpenAIStreamChunk
return chunk?.choices?.[0]?.finish_reason ?? null
}

View File

@@ -44,6 +44,24 @@ export interface OpenClawSessionHistoryMessage {
messageId?: string
messageSeq?: number
timestamp?: number
/**
* OpenClaw extension envelope. The gateway records the per-session
* monotonic sequence on `__openclaw.seq` rather than the top-level
* `messageSeq` field, so cursor logic reads from here. `id` is the
* gateway's stable message id.
*/
__openclaw?: { id?: string; seq?: number }
/**
* Origin of this message when the response merges multiple sessions.
* Absent on single-session responses for backward compatibility.
*/
source?: 'main' | 'cron' | 'hook' | 'channel' | 'other'
/**
* The session key this message originated from. Differs from the
* top-level `sessionKey` when sub-sessions (e.g. cron runs) are merged
* into a parent agent's main-session response.
*/
subSessionKey?: string
}
export interface OpenClawSessionHistory {
@@ -74,10 +92,7 @@ export type OpenClawSessionHistoryEvent =
| { type: 'error'; data: { message: string } }
export class OpenClawHttpClient {
constructor(
private readonly hostPort: number,
private readonly getToken: () => Promise<string>,
) {}
constructor(private readonly hostPort: number) {}
async getSessionHistory(
sessionKey: string,
@@ -103,15 +118,9 @@ export class OpenClawHttpClient {
async isAuthenticated(): Promise<boolean> {
try {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}/v1/models`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
{ method: 'GET' },
)
return response.ok
} catch {
@@ -124,15 +133,11 @@ export class OpenClawHttpClient {
input: OpenClawSessionHistoryInput,
extraHeaders: Record<string, string>,
): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
...extraHeaders,
},
headers: extraHeaders,
signal: input.signal,
},
)

View File

@@ -1,276 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Connects to the OpenClaw gateway's WebSocket control plane and pipes
* chat broadcast events into a ClawSession state machine. The observer
* is a transport layer only — it handles the WS connection lifecycle
* (connect, handshake, reconnect) and delegates all state management
* to ClawSession.
*/
import WebSocket from 'ws'
import { logger } from '../../../lib/logger'
import type { ClawSession } from './claw-session'
// ---------------------------------------------------------------------------
// Protocol types (subset of OpenClaw gateway protocol v3)
// ---------------------------------------------------------------------------
const PROTOCOL_VERSION = 3
const HANDSHAKE_REQUEST_ID = 'connect'
const RECONNECT_DELAY_MS = 5_000
const CONNECT_TIMEOUT_MS = 10_000
interface RequestFrame {
type: 'req'
id: string
method: string
params: Record<string, unknown>
}
type IncomingFrame =
| { type: 'res'; id: string; ok: true; payload?: unknown }
| {
type: 'res'
id: string
ok: false
error: { code: string; message: string }
}
| { type: 'event'; event: string; payload?: unknown }
// ---------------------------------------------------------------------------
// Observer
// ---------------------------------------------------------------------------
export class OpenClawObserver {
private ws: WebSocket | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private connected = false
private closed = false
private gatewayUrl: string | null = null
private gatewayToken: string | null = null
constructor(private readonly session: ClawSession) {}
/** Start observing the gateway at the given URL with the given token. */
connect(gatewayUrl: string, token: string): void {
this.gatewayUrl = gatewayUrl
this.gatewayToken = token
this.closed = false
this.doConnect()
}
/** Stop observing and close the WebSocket. */
disconnect(): void {
this.closed = true
this.clearReconnect()
if (this.ws) {
try {
this.ws.close()
} catch {}
this.ws = null
}
this.connected = false
}
/** Whether the observer has an active WS connection. */
isConnected(): boolean {
return this.connected
}
// ── Private ─────────────────────────────────────────────────────────
private doConnect(): void {
if (this.closed || !this.gatewayUrl || !this.gatewayToken) return
const wsUrl = this.gatewayUrl
.replace(/^http:\/\//, 'ws://')
.replace(/^https:\/\//, 'wss://')
logger.debug('OpenClaw observer connecting', { url: wsUrl })
const ws = new WebSocket(wsUrl)
this.ws = ws
const connectTimeout = setTimeout(() => {
logger.warn('OpenClaw observer handshake timeout')
ws.terminate()
}, CONNECT_TIMEOUT_MS)
let handshakeSent = false
ws.on('message', (raw) => {
let frame: IncomingFrame
try {
frame = JSON.parse(raw.toString('utf8')) as IncomingFrame
} catch {
return
}
// The gateway sends a connect.challenge event before accepting
// the connect request. Send the handshake after receiving it.
if (
frame.type === 'event' &&
frame.event === 'connect.challenge' &&
!handshakeSent
) {
handshakeSent = true
const connectReq: RequestFrame = {
type: 'req',
id: HANDSHAKE_REQUEST_ID,
method: 'connect',
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: 'openclaw-tui',
displayName: 'browseros-observer',
version: '1.0.0',
platform: 'node',
mode: 'ui',
},
role: 'operator',
scopes: ['operator.read'],
auth: { token: this.gatewayToken },
},
}
ws.send(JSON.stringify(connectReq))
return
}
// Handshake response
if (frame.type === 'res' && frame.id === HANDSHAKE_REQUEST_ID) {
clearTimeout(connectTimeout)
if (frame.ok) {
this.connected = true
logger.info('OpenClaw observer connected')
} else {
logger.warn('OpenClaw observer handshake failed', {
error: frame.error,
})
ws.close()
}
return
}
// Broadcast events (only process after handshake completes)
if (frame.type === 'event' && this.connected) {
this.handleEvent(frame.event, frame.payload)
}
})
ws.on('close', () => {
clearTimeout(connectTimeout)
this.connected = false
this.ws = null
// Reset any agents stuck in "working" to "unknown" — we missed
// the final/end event because the WS closed mid-task. The
// ClawSession will re-infer correct state from JSONL when the
// observer reconnects and ensureObserverConnected() re-seeds.
for (const [agentId, state] of this.session.getAllStates()) {
if (state.status === 'working') {
this.session.transition(agentId, 'unknown')
}
}
if (!this.closed) {
logger.debug('OpenClaw observer disconnected, scheduling reconnect')
this.scheduleReconnect()
}
})
ws.on('error', (err) => {
clearTimeout(connectTimeout)
logger.debug('OpenClaw observer WS error', {
message: err.message,
})
})
}
private handleEvent(eventName: string, payload: unknown): void {
if (eventName === 'chat') {
this.handleChatEvent(payload)
}
}
/**
* Parse a gateway chat broadcast event and transition the ClawSession
* state machine accordingly.
*/
private handleChatEvent(payload: unknown): void {
if (!payload || typeof payload !== 'object') return
const p = payload as Record<string, unknown>
const sessionKey = typeof p.sessionKey === 'string' ? p.sessionKey : null
const state = typeof p.state === 'string' ? p.state : null
if (!sessionKey || !state) return
const agentId = extractAgentId(sessionKey)
if (!agentId) return
if (state === 'delta' || state === 'streaming') {
this.session.transition(agentId, 'working', {
sessionKey,
currentTool: extractToolName(p),
})
} else if (state === 'final' || state === 'end') {
this.session.transition(agentId, 'idle', { sessionKey })
} else if (state === 'error') {
const errorMsg =
typeof p.errorMessage === 'string'
? p.errorMessage
: typeof p.error === 'string'
? p.error
: 'Unknown error'
this.session.transition(agentId, 'error', { sessionKey, error: errorMsg })
}
}
private scheduleReconnect(): void {
this.clearReconnect()
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.doConnect()
}, RECONNECT_DELAY_MS)
}
private clearReconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Extract agentId from an OpenClaw session key.
* Format: "agent:<agentId>:..." — we take the segment after "agent:".
*/
function extractAgentId(sessionKey: string): string | null {
if (!sessionKey.startsWith('agent:')) return null
const colonIdx = sessionKey.indexOf(':', 6)
if (colonIdx === -1) return sessionKey.slice(6)
return sessionKey.slice(6, colonIdx)
}
/**
* Try to extract a tool name from a chat event payload.
*/
function extractToolName(payload: Record<string, unknown>): string | null {
if (typeof payload.toolName === 'string') return payload.toolName
if (typeof payload.tool === 'string') return payload.tool
const content = payload.content
if (content && typeof content === 'object' && 'name' in content) {
const name = (content as Record<string, unknown>).name
if (typeof name === 'string') return name
}
return null
}

View File

@@ -17,6 +17,7 @@ import {
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { withProcessLock } from '../../../lib/process-lock'
@@ -40,6 +41,7 @@ import {
type OpenClawAgentRecord,
OpenClawCliClient,
type OpenClawConfigBatchEntry,
type OpenClawSessionEntry,
} from './openclaw-cli-client'
import {
buildOpenClawCliProviderModelRef,
@@ -61,8 +63,8 @@ import {
OpenClawHttpClient,
type OpenClawSessionHistory,
type OpenClawSessionHistoryEvent,
type OpenClawSessionHistoryMessage,
} from './openclaw-http-client'
import { OpenClawObserver } from './openclaw-observer'
import {
type ResolvedOpenClawProviderConfig,
resolveSupportedOpenClawProvider,
@@ -234,6 +236,104 @@ function getOpenClawBrowserOSSessionPrefix(agentId: string): string {
return `agent:${agentId}:openai-user:browseros:${agentId}:`
}
const MAIN_SESSION_KEY_PATTERN = /^agent:([^:]+):main$/
/**
* Extract the agent id from a main-session key (e.g. `agent:research:main`
* → `research`). Returns null when the key isn't a top-level main session,
* which signals the caller to use the per-session fetch path.
*/
function extractAgentIdFromMainSessionKey(sessionKey: string): string | null {
const match = MAIN_SESSION_KEY_PATTERN.exec(sessionKey)
return match?.[1] ?? null
}
/**
* Classify a session key by its source. The pattern is `agent:<id>:<kind>:...`;
* the third segment identifies how the session was started.
*/
function parseSessionSource(
sessionKey: string,
): NonNullable<OpenClawSessionHistoryMessage['source']> {
const parts = sessionKey.split(':')
if (parts[0] !== 'agent' || parts.length < 3) return 'other'
switch (parts[2]) {
case 'main':
return 'main'
case 'cron':
return 'cron'
case 'hook':
return 'hook'
case 'channel':
return 'channel'
default:
return 'other'
}
}
/**
* Per-session monotonic sequence. Gateway encodes it inside the
* `__openclaw` extension envelope; the legacy top-level `messageSeq`
* field exists in the type but is rarely populated.
*/
function resolveMessageSeq(msg: OpenClawSessionHistoryMessage): number | null {
const fromEnvelope = msg.__openclaw?.seq
if (typeof fromEnvelope === 'number' && Number.isFinite(fromEnvelope)) {
return fromEnvelope
}
if (typeof msg.messageSeq === 'number' && Number.isFinite(msg.messageSeq)) {
return msg.messageSeq
}
return null
}
/**
* Stable chronological order across sessions. Falls back to seq
* when timestamps tie or are missing, preserving intra-session order.
*/
function compareMessageOrder(
a: OpenClawSessionHistoryMessage,
b: OpenClawSessionHistoryMessage,
): number {
const aTs = a.timestamp ?? 0
const bTs = b.timestamp ?? 0
if (aTs !== bTs) return aTs - bTs
return (resolveMessageSeq(a) ?? 0) - (resolveMessageSeq(b) ?? 0)
}
/**
* Compound cursor for the aggregated history endpoint. Maps each
* session key to either:
* - a `messageSeq` to fetch BEFORE on the next page (more historical),
* - or `null` meaning the session is exhausted and should be skipped.
*
* Encoded as base64url JSON for URL-safe transport in `?cursor=`.
*/
type CompoundCursor = Record<string, number | null>
function decodeCompoundCursor(encoded: string | undefined): CompoundCursor {
if (!encoded) return {}
try {
const json = Buffer.from(encoded, 'base64url').toString('utf8')
const parsed = JSON.parse(json)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const out: CompoundCursor = {}
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'number' || v === null) out[k] = v
}
return out
}
} catch {
// Malformed cursors are treated as "first page" — preferable to
// erroring out the entire history fetch on a bad client cursor.
}
return {}
}
function encodeCompoundCursor(cursor: CompoundCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
}
export interface AgentOverview {
agentId: string
status: AgentLiveStatus
@@ -260,8 +360,6 @@ export class OpenClawService {
private httpClient: OpenClawHttpClient
private openclawDir: string
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
private token: string
private tokenLoaded = false
private lastError: string | null = null
private browserosServerPort: number
private resourcesDir: string | null
@@ -272,7 +370,6 @@ export class OpenClawService {
private stopLogTail: (() => void) | null = null
private lifecycleLock: Promise<void> = Promise.resolve()
private clawSession = new ClawSession()
private observer = new OpenClawObserver(this.clawSession)
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
@@ -281,13 +378,9 @@ export class OpenClawService {
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
})
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
this.httpClient = new OpenClawHttpClient(this.hostPort)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
@@ -323,19 +416,6 @@ export class OpenClawService {
return this.hostPort
}
/**
* Current gateway auth token. The token string is loaded from
* `gateway.auth.token` in the persisted openclaw.json during setup,
* with a freshly generated UUID as fallback. Exposed so the ACPx
* harness can pass it to spawned `openclaw acp` child processes via
* the documented `OPENCLAW_GATEWAY_TOKEN` env var (avoids both the
* `--token` process-listing leak and reliance on a token-file path
* that doesn't exist as a discrete file inside the container).
*/
getGatewayToken(): string {
return this.token
}
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
onAgentStatusChange(
listener: (agentId: string, state: AgentSessionState) => void,
@@ -348,6 +428,70 @@ export class OpenClawService {
return this.clawSession.getState(agentId)
}
/**
* Drive the live-status state machine from a turn lifecycle event the
* AgentHarnessService observed. Replaces the previous WS observer
* pipeline that re-tapped the same gateway events; the harness already
* sees them as ACP `session/update` notifications, so we forward those
* here. Caller passes the stream events verbatim.
*
* `tool_call` and `tool_call_update` populate `currentTool` so the
* dashboard SSE keeps its existing payload shape. `done` clears
* working state to `idle`; `error` keeps a sticky error badge.
*/
recordAgentTurnEvent(
agentId: string,
sessionKey: string,
event:
| { type: 'turn_started' }
| { type: 'turn_event'; event: AgentStreamEvent }
| { type: 'turn_ended'; error?: string },
): void {
if (event.type === 'turn_started') {
this.clawSession.transition(agentId, 'working', { sessionKey })
return
}
if (event.type === 'turn_ended') {
if (event.error !== undefined) {
this.clawSession.transition(agentId, 'error', {
sessionKey,
error: event.error,
})
} else {
this.clawSession.transition(agentId, 'idle', { sessionKey })
}
return
}
const inner = event.event
if (inner.type === 'tool_call') {
this.clawSession.transition(agentId, 'working', {
sessionKey,
currentTool: inner.title ?? null,
})
return
}
if (inner.type === 'error') {
this.clawSession.transition(agentId, 'error', {
sessionKey,
error: inner.message,
})
return
}
if (inner.type === 'done') {
this.clawSession.transition(agentId, 'idle', { sessionKey })
return
}
if (inner.type === 'text_delta') {
// Heartbeat — keep the existing `working` row fresh; preserve
// the last-known currentTool by passing it through.
const prev = this.clawSession.getState(agentId)
this.clawSession.transition(agentId, 'working', {
sessionKey,
currentTool: prev.currentTool,
})
}
}
// ── Lifecycle ────────────────────────────────────────────────────────
/** Warm the VM and gateway image so later setup/start avoids registry work. */
@@ -394,14 +538,13 @@ export class OpenClawService {
providerKeyCount: Object.keys(provider.envValues).length,
})
await this.refreshGatewayAuthToken()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayAuth: 'none',
gatewayBind: 'lan',
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
installDaemon: false,
@@ -418,8 +561,6 @@ export class OpenClawService {
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
await this.refreshGatewayAuthToken()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
@@ -478,8 +619,6 @@ export class OpenClawService {
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
@@ -533,7 +672,6 @@ export class OpenClawService {
return this.withLifecycleLock('stop', async () => {
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
this.controlPlaneStatus = 'disconnected'
this.observer.disconnect()
this.stopGatewayLogTail()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
@@ -550,8 +688,6 @@ export class OpenClawService {
this.controlPlaneStatus = 'reconnecting'
await this.runtime.ensureReady(logProgress)
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
@@ -596,8 +732,6 @@ export class OpenClawService {
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Reloading gateway auth token...')
await this.refreshGatewayAuthToken()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
@@ -607,7 +741,6 @@ export class OpenClawService {
async shutdown(): Promise<void> {
this.controlPlaneStatus = 'disconnected'
this.observer.disconnect()
this.stopGatewayLogTail()
try {
await this.runtime.stopGateway()
@@ -794,9 +927,155 @@ export class OpenClawService {
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
): Promise<OpenClawSessionHistory> {
await this.assertGatewayReady()
return this.runControlPlaneCall(() =>
this.httpClient.getSessionHistory(sessionKey, input),
return this.runControlPlaneCall(async () => {
const agentId = extractAgentIdFromMainSessionKey(sessionKey)
if (!agentId) {
return this.httpClient.getSessionHistory(sessionKey, input)
}
return this.fetchAggregatedAgentHistory(sessionKey, agentId, input)
})
}
/**
* Aggregates the agent's main session and every sub-session (cron,
* hook, channel) into a single chronological response. The main
* session's own messages are included; each sub-session's messages
* are tagged with `source` and `subSessionKey` so the UI can
* distinguish autonomous turns from user-driven turns.
*
* Pagination uses a compound cursor that encodes a per-session seq
* for each session in scope (`{<sessionKey>: seq | null}`). Each page
* fetches each non-exhausted session with its own per-session cursor,
* merges messages across sessions by timestamp, slices to `limit`,
* and emits a fresh compound cursor reflecting where each session
* should resume on the next page. A session with `null` in the
* cursor is exhausted and skipped.
*
* Sub-session fetches that fail are logged and dropped — partial
* timelines are preferable to a hard failure that hides the main
* session.
*/
private async fetchAggregatedAgentHistory(
mainSessionKey: string,
agentId: string,
input: { limit?: number; cursor?: string; signal?: AbortSignal },
): Promise<OpenClawSessionHistory> {
const compoundIn = decodeCompoundCursor(input.cursor)
const sessions = await this.cliClient
.listSessions(agentId)
.catch((err): OpenClawSessionEntry[] => {
logger.warn(
'Failed to list OpenClaw sub-sessions; falling back to main only',
{ agentId, error: err instanceof Error ? err.message : String(err) },
)
return []
})
// Build the candidate set from the agent's session directory plus
// the main key (which may not appear in `sessions.list` if the file
// hasn't been written yet for a fresh agent).
const targetKeys = new Set<string>([mainSessionKey])
for (const entry of sessions) {
if (entry.key?.startsWith(`agent:${agentId}:`)) {
targetKeys.add(entry.key)
}
}
// Only fetch sessions that aren't exhausted by the inbound cursor.
// A session with `null` in the cursor is fully read; skip it on
// subsequent pages.
const activeKeys = Array.from(targetKeys).filter(
(k) => compoundIn[k] !== null,
)
const fetchedHistories = await Promise.all(
activeKeys.map(async (key) => {
const sessionCursor = compoundIn[key]
try {
const history = await this.httpClient.getSessionHistory(key, {
limit: input.limit,
cursor:
typeof sessionCursor === 'number'
? String(sessionCursor)
: undefined,
signal: input.signal,
})
return { key, history }
} catch (err) {
logger.warn('Failed to fetch OpenClaw sub-session history', {
sessionKey: key,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}),
)
type Annotated = OpenClawSessionHistoryMessage & { __sessionKey: string }
const merged: Annotated[] = []
let truncated = false
for (const result of fetchedHistories) {
if (!result) continue
const source = parseSessionSource(result.key)
const isMain = result.key === mainSessionKey
for (const msg of result.history.messages) {
merged.push({
...msg,
source,
...(isMain ? {} : { subSessionKey: result.key }),
__sessionKey: result.key,
})
}
if (result.history.truncated) truncated = true
}
merged.sort(compareMessageOrder)
// The merged window contains the latest portion fetched. We emit
// up to `limit` messages from the END (newest), and compute the
// resume position for each session as the seq of the EARLIEST
// emitted message that came from that session.
const limited =
typeof input.limit === 'number' && input.limit > 0
? merged.slice(-input.limit)
: merged
const compoundOut: CompoundCursor = {}
// Carry forward exhausted sessions so subsequent pages keep skipping them.
for (const key of Array.from(targetKeys)) {
if (compoundIn[key] === null) {
compoundOut[key] = null
}
}
for (const result of fetchedHistories) {
if (!result) continue
const key = result.key
const earliestEmitted = limited.find((m) => m.__sessionKey === key)
const sessionFetchHasMore = Boolean(result.history.hasMore)
const droppedFromMerge =
result.history.messages.length >
limited.filter((m) => m.__sessionKey === key).length
const sessionHasMore = sessionFetchHasMore || droppedFromMerge
if (!sessionHasMore) {
compoundOut[key] = null
continue
}
const seq = earliestEmitted ? resolveMessageSeq(earliestEmitted) : null
compoundOut[key] = seq
}
const hasMore = Object.values(compoundOut).some(
(v) => typeof v === 'number',
)
const messages = limited.map(({ __sessionKey: _drop, ...rest }) => rest)
return {
sessionKey: mainSessionKey,
messages,
cursor: hasMore ? encodeCompoundCursor(compoundOut) : null,
hasMore,
truncated: truncated || limited.length < merged.length,
}
}
async streamSessionHistory(
@@ -871,7 +1150,6 @@ export class OpenClawService {
try {
await this.runtime.ensureReady()
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
@@ -1001,10 +1279,7 @@ export class OpenClawService {
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
this.httpClient = new OpenClawHttpClient(this.hostPort)
}
private async ensureGatewayPortAllocated(
@@ -1037,25 +1312,13 @@ export class OpenClawService {
}
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
if (!this.tokenLoaded) {
logger.debug(
'OpenClaw gateway port is ready before auth token is loaded',
{
hostPort,
},
)
return false
}
const client =
hostPort === this.hostPort
? this.httpClient
: new OpenClawHttpClient(hostPort, async () => this.token)
: new OpenClawHttpClient(hostPort)
const authenticated = await client.isAuthenticated()
if (!authenticated) {
logger.warn('OpenClaw gateway port rejected current auth token', {
hostPort,
})
logger.warn('OpenClaw gateway readiness probe failed', { hostPort })
}
return authenticated
}
@@ -1096,12 +1359,10 @@ export class OpenClawService {
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
try {
await this.ensureTokenLoaded()
const result = await fn()
this.controlPlaneStatus = 'connected'
this.lastGatewayError = null
this.lastRecoveryReason = null
this.ensureObserverConnected()
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -1113,20 +1374,10 @@ export class OpenClawService {
}
}
private ensureObserverConnected(): void {
if (this.observer.isConnected()) return
// ClawSession starts empty after the JSONL seed was removed; the WS
// observer fills in agent status as events arrive.
const url = `http://127.0.0.1:${this.hostPort}`
this.observer.connect(url, this.token)
}
private classifyControlPlaneError(
error: unknown,
): OpenClawGatewayRecoveryReason {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('Unauthorized')) return 'token_mismatch'
if (message.includes('token')) return 'token_mismatch'
if (message.includes('not ready')) return 'container_not_ready'
return 'unknown'
}
@@ -1354,7 +1605,6 @@ export class OpenClawService {
hostPort: this.hostPort,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),
gatewayToken: this.tokenLoaded ? this.token : undefined,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
@@ -1459,50 +1709,6 @@ export class OpenClawService {
return true
}
private async ensureTokenLoaded(): Promise<void> {
if (this.tokenLoaded) {
return
}
if (!existsSync(this.getStateConfigPath())) {
return
}
await this.loadTokenFromConfig()
}
private async refreshGatewayAuthToken(): Promise<void> {
this.tokenLoaded = false
if (!existsSync(this.getStateConfigPath())) {
return
}
await this.loadTokenFromConfig()
}
private async loadTokenFromConfig(): Promise<void> {
try {
const config = JSON.parse(
await readFile(this.getStateConfigPath(), 'utf-8'),
) as {
gateway?: {
auth?: {
token?: unknown
}
}
}
const token = config.gateway?.auth?.token
if (typeof token === 'string' && token) {
this.token = token
this.tokenLoaded = true
logger.info('Loaded OpenClaw gateway token from mounted config')
}
} catch (err) {
logger.warn('Failed to load OpenClaw gateway token from mounted config', {
error: err instanceof Error ? err.message : String(err),
})
}
}
private createProgressLogger(
onLog?: (msg: string) => void,
): (msg: string) => void {

View File

@@ -0,0 +1,359 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* SQLite-backed store for files an OpenClaw agent produced inside its
* workspace during a chat turn. The detection model is a per-turn
* snapshot diff: take a `(path → size, mtime)` map of the workspace
* before the turn starts, re-scan after the SSE `done` event, and
* write a row for any new or modified file.
*
* Adapter-agnostic by design — the watcher is injected with the
* agent's workspace dir, so V2 can plug Claude / Codex turn lifecycle
* into the same store with a different `workspaceDir`.
*/
import { randomUUID } from 'node:crypto'
import { realpath, stat } from 'node:fs/promises'
import { relative, resolve, sep } from 'node:path'
import { and, desc, eq } from 'drizzle-orm'
import { type BrowserOsDatabase, getDb } from '../../../lib/db'
import {
agentDefinitions,
type NewProducedFileRow,
type ProducedFileRow,
producedFiles,
} from '../../../lib/db/schema'
import { walkWorkspace } from './produced-files-walker'
const TURN_PROMPT_MAX_CHARS = 280
export interface FileSnapshotEntry {
size: number
mtimeMs: number
}
/** A `(workspace-relative path → fs metadata)` snapshot of a workspace. */
export type FileSnapshot = Map<string, FileSnapshotEntry>
export interface FinalizeTurnInput {
agentDefinitionId: string
sessionKey: string
turnId: string
/** Raw user prompt; truncated to `TURN_PROMPT_MAX_CHARS` before persist. */
turnPrompt: string
/** Absolute host path to the agent's workspace directory. */
workspaceDir: string
/** Snapshot taken before the turn began. */
before: FileSnapshot
}
export interface ResolvedFile {
row: ProducedFileRow
/** Absolute host path; guaranteed to live inside the original workspace. */
absolutePath: string
}
export class ProducedFilesStore {
private readonly db: BrowserOsDatabase
constructor(options: { db?: BrowserOsDatabase } = {}) {
this.db = options.db ?? getDb()
}
/**
* Walk the workspace and capture every file's size + mtime. Used to
* bracket a chat turn so the post-turn diff knows what changed.
*/
async snapshotWorkspace(workspaceDir: string): Promise<FileSnapshot> {
const snapshot: FileSnapshot = new Map()
await walkWorkspace(workspaceDir, (relPath, metadata) => {
snapshot.set(relPath, metadata)
})
return snapshot
}
/**
* Diff the live workspace against `before`, persist rows for any
* new or modified file, return the rows so the chat-turn finalizer
* can broadcast them on the SSE feed. Re-modifications update the
* existing row in place (the `(agentDefinitionId, path)` unique
* index makes the upsert deterministic).
*/
async finalizeTurn(input: FinalizeTurnInput): Promise<ProducedFileRow[]> {
const after: FileSnapshot = await this.snapshotWorkspace(input.workspaceDir)
const changed: Array<{ relPath: string; entry: FileSnapshotEntry }> = []
for (const [relPath, entry] of after) {
const previous = input.before.get(relPath)
if (
!previous ||
previous.size !== entry.size ||
previous.mtimeMs !== entry.mtimeMs
) {
changed.push({ relPath, entry })
}
}
if (changed.length === 0) return []
const now = Date.now()
const turnPrompt = truncatePrompt(input.turnPrompt)
const rows: ProducedFileRow[] = []
for (const { relPath, entry } of changed) {
const row: NewProducedFileRow = {
id: randomUUID(),
agentDefinitionId: input.agentDefinitionId,
sessionKey: input.sessionKey,
turnId: input.turnId,
turnPrompt,
path: relPath,
size: entry.size,
mtimeMs: entry.mtimeMs,
createdAt: now,
detectedBy: 'diff',
}
// Upsert on (agent, path) — re-modifications win, no duplicates.
const upserted = this.db
.insert(producedFiles)
.values(row)
.onConflictDoUpdate({
target: [producedFiles.agentDefinitionId, producedFiles.path],
set: {
sessionKey: row.sessionKey,
turnId: row.turnId,
turnPrompt: row.turnPrompt,
size: row.size,
mtimeMs: row.mtimeMs,
createdAt: row.createdAt,
detectedBy: row.detectedBy,
},
})
.returning()
.all()
const persisted = upserted[0] ?? row
rows.push(persisted as ProducedFileRow)
}
return rows
}
/** Inline-card query — files for a single assistant turn. */
async listByTurn(turnId: string): Promise<ProducedFileRow[]> {
return this.db
.select()
.from(producedFiles)
.where(eq(producedFiles.turnId, turnId))
.orderBy(desc(producedFiles.createdAt))
.all()
}
/**
* Outputs-rail query — every file an agent has produced across all
* sessions, newest first.
*/
async listByAgent(
agentDefinitionId: string,
options: { limit?: number } = {},
): Promise<ProducedFileRow[]> {
const limit = options.limit ?? 200
return this.db
.select()
.from(producedFiles)
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
.orderBy(desc(producedFiles.createdAt))
.limit(limit)
.all()
}
/**
* Resolve a gateway-side OpenClaw agent name (e.g. `main`,
* `chief-01`) to the corresponding `agentDefinitions.id` so file
* rows can be FK'd back to the harness record.
*
* Two shapes exist on disk depending on how the agent was added:
*
* 1. Reconciled rows from `agentHarnessService.reconcileWithGateway`
* use `id == openclawAgentId` directly
* (see `agent-harness-service.ts:522`).
* 2. BrowserOS-created rows use `id = oc-<uuid>` and store the
* openclaw name in the `name` column (`db-agent-store.ts:55-65`).
*
* Lookup tries shape 1 first (direct id hit), then shape 2 by
* `(adapter='openclaw', name)`.
*/
async resolveAgentDefinitionId(
openclawAgentId: string,
): Promise<string | null> {
const directHit = this.db
.select({ id: agentDefinitions.id })
.from(agentDefinitions)
.where(eq(agentDefinitions.id, openclawAgentId))
.limit(1)
.all()
if (directHit[0]) return directHit[0].id
const byName = this.db
.select({ id: agentDefinitions.id })
.from(agentDefinitions)
.where(
and(
eq(agentDefinitions.adapter, 'openclaw'),
eq(agentDefinitions.name, openclawAgentId),
),
)
.limit(1)
.all()
return byName[0]?.id ?? null
}
/** Single-row lookup; null if the id is unknown. */
async findById(id: string): Promise<ProducedFileRow | null> {
const rows = this.db
.select()
.from(producedFiles)
.where(eq(producedFiles.id, id))
.limit(1)
.all()
return rows[0] ?? null
}
/** Used by `removeRegisteredModel` and similar admin paths later on. */
async deleteByAgent(agentDefinitionId: string): Promise<void> {
this.db
.delete(producedFiles)
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
.run()
}
/** Useful for hard-resetting a session's files (e.g. workspace clear). */
async deleteBySession(sessionKey: string): Promise<void> {
this.db
.delete(producedFiles)
.where(eq(producedFiles.sessionKey, sessionKey))
.run()
}
/**
* Resolve a stored file id to an absolute host path, after validating
* that the on-disk path still lives inside `workspaceDir`. The HTTP
* download / preview routes are the only callers; the workspace dir
* is supplied by the openclaw service so this module stays
* adapter-agnostic.
*/
async resolveFilePath(input: {
fileId: string
workspaceDir: string
}): Promise<ResolvedFile | null> {
const row = await this.findById(input.fileId)
if (!row) return null
const absolutePath = await resolveSafeWorkspacePath(
input.workspaceDir,
row.path,
)
if (!absolutePath) return null
return { row, absolutePath }
}
/**
* Group a flat list of rows by `turnId`, preserving the latest-first
* order on the row level and keeping the most-recent group first.
* The Outputs rail uses this shape directly.
*/
groupByTurn(rows: ProducedFileRow[]): Array<{
turnId: string
turnPrompt: string
createdAt: number
files: ProducedFileRow[]
}> {
const grouped = new Map<
string,
{
turnId: string
turnPrompt: string
createdAt: number
files: ProducedFileRow[]
}
>()
for (const row of rows) {
const existing = grouped.get(row.turnId)
if (!existing) {
grouped.set(row.turnId, {
turnId: row.turnId,
turnPrompt: row.turnPrompt,
// Group's createdAt = its newest file (rows are
// already desc-by-createdAt, so the first one wins).
createdAt: row.createdAt,
files: [row],
})
continue
}
existing.files.push(row)
if (row.createdAt > existing.createdAt) {
existing.createdAt = row.createdAt
}
}
return Array.from(grouped.values()).sort(
(a, b) => b.createdAt - a.createdAt,
)
}
}
function truncatePrompt(value: string): string {
const trimmed = value.trim()
if (trimmed.length <= TURN_PROMPT_MAX_CHARS) return trimmed
return `${trimmed.slice(0, TURN_PROMPT_MAX_CHARS - 1)}`
}
/**
* Resolve `workspaceDir + relPath` to an absolute host path, but only
* if the resolved real path lives inside the workspace root. Returns
* null on:
* - lexical traversal (`..` segments escaping the root),
* - symlink escape (a file in the workspace pointing outside it),
* - missing files,
* - any unreadable path component.
*
* Exported so the unit test can hit it without a sqlite handle.
*/
export async function resolveSafeWorkspacePath(
workspaceDir: string,
relPath: string,
): Promise<string | null> {
// Lexical containment first — fail fast without touching the FS.
const workspaceRoot = resolve(workspaceDir)
const lexical = resolve(workspaceRoot, relPath)
const lexicalRel = relative(workspaceRoot, lexical)
if (
lexicalRel === '' ||
lexicalRel.startsWith('..') ||
lexicalRel.startsWith(`..${sep}`)
) {
return null
}
// Realpath check — collapses symlinks so a workspace symlink that
// points outside the root cannot be downloaded. Falls through to
// null if anything errors (file gone, permissions, broken link).
try {
const [realRoot, realFile] = await Promise.all([
realpath(workspaceRoot),
realpath(lexical),
])
const realRel = relative(realRoot, realFile)
if (
realRel === '' ||
realRel.startsWith('..') ||
realRel.startsWith(`..${sep}`)
) {
return null
}
await stat(realFile)
return realFile
} catch {
return null
}
}
// Re-export the row type so callers pulling the store don't have to
// also import the schema module.
export type { ProducedFileRow }

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Workspace walker used by the produced-files diff watcher. Recurses
* an OpenClaw agent's workspace directory and yields one
* `(workspace-relative path, size, mtime)` triple per file.
*
* Design choices:
*
* - **Pure async iteration.** No third-party deps; relies on
* `fs.promises.readdir` + `Dirent` so directory traversal is one
* syscall per directory.
* - **Symlink-aware.** Symlinks themselves aren't followed (they
* appear in `Dirent.isSymbolicLink()`); the walker skips them so
* an agent can't smuggle host-fs paths into the diff via a
* symlink in its workspace.
* - **Excludes well-known cruft directories** that no useful agent
* output ever lives inside (`node_modules`, `.git`, `.cache`).
* These directories are also expensive to traverse, so skipping
* them keeps the per-turn snapshot fast.
* - **Bounded.** Hard caps on entry count and recursion depth keep
* pathological workspaces from stalling the chat-turn finalizer.
*/
import type { Dirent } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
const EXCLUDED_DIRECTORIES = new Set(['node_modules', '.git', '.cache'])
const MAX_ENTRIES = 50_000
const MAX_DEPTH = 16
export interface WorkspaceFileMetadata {
size: number
mtimeMs: number
}
export type WorkspaceFileVisitor = (
/** Workspace-relative path (POSIX-style separators). */
relativePath: string,
metadata: WorkspaceFileMetadata,
) => void
/**
* Walk `workspaceDir` recursively, calling `visit` for every regular
* file. Returns silently if the directory doesn't exist (a fresh
* agent that hasn't produced anything yet shouldn't error here).
*/
export async function walkWorkspace(
workspaceDir: string,
visit: WorkspaceFileVisitor,
): Promise<void> {
let entriesSeen = 0
await walk(workspaceDir, workspaceDir, 0, (file) => {
entriesSeen += 1
if (entriesSeen > MAX_ENTRIES) return false
visit(file.relativePath, file.metadata)
return true
})
}
interface VisitedFile {
relativePath: string
metadata: WorkspaceFileMetadata
}
async function walk(
root: string,
current: string,
depth: number,
yieldFile: (file: VisitedFile) => boolean,
): Promise<boolean> {
if (depth > MAX_DEPTH) return true
let entries: Dirent[]
try {
entries = await readdir(current, { withFileTypes: true })
} catch {
// Workspace dir missing or unreadable — fresh agent that hasn't
// written anything yet, or transient permissions issue. Treat as
// "no files" rather than throwing.
return true
}
for (const entry of entries) {
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue
const absolute = join(current, entry.name)
if (entry.isSymbolicLink()) {
// Skip symlinks — never follow, never record. Prevents an
// agent from smuggling host-fs paths into the diff via a
// symlink in its workspace.
continue
}
if (entry.isDirectory()) {
const keepGoing = await walk(root, absolute, depth + 1, yieldFile)
if (!keepGoing) return false
continue
}
if (!entry.isFile()) continue
let stats: Awaited<ReturnType<typeof stat>>
try {
stats = await stat(absolute)
} catch {
// Concurrent delete between readdir and stat — skip silently.
continue
}
const relativePath = toPosix(relative(root, absolute))
const keepGoing = yieldFile({
relativePath,
metadata: { size: stats.size, mtimeMs: stats.mtimeMs },
})
if (!keepGoing) return false
}
return true
}
function toPosix(value: string): string {
if (sep === '/') return value
return value.split(sep).join('/')
}

View File

@@ -46,32 +46,6 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
mode: z.enum(['chat', 'agent']).optional().default('agent'),
origin: z.enum(['sidepanel', 'newtab']).optional().default('sidepanel'),
declinedApps: z.array(z.string()).optional(),
aclRules: z
.array(
z.object({
id: z.string(),
sitePattern: z.string(),
selector: z.string().optional(),
textMatch: z.string().optional(),
description: z.string().optional(),
enabled: z.boolean(),
}),
)
.optional(),
toolApprovalConfig: z
.object({
categories: z.record(z.boolean()),
})
.optional(),
toolApprovalResponses: z
.array(
z.object({
approvalId: z.string(),
approved: z.boolean(),
reason: z.string().optional(),
}),
)
.optional(),
selectedText: z.string().optional(),
selectedTextSource: z
.object({

View File

@@ -1,5 +1,4 @@
import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api'
import type { ElementProperties } from '@browseros/shared/types/acl'
import { logger } from '../lib/logger'
import type { CdpBackend } from './backends/types'
import type { BookmarkNode } from './bookmarks'
@@ -364,134 +363,6 @@ export class Browser {
}
}
async resolveElementProperties(
pageId: number,
backendNodeId: number,
): Promise<ElementProperties | null> {
const session = await this.resolveSession(pageId)
try {
const targetNodeId =
(await this.resolveActionableElement(pageId, backendNodeId)) ??
backendNodeId
const desc = await session.DOM.describeNode({
backendNodeId: targetNodeId,
depth: 0,
})
const node = desc.node
const attrs = parseNodeAttributes(node)
const resolved = await session.DOM.resolveNode({
backendNodeId: targetNodeId,
})
const objectId = resolved.object?.objectId
let textContent = ''
let labelText = ''
if (objectId) {
const textResult = await session.Runtime.callFunctionOn({
functionDeclaration: `function(){
var text = (this.innerText || this.textContent || '').trim();
var aria = this.getAttribute('aria-label') || '';
var placeholder = this.getAttribute('placeholder') || '';
var title = this.getAttribute('title') || '';
var value = typeof this.value === 'string' ? this.value : '';
var labels = Array.from(this.labels || [])
.map(function(label){ return (label.innerText || label.textContent || '').trim(); })
.filter(Boolean)
.join(' ');
return {
textContent: text.substring(0, 200),
labelText: [aria, labels, placeholder, title, value, text]
.filter(Boolean)
.join(' ')
.trim()
.substring(0, 400),
};
}`,
objectId,
returnByValue: true,
})
const value = (textResult.result?.value ?? {}) as {
textContent?: string
labelText?: string
}
textContent = value.textContent ?? ''
labelText = value.labelText ?? ''
}
return {
tagName: node.localName ?? '',
textContent,
attributes: attrs,
labelText,
ariaLabel: attrs['aria-label'],
role: attrs.role,
}
} catch {
return null
}
}
async highlightBlockedElement(
pageId: number,
backendNodeId: number,
reason: string,
): Promise<void> {
const session = await this.resolveSession(pageId)
const targetNodeId =
(await this.resolveActionableElement(pageId, backendNodeId)) ??
backendNodeId
try {
const resolved = await session.DOM.resolveNode({
backendNodeId: targetNodeId,
})
const objectId = resolved.object?.objectId
if (!objectId) return
await session.Runtime.callFunctionOn({
functionDeclaration: `function(reason){
var existing = document.getElementById('__browseros_acl_block_overlay');
if (existing) existing.remove();
var existingStyle = document.getElementById('__browseros_acl_block_style');
if (!existingStyle) {
var style = document.createElement('style');
style.id = '__browseros_acl_block_style';
style.textContent = [
'#__browseros_acl_block_overlay{position:absolute;pointer-events:none;z-index:2147483647;}',
'#__browseros_acl_block_overlay .ring{position:absolute;inset:0;border:2px solid rgba(220,38,38,0.95);background:rgba(220,38,38,0.14);border-radius:10px;box-shadow:0 0 0 3px rgba(255,255,255,0.75);}',
'#__browseros_acl_block_overlay .badge{position:absolute;top:-10px;right:-10px;background:rgba(153,27,27,0.96);color:white;font:600 11px/1.2 system-ui,sans-serif;padding:6px 8px;border-radius:999px;white-space:nowrap;box-shadow:0 6px 18px rgba(0,0,0,0.2);}',
].join('');
document.head.appendChild(style);
}
var rect = this.getBoundingClientRect();
if (!rect.width || !rect.height) return;
var overlay = document.createElement('div');
overlay.id = '__browseros_acl_block_overlay';
overlay.style.left = (rect.left + window.scrollX) + 'px';
overlay.style.top = (rect.top + window.scrollY) + 'px';
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
var ring = document.createElement('div');
ring.className = 'ring';
var badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = reason || 'Blocked';
overlay.appendChild(ring);
overlay.appendChild(badge);
document.body.appendChild(overlay);
window.setTimeout(function(){
var current = document.getElementById('__browseros_acl_block_overlay');
if (current) current.remove();
}, 2500);
}`,
objectId,
arguments: [{ value: reason }],
})
} catch {
// best-effort visual feedback
}
}
async resolveTabIds(tabIds: number[]): Promise<Map<number, number>> {
await this.listPages()
const tabToPage = new Map<number, number>()

View File

@@ -4,16 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { createRuntimeStore } from 'acpx/runtime'
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
import type { AgentDefinition } from './agent-types'
import { prepareClaudeCodeContext } from './claude-code/prepare'
import { prepareCodexContext } from './codex/prepare'
import {
maybeHandleOpenClawTurn,
prepareOpenClawContext,
} from './openclaw/prepare'
import type { AgentPromptInput, AgentStreamEvent } from './types'
import { prepareOpenClawContext } from './openclaw/prepare'
export interface PreparedAcpxAgentContext {
cwd: string
@@ -35,29 +29,16 @@ export interface PrepareAcpxAgentContextInput {
message: string
}
export interface AcpxAdapterTurnInput {
prompt: AgentPromptInput
prepared: PreparedAcpxAgentContext
sessionStore: ReturnType<typeof createRuntimeStore>
openclawGatewayChat: OpenClawGatewayChatClient | null
}
export interface AcpxAgentAdapter {
prepare(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext>
maybeHandleTurn?(
input: AcpxAdapterTurnInput,
): Promise<ReadableStream<AgentStreamEvent> | null>
}
const ADAPTERS: Record<AgentDefinition['adapter'], AcpxAgentAdapter> = {
claude: { prepare: prepareClaudeCodeContext },
codex: { prepare: prepareCodexContext },
openclaw: {
prepare: prepareOpenClawContext,
maybeHandleTurn: maybeHandleOpenClawTurn,
},
openclaw: { prepare: prepareOpenClawContext },
}
export function getAcpxAgentAdapter(

View File

@@ -19,7 +19,6 @@ import {
createAgentRegistry,
createRuntimeStore,
} from 'acpx/runtime'
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import {
@@ -51,11 +50,10 @@ import type {
* when spawning the openclaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current token and VM/container paths at spawn time.
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Current gateway auth token. Passed to `openclaw acp --token`. */
getGatewayToken(): string
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
@@ -76,15 +74,6 @@ type AcpxRuntimeOptions = {
* claude/codex (their adapters spawn their own CLI binaries).
*/
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. When wired, the runtime diverts OpenClaw turns that
* carry image attachments to the gateway's HTTP `/v1/chat/completions`
* endpoint (which accepts OpenAI-style `image_url` parts) instead of
* the ACP bridge — the bridge silently drops image content blocks.
* Without this client, image turns to OpenClaw agents fall through to
* the ACP path and the model never sees the image.
*/
openclawGatewayChat?: OpenClawGatewayChatClient
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
}
@@ -98,19 +87,12 @@ interface PreparedRuntimeContext {
openclawSessionKey: string | null
}
const BROWSEROS_ACP_AGENT_INSTRUCTIONS = `<role>
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
</role>`
export class AcpxRuntime implements AgentRuntime {
private readonly defaultCwd: string | null
private readonly browserosDir: string
private readonly stateDir: string
private readonly browserosServerPort: number
private readonly openclawGateway: OpenclawGatewayAccessor | null
private readonly openclawGatewayChat: OpenClawGatewayChatClient | null
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
@@ -127,7 +109,6 @@ export class AcpxRuntime implements AgentRuntime {
this.browserosServerPort =
options.browserosServerPort ?? DEFAULT_PORTS.server
this.openclawGateway = options.openclawGateway ?? null
this.openclawGatewayChat = options.openclawGatewayChat ?? null
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
@@ -205,24 +186,6 @@ export class AcpxRuntime implements AgentRuntime {
imageAttachmentCount: imageAttachments.length,
})
const adapter = getAcpxAgentAdapter(input.agent.adapter)
const adapterStream =
(await adapter.maybeHandleTurn?.({
prompt: input,
prepared: {
cwd: prepared.cwd,
runtimeSessionKey: prepared.runtimeSessionKey,
runPrompt: prepared.runPrompt,
commandEnv: prepared.agentCommandEnv,
commandIdentity: prepared.commandIdentity,
useBrowserosMcp: prepared.useBrowserosMcp,
openclawSessionKey: prepared.openclawSessionKey,
},
sessionStore: this.sessionStore,
openclawGatewayChat: this.openclawGatewayChat,
})) ?? null
if (adapterStream) return adapterStream
const runtime = this.getRuntime({
cwd,
permissionMode: input.permissionMode,
@@ -509,14 +472,16 @@ export function unwrapBrowserosAcpUserMessage(raw: string): string {
}
function stripOuterRoleEnvelope(value: string): string {
const prefix = `${BROWSEROS_ACP_AGENT_INSTRUCTIONS}
<user_request>
`
const suffix = `
</user_request>`
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value
return value.slice(prefix.length, -suffix.length)
// Any `<role>…</role>\n\n<user_request>\n…\n</user_request>` envelope.
// Adapter-agnostic so both the BrowserOS multi-line role block and the
// openclaw single-line role block get unwrapped. TKT-774's exact-prefix
// match only covered the BrowserOS form, so the openclaw envelope
// (added when openclaw moved to its own prepare step) was landing
// unwrapped in history payloads.
const match = value.match(
/^<role\b[^>]*>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
)
return match ? match[1] : value
}
function stripOuterRuntimeEnvelope(value: string): string {
@@ -756,8 +721,8 @@ function createBrowserosAgentRegistry(input: {
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side openclaw install.
*
* Auth: `openclaw acp --url ...` deliberately does not reuse implicit
* env/config credentials, so pass the gateway token explicitly.
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
@@ -767,7 +732,6 @@ function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const token = gateway.getGatewayToken()
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
@@ -816,8 +780,6 @@ function resolveOpenclawAcpCommand(
'acp',
'--url',
gatewayUrlInsideContainer,
'--token',
token,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)

View File

@@ -1,219 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import type { AcpSessionRecord, createRuntimeStore } from 'acpx/runtime'
import type {
OpenAIChatMessage,
OpenAIContentPart,
} from '../../../api/services/openclaw/openclaw-gateway-chat-client'
import { logger } from '../../logger'
import type { AcpxAdapterTurnInput } from '../acpx-agent-adapter'
import type { AgentStreamEvent } from '../types'
type ImageAttachment = Readonly<{ mediaType: string; data: string }>
export async function maybeHandleOpenClawTurn(
input: AcpxAdapterTurnInput,
): Promise<ReadableStream<AgentStreamEvent> | null> {
const imageAttachments = (input.prompt.attachments ?? []).filter((a) =>
a.mediaType.startsWith('image/'),
)
if (imageAttachments.length === 0 || !input.openclawGatewayChat) {
return null
}
return sendOpenclawViaGateway({
prompt: input.prompt,
sessionStore: input.sessionStore,
openclawGatewayChat: input.openclawGatewayChat,
imageAttachments,
cwd: input.prepared.cwd,
runPrompt: input.prepared.runPrompt,
})
}
/** Handles OpenClaw image turns through the gateway HTTP chat endpoint. */
async function sendOpenclawViaGateway(input: {
prompt: AcpxAdapterTurnInput['prompt']
sessionStore: AcpxAdapterTurnInput['sessionStore']
openclawGatewayChat: NonNullable<AcpxAdapterTurnInput['openclawGatewayChat']>
imageAttachments: ReadonlyArray<ImageAttachment>
cwd: string
runPrompt: string
}): Promise<ReadableStream<AgentStreamEvent>> {
const existingRecord = await input.sessionStore.load(input.prompt.sessionKey)
const priorMessages = existingRecord
? recordToOpenAIMessages(existingRecord)
: []
const userContent: OpenAIContentPart[] = [
{
type: 'text',
text: input.runPrompt,
},
...input.imageAttachments.map(
(a): OpenAIContentPart => ({
type: 'image_url',
image_url: { url: `data:${a.mediaType};base64,${a.data}` },
}),
),
]
const messages: OpenAIChatMessage[] = [
...priorMessages,
{ role: 'user', content: userContent },
]
logger.info('Agent harness gateway image turn dispatched', {
agentId: input.prompt.agent.id,
sessionKey: input.prompt.sessionKey,
cwd: input.cwd,
priorMessageCount: priorMessages.length,
imageAttachmentCount: input.imageAttachments.length,
})
const upstream = await input.openclawGatewayChat.streamTurn({
agentId: input.prompt.agent.id,
sessionKey: input.prompt.sessionKey,
messages,
signal: input.prompt.signal,
})
const sessionStore = input.sessionStore
const sessionKey = input.prompt.sessionKey
const userMessageText = input.prompt.message
const imageAttachments = input.imageAttachments
let accumulated = ''
return new ReadableStream<AgentStreamEvent>({
start: (controller) => {
const reader = upstream.getReader()
const persist = async () => {
if (!existingRecord || !accumulated) return
try {
await persistGatewayTurn(
sessionStore,
sessionKey,
userMessageText,
imageAttachments,
accumulated,
)
} catch (err) {
logger.warn(
'Failed to persist gateway image turn to acpx session record',
{
sessionKey,
error: err instanceof Error ? err.message : String(err),
},
)
}
}
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value.type === 'text_delta') accumulated += value.text
controller.enqueue(value)
}
await persist()
controller.close()
} catch (err) {
controller.enqueue({
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
controller.close()
}
})().catch(() => {})
},
cancel: () => {
// Best-effort: cancel propagation to the gateway is tracked separately.
},
})
}
async function persistGatewayTurn(
sessionStore: ReturnType<typeof createRuntimeStore>,
sessionKey: string,
userMessageText: string,
imageAttachments: ReadonlyArray<ImageAttachment>,
assistantText: string,
): Promise<void> {
const record = await sessionStore.load(sessionKey)
if (!record) return
const userContent: AcpxUserContent[] = [
{ Text: userMessageText } as AcpxUserContent,
]
for (const _image of imageAttachments) {
userContent.push({ Image: { source: 'base64' } } as AcpxUserContent)
}
const turnId = randomUUID()
const updated = {
...record,
messages: [
...record.messages,
{ User: { id: `user-${turnId}`, content: userContent } },
{ Agent: { content: [{ Text: assistantText }], tool_results: {} } },
],
lastUsedAt: new Date().toISOString(),
} as AcpSessionRecord
await sessionStore.save(updated)
}
function recordToOpenAIMessages(record: AcpSessionRecord): OpenAIChatMessage[] {
const messages: OpenAIChatMessage[] = []
for (const message of record.messages) {
if (message === 'Resume') continue
if ('User' in message) {
const text = message.User.content
.map(userContentToText)
.filter(Boolean)
.join('\n\n')
.trim()
if (text) messages.push({ role: 'user', content: text })
continue
}
if ('Agent' in message) {
const text = message.Agent.content
.map((part) => ('Text' in part ? part.Text : ''))
.join('')
.trim()
if (text) messages.push({ role: 'assistant', content: text })
}
}
return messages
}
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
type AcpxUserContent = Extract<
Exclude<AcpxSessionMessage, 'Resume'>,
{ User: unknown }
>['User']['content'][number]
function userContentToText(content: AcpxUserContent): string {
if ('Text' in content) return unwrapPromptText(content.Text)
if ('Mention' in content) return content.Mention.content
if ('Image' in content) return content.Image.source ? '[image]' : ''
return ''
}
function unwrapPromptText(raw: string): string {
const runtimeMatch = raw.match(
/^<browseros_acpx_runtime\b[\s\S]*?<\/browseros_acpx_runtime>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
)
if (runtimeMatch) return decodeBasicEntities(runtimeMatch[1]).trim()
const roleMatch = raw.match(
/^<role>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
)
if (roleMatch) return decodeBasicEntities(roleMatch[1]).trim()
return raw.trim()
}
function decodeBasicEntities(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}

View File

@@ -14,8 +14,6 @@ import {
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
export { maybeHandleOpenClawTurn } from './image-turn'
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'

View File

@@ -27,6 +27,20 @@ export interface AgentHistoryPage {
items: AgentHistoryEntry[]
}
/**
* One file the harness attributed to the assistant turn that just
* finished. Emitted as part of a `produced_files` event before the
* terminal `done` so the inline artifact card renders alongside the
* streamed text the user just watched complete.
*/
export interface ProducedFileEventEntry {
id: string
/** Workspace-relative POSIX path. */
path: string
size: number
mtimeMs: number
}
export type AgentStreamEvent =
| {
type: 'text_delta'
@@ -47,6 +61,10 @@ export type AgentStreamEvent =
text: string
rawType?: string
}
| {
type: 'produced_files'
files: ProducedFileEventEntry[]
}
| {
type: 'done'
text?: string

View File

@@ -0,0 +1,18 @@
CREATE TABLE `produced_files` (
`id` text PRIMARY KEY NOT NULL,
`agent_definition_id` text NOT NULL,
`session_key` text NOT NULL,
`turn_id` text NOT NULL,
`turn_prompt` text NOT NULL,
`path` text NOT NULL,
`size` integer NOT NULL,
`mtime_ms` integer NOT NULL,
`created_at` integer NOT NULL,
`detected_by` text DEFAULT 'diff' NOT NULL,
FOREIGN KEY (`agent_definition_id`) REFERENCES `agent_definitions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `produced_files_agent_path_unique` ON `produced_files` (`agent_definition_id`,`path`);--> statement-breakpoint
CREATE INDEX `produced_files_agent_created_idx` ON `produced_files` (`agent_definition_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `produced_files_turn_idx` ON `produced_files` (`turn_id`);--> statement-breakpoint
CREATE INDEX `produced_files_session_idx` ON `produced_files` (`session_key`);

View File

@@ -0,0 +1,338 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a8560df5-6cbe-46c2-b7df-ef0d09d232bf",
"prevId": "6be24444-91aa-492e-96e5-d84c0f020468",
"tables": {
"agent_definitions": {
"name": "agent_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"adapter": {
"name": "adapter",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_effort": {
"name": "reasoning_effort",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission_mode": {
"name": "permission_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'approve-all'"
},
"session_key": {
"name": "session_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"adapter_config_json": {
"name": "adapter_config_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"agent_definitions_session_key_unique": {
"name": "agent_definitions_session_key_unique",
"columns": [
"session_key"
],
"isUnique": true
},
"agent_definitions_updated_at_idx": {
"name": "agent_definitions_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
},
"agent_definitions_adapter_updated_at_idx": {
"name": "agent_definitions_adapter_updated_at_idx",
"columns": [
"adapter",
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_tokens": {
"name": "oauth_tokens",
"columns": {
"browseros_id": {
"name": "browseros_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_tokens_browseros_id_idx": {
"name": "oauth_tokens_browseros_id_idx",
"columns": [
"browseros_id"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"oauth_tokens_browseros_id_provider_pk": {
"columns": [
"browseros_id",
"provider"
],
"name": "oauth_tokens_browseros_id_provider_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"produced_files": {
"name": "produced_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_definition_id": {
"name": "agent_definition_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_key": {
"name": "session_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"turn_id": {
"name": "turn_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"turn_prompt": {
"name": "turn_prompt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtime_ms": {
"name": "mtime_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detected_by": {
"name": "detected_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diff'"
}
},
"indexes": {
"produced_files_agent_path_unique": {
"name": "produced_files_agent_path_unique",
"columns": [
"agent_definition_id",
"path"
],
"isUnique": true
},
"produced_files_agent_created_idx": {
"name": "produced_files_agent_created_idx",
"columns": [
"agent_definition_id",
"created_at"
],
"isUnique": false
},
"produced_files_turn_idx": {
"name": "produced_files_turn_idx",
"columns": [
"turn_id"
],
"isUnique": false
},
"produced_files_session_idx": {
"name": "produced_files_session_idx",
"columns": [
"session_key"
],
"isUnique": false
}
},
"foreignKeys": {
"produced_files_agent_definition_id_agent_definitions_id_fk": {
"name": "produced_files_agent_definition_id_agent_definitions_id_fk",
"tableFrom": "produced_files",
"tableTo": "agent_definitions",
"columnsFrom": [
"agent_definition_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1777752799806,
"tag": "0001_lazy_orphan",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1777902205667,
"tag": "0002_chemical_whirlwind",
"breakpoints": true
}
]
}
}

View File

@@ -6,3 +6,4 @@
export * from './agents'
export * from './oauth'
export * from './produced-files'

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core'
import { agentDefinitions } from './agents'
/**
* Files an OpenClaw agent produced as part of a chat turn. Populated by
* a per-turn workspace diff: snapshot the agent's CWD before
* `chatStream(...)` runs, re-scan after the SSE `done` event fires,
* write rows for any new or modified path. The rail UI groups by
* `turn_id` and the inline artifact card renders one row per file.
*
* Schema is intentionally adapter-agnostic — V1 only enables the
* watcher for the openclaw adapter, but V2 can plug Claude / Codex
* into the same table without migrating.
*/
export const producedFiles = sqliteTable(
'produced_files',
{
/** Stable id; opaque file handle in download/preview URLs. */
id: text('id').primaryKey(),
/** FK → agent_definitions.id; CASCADE so agent deletion sweeps. */
agentDefinitionId: text('agent_definition_id')
.notNull()
.references(() => agentDefinitions.id, { onDelete: 'cascade' }),
/** OpenClaw session that owns this turn (e.g. `session-abc`). */
sessionKey: text('session_key').notNull(),
/** Identifier for the assistant turn that produced the file. */
turnId: text('turn_id').notNull(),
/**
* The user prompt that initiated this turn — denormalised so the
* rail's "group by prompt" header doesn't have to join the JSONL
* log. Capped at 280 chars in code; the column is unbounded.
*/
turnPrompt: text('turn_prompt').notNull(),
/** Workspace-relative path (e.g. `reports/q1.pdf`). */
path: text('path').notNull(),
size: integer('size').notNull(),
/** mtime in ms — used to detect re-modifications. */
mtimeMs: integer('mtime_ms').notNull(),
/** Server clock when our watcher first saw it. */
createdAt: integer('created_at').notNull(),
/**
* `'diff'` for the V1 per-turn workspace diff watcher;
* `'tool'` reserved for the future tool-event parsing layer.
*/
detectedBy: text('detected_by', { enum: ['diff', 'tool'] })
.notNull()
.default('diff'),
},
(table) => [
// One row per (agent, path) pair — re-modifications update in place,
// so a tool that overwrites `report.pdf` doesn't accumulate
// duplicate rows. The most recent turn that touched the file owns
// the row.
uniqueIndex('produced_files_agent_path_unique').on(
table.agentDefinitionId,
table.path,
),
// Outputs-rail query: latest files per agent.
index('produced_files_agent_created_idx').on(
table.agentDefinitionId,
table.createdAt,
),
// Inline-card query: by turn.
index('produced_files_turn_idx').on(table.turnId),
// Cleanup hook: by session (when a session is deleted later).
index('produced_files_session_idx').on(table.sessionKey),
],
)
export type ProducedFileRow = InferSelectModel<typeof producedFiles>
export type NewProducedFileRow = InferInsertModel<typeof producedFiles>

View File

@@ -1,55 +0,0 @@
# ACL Matcher
The ACL matcher blocks guarded tool actions (click, fill, hover, etc.) when they target elements that match user-defined access control rules. It scores each rule against the target element using a combination of exact, fuzzy, and semantic similarity — then blocks if the confidence exceeds a threshold.
## How it works
When a guarded tool is invoked, `acl-guard.ts` resolves the target element's properties (text content, aria labels, attributes, etc.) and runs them through the scoring pipeline:
1. **Site filtering** — rules are filtered to those matching the current page URL
2. **Site-only rules** — rules with no selector/text/description block the entire site immediately
3. **Element scoring** — remaining rules are scored against the element using three signals:
| Signal | Weight | How it works |
|--------|--------|-------------|
| Exact | 25% | Are any compiled rule terms a substring of an element field? |
| Fuzzy | 25% | Edit distance ratio between rule terms and element text windows |
| Semantic | 50% | Cosine similarity of sentence embeddings (BAAI/bge-small-en-v1.5 via ONNX) |
The weighted scores produce a **confidence** value between 0 and 1. If confidence >= **0.4** (Handpicked, needs updating), the action is blocked.
## Files
| File | Purpose |
|------|---------|
| `acl-guard.ts` | Entry point — called by `framework.ts` during tool execution |
| `acl-scorer.ts` | Core pipeline: text normalization, feature extraction, scoring, decision |
| `acl-embeddings.ts` | Lazy-loaded `@huggingface/transformers` pipeline for semantic similarity |
| `acl-edit-distance.ts` | Levenshtein edit distance ratio for fuzzy matching |
| `acl-stopwords.ts` | Static set of 198 English stopwords (from NLTK corpus) |
Shared types and basic matchers live in `packages/shared/`:
- `src/types/acl.ts``AclRule` and `ElementProperties` interfaces
- `src/acl/match.ts` — site pattern globbing and CSS selector matching
## Embedding model
The semantic scoring uses [BAAI/bge-small-en-v1.5](https://huggingface.co/BAAI/bge-small-en-v1.5) (~33MB ONNX model) via `@huggingface/transformers`. The model downloads automatically on first use and is cached for the process lifetime.
Override the model with the `ACL_EMBEDDING_MODEL` environment variable (e.g. `ACL_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5`).
## Testing
```bash
bun --env-file=.env.development test apps/server/tests/tools/acl-scorer.test.ts
```
Test fixtures live in `apps/server/tests/__fixtures__/acl/` (courtesy of claude code):
| Fixture | Tests |
|---------|-------|
| `submit-button.json` | Exact match — "Place Order" button vs "block checkout submit" rule |
| `semantic-payment.json` | Semantic match — "Proceed to Checkout" vs "prevent purchase actions" |
| `semantic-delete.json` | Semantic match — "Remove my account permanently" vs "block account deletion" |
| `semantic-send-email.json` | Semantic match — send button vs "do not dispatch emails" |
| `semantic-safe.json` | False positive — "View Report" should NOT be blocked by payment/delete rules |

View File

@@ -1,25 +0,0 @@
export function editDistanceRatio(a: string, b: string): number {
const maxLength = Math.max(a.length, b.length)
if (maxLength === 0) return 1.0
let previousRow = Array.from({ length: b.length + 1 }, (_, index) => index)
let currentRow = new Array<number>(b.length + 1)
for (let i = 1; i <= a.length; i++) {
currentRow[0] = i
for (let j = 1; j <= b.length; j++) {
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1
currentRow[j] = Math.min(
previousRow[j] + 1,
currentRow[j - 1] + 1,
previousRow[j - 1] + substitutionCost,
)
}
;[previousRow, currentRow] = [currentRow, previousRow]
}
const distance = previousRow[b.length]
return 1.0 - distance / maxLength
}

View File

@@ -1,136 +0,0 @@
import { logger } from '../../lib/logger'
interface SemanticScore {
score: number
backend: string
}
interface EmbeddingOutput {
tolist: () => number[][]
dispose?: () => void
}
interface FeatureExtractionPipeline {
(
texts: string[],
options: { pooling: string; normalize: boolean },
): Promise<EmbeddingOutput>
dispose?: () => Promise<void>
}
let pipelineInstance: FeatureExtractionPipeline | null = null
const LOAD_RETRY_MS = 60_000
let lastLoadFailedAt = 0
let cleanupListener: (() => void) | null = null
function getModelName(): string {
return process.env.ACL_EMBEDDING_MODEL ?? 'Xenova/bge-small-en-v1.5'
}
function isSemanticDisabled(): boolean {
return process.env.ACL_EMBEDDING_DISABLE === 'true'
}
export async function disposeSemanticPipeline(): Promise<void> {
const current = pipelineInstance
pipelineInstance = null
if (cleanupListener) {
process.removeListener('beforeExit', cleanupListener)
cleanupListener = null
}
if (!current?.dispose) {
return
}
try {
await current.dispose()
} catch (error) {
logger.warn('ACL embedding model disposal failed', {
error: error instanceof Error ? error.message : String(error),
})
}
}
function registerPipelineCleanup(): void {
if (cleanupListener) {
return
}
cleanupListener = () => {
// beforeExit cannot await async cleanup, so explicit disposal is still
// required anywhere teardown must be deterministic.
void disposeSemanticPipeline()
}
process.once('beforeExit', cleanupListener)
}
async function ensurePipeline(): Promise<FeatureExtractionPipeline | null> {
if (pipelineInstance) return pipelineInstance
if (lastLoadFailedAt > 0 && Date.now() - lastLoadFailedAt < LOAD_RETRY_MS) {
return null
}
try {
const { pipeline } = await import('@huggingface/transformers')
const extractor = await pipeline('feature-extraction', getModelName(), {
dtype: 'fp32',
})
pipelineInstance = extractor as unknown as FeatureExtractionPipeline
registerPipelineCleanup()
lastLoadFailedAt = 0
logger.info('ACL embedding model loaded', { model: getModelName() })
return pipelineInstance
} catch (error) {
lastLoadFailedAt = Date.now()
logger.warn(
'ACL embedding model failed to load, semantic scoring disabled',
{
model: getModelName(),
error: error instanceof Error ? error.message : String(error),
},
)
return null
}
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
export async function computeSemanticSimilarity(
left: string,
right: string,
): Promise<SemanticScore> {
if (!left || !right) return { score: 0, backend: 'none' }
if (isSemanticDisabled()) return { score: 0, backend: 'disabled' }
const extractor = await ensurePipeline()
if (!extractor) return { score: 0, backend: 'error' }
try {
const output = await extractor([left, right], {
pooling: 'cls',
normalize: true,
})
const embeddings = output.tolist()
output.dispose?.()
const score = cosineSimilarity(embeddings[0], embeddings[1])
return {
score: Math.max(0, Math.min(score, 1)),
backend: 'transformers.js',
}
} catch (error) {
logger.warn('ACL semantic similarity computation failed', {
error: error instanceof Error ? error.message : String(error),
})
return { score: 0, backend: 'error' }
}
}

View File

@@ -1,127 +0,0 @@
import { matchesSitePattern } from '@browseros/shared/acl/match'
import type { AclRule } from '@browseros/shared/types/acl'
import type { Browser } from '../../browser/browser'
import { logger } from '../../lib/logger'
import { scoreFixture } from './acl-scorer'
const GUARDED_TOOLS = new Set([
'click',
'click_at',
'fill',
'type_at',
'hover',
'hover_at',
'drag',
'drag_at',
'focus',
'clear',
'check',
'uncheck',
'select_option',
'press_key',
'upload_file',
])
export interface AclCheckResult {
blocked: boolean
rule?: AclRule
pageId?: number
elementId?: number
}
async function resolveTargetElementId(
toolName: string,
args: Record<string, unknown>,
browser: Browser,
pageId: number,
): Promise<number | undefined> {
if (typeof args.element === 'number') return args.element
if (toolName === 'drag' && typeof args.sourceElement === 'number') {
return args.sourceElement
}
if (typeof args.x === 'number' && typeof args.y === 'number') {
return (
(await browser.resolveElementAtPoint(pageId, args.x, args.y)) ?? undefined
)
}
if (
toolName === 'drag_at' &&
typeof args.startX === 'number' &&
typeof args.startY === 'number'
) {
return (
(await browser.resolveElementAtPoint(pageId, args.startX, args.startY)) ??
undefined
)
}
return undefined
}
export async function checkAcl(
toolName: string,
args: Record<string, unknown>,
browser: Browser,
rules: AclRule[],
): Promise<AclCheckResult> {
if (!GUARDED_TOOLS.has(toolName)) return { blocked: false }
if (!rules.length) return { blocked: false }
const pageId = args.page as number | undefined
if (pageId === undefined) return { blocked: false }
const pageInfo = await browser.refreshPageInfo(pageId)
if (!pageInfo) return { blocked: false }
const siteRules = rules.filter((r) =>
matchesSitePattern(pageInfo.url, r.sitePattern),
)
if (!siteRules.length) return { blocked: false }
const siteOnlyRule = siteRules.find(
(r) => !r.selector && !r.textMatch && !r.description,
)
if (siteOnlyRule) {
logger.info('ACL blocked by site-only rule', {
toolName,
pageId,
pageUrl: pageInfo.url,
ruleId: siteOnlyRule.id,
sitePattern: siteOnlyRule.sitePattern,
})
return { blocked: true, rule: siteOnlyRule, pageId }
}
const elementId = await resolveTargetElementId(
toolName,
args,
browser,
pageId,
)
if (elementId === undefined) return { blocked: false }
const props = await browser.resolveElementProperties(pageId, elementId)
if (!props) return { blocked: false }
const decision = await scoreFixture(toolName, pageInfo.url, props, siteRules)
if (decision.blocked) {
const matchedRule = decision.matchedRuleId
? rules.find((rule) => rule.id === decision.matchedRuleId)
: undefined
logger.info('ACL blocked by scorer', {
toolName,
pageId,
pageUrl: pageInfo.url,
elementId,
ruleId: decision.matchedRuleId,
confidence: decision.confidence,
reason: decision.reason,
})
return { blocked: true, rule: matchedRule, pageId, elementId }
}
return { blocked: false }
}

View File

@@ -1,399 +0,0 @@
import { matchesSitePattern } from '@browseros/shared/acl/match'
import type { AclRule, ElementProperties } from '@browseros/shared/types/acl'
import { logger } from '../../lib/logger'
import { editDistanceRatio } from './acl-edit-distance'
import { computeSemanticSimilarity } from './acl-embeddings'
import { NLTK_STOP_WORDS } from './acl-stopwords'
const EXACT_WEIGHT = 0.25
const FUZZY_WEIGHT = 0.25
const SEMANTIC_WEIGHT = 0.5
const BLOCK_THRESHOLD = 0.4
export interface RuleScore {
ruleId: string
blocked: boolean
confidence: number
exactScore: number
fuzzyScore: number
semanticScore: number
semanticBackend: string
selectorMatched: boolean
siteMatched: boolean
reason: string
matchedTerms: string[]
}
export interface MatchDecision {
blocked: boolean
toolName: string
pageUrl: string
matchedRuleId: string | null
confidence: number
reason: string
candidates: RuleScore[]
}
interface RuleMatchInputs {
terms: string[]
ruleText: string
elementFields: string[]
elementText: string
}
// --- Text normalization ---
function splitIdentifierWords(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
}
function normalizeText(value: string): string {
return splitIdentifierWords(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
}
function tokenizeWords(value: string): string[] {
return normalizeText(value)
.split(/\s+/)
.filter((t) => t.length > 0 && /^[a-z0-9]+$/.test(t))
}
function normalizeTerm(term: string): string {
return tokenizeWords(term).join(' ')
}
function dedupe(values: Iterable<string>): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const v of values) {
if (v && !seen.has(v)) {
seen.add(v)
result.push(v)
}
}
return result
}
function dedupeTextTokens(value: string): string {
return dedupe(value.split(/\s+/)).join(' ')
}
// --- Selector matching ---
function selectorMatchesProps(
selector: string,
props: ElementProperties,
): boolean {
const tag = props.tagName.toLowerCase()
const id = props.attributes.id
const classes = (props.attributes.class ?? '').split(/\s+/).filter(Boolean)
for (const raw of selector.split(',')) {
const part = raw.trim()
if (!part) continue
if (part.startsWith('#') && id && part === `#${id}`) return true
if (part.startsWith('.') && classes.some((c) => part === `.${c}`))
return true
const match = part.match(/^(\w+)$/)
if (match && match[1].toLowerCase() === tag) return true
}
return false
}
// --- Feature extraction ---
function extractHostTerms(pattern: string): Set<string> {
const host = pattern.includes('/') ? pattern.split('/')[0] : pattern
const normalized = tokenizeWords(host.replace(/\*/g, ' '))
return new Set(normalized.filter((t) => t.length >= 3))
}
function compileRuleTerms(rule: AclRule): string[] {
const terms: string[] = []
const textMatch = normalizeTerm(rule.textMatch ?? '')
if (textMatch) terms.push(textMatch)
const descriptionRaw = rule.description ?? ''
const description = normalizeTerm(descriptionRaw)
if (!description) return dedupe(terms)
terms.push(description)
const hostTerms = extractHostTerms(rule.sitePattern)
const descTokens = tokenizeWords(descriptionRaw)
const rawTerms = descTokens.filter(
(t) => t.length >= 3 && !NLTK_STOP_WORDS.has(t) && !hostTerms.has(t),
)
terms.push(...rawTerms)
// Make 2-grams and 3-grams from user-provided rules
for (const window of [2, 3]) {
if (rawTerms.length < window) continue
for (let start = 0; start <= rawTerms.length - window; start++) {
terms.push(rawTerms.slice(start, start + window).join(' '))
}
}
return dedupe(terms)
}
function buildRuleText(rule: AclRule): string {
return normalizeText([rule.textMatch ?? '', rule.description ?? ''].join(' '))
}
function buildSearchFields(props: ElementProperties): string[] {
const attrs = props.attributes ?? {}
const rawFields = [
props.labelText ?? '',
props.ariaLabel ?? '',
props.textContent,
attrs.placeholder ?? '',
attrs.title ?? '',
attrs.name ?? '',
attrs.value ?? '',
attrs.id ?? '',
]
return dedupe(rawFields.filter(Boolean).map(normalizeTerm))
}
function buildSearchText(props: ElementProperties): string {
return dedupeTextTokens(
[...buildSearchFields(props), normalizeTerm(props.role ?? '')]
.filter(Boolean)
.join(' '),
)
}
function buildRuleMatchInputs(
rule: AclRule,
props: ElementProperties,
): RuleMatchInputs {
return {
terms: compileRuleTerms(rule),
ruleText: buildRuleText(rule),
elementFields: buildSearchFields(props),
elementText: buildSearchText(props),
}
}
// --- Similarity scoring ---
function phraseWindows(text: string, phraseTokenCount: number): string[] {
const tokens = text.split(/\s+/).filter(Boolean)
if (tokens.length === 0) return []
if (phraseTokenCount <= 1) return tokens
if (tokens.length <= phraseTokenCount) return [tokens.join(' ')]
const windows: string[] = []
for (let i = 0; i <= tokens.length - phraseTokenCount; i++) {
windows.push(tokens.slice(i, i + phraseTokenCount).join(' '))
}
return windows
}
function exactScore(terms: string[], fields: string[]): [number, string[]] {
const matched = terms.filter((term) =>
fields.some((field) => term && field?.includes(term)),
)
return [matched.length > 0 ? 1.0 : 0.0, dedupe(matched)]
}
function fuzzyScore(terms: string[], fields: string[]): number {
let best = 0
for (const term of terms) {
const tokenCount = Math.max(term.split(/\s+/).length, 1)
for (const field of fields) {
const candidates = phraseWindows(field, tokenCount)
if (candidates.length === 0) candidates.push(field)
for (const candidate of candidates) {
best = Math.max(best, editDistanceRatio(term, candidate))
}
}
}
return best
}
function weightedScore(exact: number, fuzzy: number, semantic: number): number {
return (
EXACT_WEIGHT * exact + FUZZY_WEIGHT * fuzzy + SEMANTIC_WEIGHT * semantic
)
}
// --- Rule scoring ---
function hasContentFilter(rule: AclRule): boolean {
return Boolean(rule.selector || rule.textMatch || rule.description)
}
function scoreSelectorMismatch(rule: AclRule): RuleScore {
return {
ruleId: rule.id,
blocked: false,
confidence: 0,
exactScore: 0,
fuzzyScore: 0,
semanticScore: 0,
semanticBackend: 'none',
selectorMatched: false,
siteMatched: true,
reason: 'selector-mismatch',
matchedTerms: [],
}
}
function scoreSiteOnlyRule(rule: AclRule, selectorMatched: boolean): RuleScore {
return {
ruleId: rule.id,
blocked: true,
confidence: 1,
exactScore: 1,
fuzzyScore: 1,
semanticScore: 1,
semanticBackend: 'site-only',
selectorMatched,
siteMatched: true,
reason: 'site-only-rule',
matchedTerms: [],
}
}
function scoreSelectorOnlyRule(
rule: AclRule,
selectorMatched: boolean,
): RuleScore {
const confidence = selectorMatched ? 1 : 0
return {
ruleId: rule.id,
blocked: selectorMatched,
confidence,
exactScore: confidence,
fuzzyScore: confidence,
semanticScore: confidence,
semanticBackend: 'selector-only',
selectorMatched,
siteMatched: true,
reason: 'selector-only',
matchedTerms: [],
}
}
function determineMatchReason(exact: number, confidence: number): string {
if (exact >= 1.0) return 'exact-term-match'
if (confidence >= BLOCK_THRESHOLD) return 'weighted-match'
return 'below-threshold'
}
async function scoreRule(
pageUrl: string,
props: ElementProperties,
rule: AclRule,
): Promise<RuleScore | null> {
if (rule.enabled === false) return null
if (!matchesSitePattern(pageUrl, rule.sitePattern)) return null
let selectorMatched = true
if (rule.selector) {
selectorMatched = selectorMatchesProps(rule.selector, props)
if (!selectorMatched) return scoreSelectorMismatch(rule)
}
if (!hasContentFilter(rule)) return scoreSiteOnlyRule(rule, selectorMatched)
const inputs = buildRuleMatchInputs(rule, props)
if (inputs.terms.length === 0)
return scoreSelectorOnlyRule(rule, selectorMatched)
const [exact, matchedTerms] = exactScore(inputs.terms, inputs.elementFields)
const fuzzy = fuzzyScore(inputs.terms, inputs.elementFields)
const semantic = await computeSemanticSimilarity(
inputs.ruleText,
inputs.elementText,
)
const confidence =
Math.round(weightedScore(exact, fuzzy, semantic.score) * 10000) / 10000
const result: RuleScore = {
ruleId: rule.id,
blocked: confidence >= BLOCK_THRESHOLD,
confidence,
exactScore: Math.round(exact * 10000) / 10000,
fuzzyScore: Math.round(fuzzy * 10000) / 10000,
semanticScore: Math.round(semantic.score * 10000) / 10000,
semanticBackend: semantic.backend,
selectorMatched,
siteMatched: true,
reason: determineMatchReason(exact, confidence),
matchedTerms,
}
logger.debug('ACL rule scored', {
ruleId: result.ruleId,
reason: result.reason,
confidence: result.confidence,
exact: result.exactScore,
fuzzy: result.fuzzyScore,
semantic: result.semanticScore,
semanticBackend: result.semanticBackend,
})
return result
}
export async function scoreFixture(
toolName: string,
pageUrl: string,
element: ElementProperties,
rules: AclRule[],
): Promise<MatchDecision> {
const candidates: RuleScore[] = []
for (const rule of rules) {
const score = await scoreRule(pageUrl, element, rule)
if (score) candidates.push(score)
}
candidates.sort((a, b) => b.confidence - a.confidence)
const top = candidates[0]
const decision: MatchDecision = {
blocked: top?.blocked ?? false,
toolName,
pageUrl,
matchedRuleId: top?.blocked ? top.ruleId : null,
confidence: top?.confidence ?? 0,
reason: top?.reason ?? 'no-matching-rules',
candidates,
}
if (candidates.some((candidate) => candidate.semanticBackend === 'error')) {
logger.warn('ACL decision computed without semantic scoring', {
toolName,
pageUrl,
candidateCount: candidates.length,
})
}
if (decision.blocked) {
logger.info('ACL BLOCKED', {
toolName,
pageUrl,
ruleId: decision.matchedRuleId,
confidence: decision.confidence,
reason: decision.reason,
})
} else {
logger.debug('ACL ALLOWED', { toolName, pageUrl, reason: decision.reason })
}
return decision
}

View File

@@ -1,200 +0,0 @@
export const NLTK_STOP_WORDS = new Set([
'a',
'about',
'above',
'after',
'again',
'against',
'ain',
'all',
'am',
'an',
'and',
'any',
'are',
'aren',
"aren't",
'as',
'at',
'be',
'because',
'been',
'before',
'being',
'below',
'between',
'both',
'but',
'by',
'can',
'couldn',
"couldn't",
'd',
'did',
'didn',
"didn't",
'do',
'does',
'doesn',
"doesn't",
'doing',
'don',
"don't",
'down',
'during',
'each',
'few',
'for',
'from',
'further',
'had',
'hadn',
"hadn't",
'has',
'hasn',
"hasn't",
'have',
'haven',
"haven't",
'having',
'he',
"he'd",
"he'll",
"he's",
'her',
'here',
'hers',
'herself',
'him',
'himself',
'his',
'how',
'i',
"i'd",
"i'll",
"i'm",
"i've",
'if',
'in',
'into',
'is',
'isn',
"isn't",
'it',
"it'd",
"it'll",
"it's",
'its',
'itself',
'just',
'll',
'm',
'ma',
'me',
'mightn',
"mightn't",
'more',
'most',
'mustn',
"mustn't",
'my',
'myself',
'needn',
"needn't",
'no',
'nor',
'not',
'now',
'o',
'of',
'off',
'on',
'once',
'only',
'or',
'other',
'our',
'ours',
'ourselves',
'out',
'over',
'own',
're',
's',
'same',
'shan',
"shan't",
'she',
"she'd",
"she'll",
"she's",
'should',
"should've",
'shouldn',
"shouldn't",
'so',
'some',
'such',
't',
'than',
'that',
"that'll",
'the',
'their',
'theirs',
'them',
'themselves',
'then',
'there',
'these',
'they',
"they'd",
"they'll",
"they're",
"they've",
'this',
'those',
'through',
'to',
'too',
'under',
'until',
'up',
've',
'very',
'was',
'wasn',
"wasn't",
'we',
"we'd",
"we'll",
"we're",
"we've",
'were',
'weren',
"weren't",
'what',
'when',
'where',
'which',
'while',
'who',
'whom',
'why',
'will',
'with',
'won',
"won't",
'wouldn',
"wouldn't",
'y',
'you',
"you'd",
"you'll",
"you're",
"you've",
'your',
'yours',
'yourself',
'yourselves',
])

View File

@@ -1,8 +1,7 @@
import { z } from 'zod'
import type { BookmarkNode } from '../browser/bookmarks'
import { defineToolWithCategory } from './framework'
import { defineTool } from './framework'
const defineManagementTool = defineToolWithCategory('data-modification')
const bookmarkNodeSchema = z.object({
id: z.string(),
title: z.string(),
@@ -27,7 +26,7 @@ function formatBookmarkTree(nodes: BookmarkNode[]): string {
return lines.join('\n')
}
export const get_bookmarks = defineManagementTool({
export const get_bookmarks = defineTool({
name: 'get_bookmarks',
description: 'List all bookmarks in the browser',
input: z.object({}),
@@ -49,7 +48,7 @@ export const get_bookmarks = defineManagementTool({
},
})
export const create_bookmark = defineManagementTool({
export const create_bookmark = defineTool({
name: 'create_bookmark',
description: 'Create a new bookmark or folder. Omit url to create a folder.',
input: z.object({
@@ -77,7 +76,7 @@ export const create_bookmark = defineManagementTool({
},
})
export const remove_bookmark = defineManagementTool({
export const remove_bookmark = defineTool({
name: 'remove_bookmark',
description: 'Remove a bookmark or folder by ID (recursive)',
input: z.object({
@@ -94,7 +93,7 @@ export const remove_bookmark = defineManagementTool({
},
})
export const update_bookmark = defineManagementTool({
export const update_bookmark = defineTool({
name: 'update_bookmark',
description: 'Update a bookmark title or URL',
input: z.object({
@@ -116,7 +115,7 @@ export const update_bookmark = defineManagementTool({
},
})
export const move_bookmark = defineManagementTool({
export const move_bookmark = defineTool({
name: 'move_bookmark',
description: 'Move a bookmark or folder into a different folder',
input: z.object({
@@ -143,7 +142,7 @@ export const move_bookmark = defineManagementTool({
},
})
export const search_bookmarks = defineManagementTool({
export const search_bookmarks = defineTool({
name: 'search_bookmarks',
description: 'Search bookmarks by title or URL',
input: z.object({

View File

@@ -1,7 +1,5 @@
import { z } from 'zod'
import { defineToolWithCategory } from './framework'
const defineAssistantTool = defineToolWithCategory('assistant')
import { defineTool } from './framework'
const BROWSEROS_INFO = `# BrowserOS — The Open-Source AI Browser
@@ -99,7 +97,7 @@ function getTopicContent(topic: string): string {
: BROWSEROS_INFO.slice(startIdx).trim()
}
export const browseros_info = defineAssistantTool({
export const browseros_info = defineTool({
name: 'browseros_info',
description:
'Get information about BrowserOS features, capabilities, and documentation links. Use when users ask "What is BrowserOS?", "What can BrowserOS do?", or about specific features.',

Some files were not shown because too many files have changed in this diff Show More