Files
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

50 lines
1.5 KiB
TypeScript

/**
* @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,
}
}