Session Workspaces: shared file context for agent and user
Status: IMPLEMENTED, 2026-07-16 (walking-skeleton steps 1–4). Grounded in a full code audit (file:line references below are current as of the draft date). See “Implementation status” at the bottom for what shipped, where, and the deliberate deviations.
Definition
One root directory per chat session — the workspace root — is the shared filesystem context for both parties:
- The agent operates in it:
local_fs(read/write/grep/find) resolves against it and is sandboxed to it. - The user sees it: an explorable file tree in the chat UI, file peek,
and @-mentions — typing
@in the composer autocompletes files from the workspace and attaches them to the prompt as ACP resource blocks.
Same root, two views. The root is runtime-defined per session (picked at
session start alongside model/think), with a serve-level default from an arg
(contenox serve [dir]) or env, and an allowlist guarding what a browser
client may choose.
What the audit found (the punchlines)
- Per-session cwd is already protocol-plumbed but DECORATIVE in the beam
path. ACP
session/newrequires an absolute cwd; acpsvc persists it (session.go:904-920) — but only forsession/listdisplay. Beam always sendscwd: '/'(acpWorkspaceController.ts:53-54, never overridden). Incontenox serve,local_fsis fixed-rooted at the serve process’s directory (serve_cmd.go:153, no cwdResolver) — the session cwd is ignored for file access. - The stdio path already proves the target pattern.
contenox acpwiresNewLocalFSToolsWith("", db, acpFileIO, "local_fs", NewACPCwdResolver(...))(acp_cmd.go:238-244): empty fixed root, per-session cwd resolution, and file IO proxied to the client when it advertisesfs/read_text_file(with server-sideosfallback —runtime/acpsvc/fileio.go). The serve path needs the same wiring, minus the client-FS proxy (browsers have no local FS to offer). - The browse API already exists and is unused.
GET/PUT/POST /files(list/stat/content/download/write/move) is registered in serve behind the/apitoken, rooted at the workspace root (runtime/internal/localfileapi/routes.go,serverapi/server.go:128-134). Beam never calls it. This is the file-explorer data source, already built. - The @-mention carrier exists. libacp has
resource_linkand embeddedresourcecontent blocks (libacp/content.go), initialize advertisesembeddedContext: true, andflattenPromptBlocks(acpsvc/content.go:9-58) already consumes them: inline resource text is appended to the prompt; aresource_linkbecomes aname: uriline the agent can follow withlocal_fs. No new protocol needed. - UI kickstart components:
FileTree(packages/ui, built + storybook, currently dangling — caller supplies the node tree;directoryClickMode: 'navigate'was designed for cwd changes),InlineAttachmentRenderer’sfile_view(already wired for agent-produced file views), andSlashCommandMenu(the in-repo pattern for composer autocomplete — same mechanic as @, different trigger).
Security model
A browser choosing arbitrary host paths is a capability grant; treat it so:
- Serve holds an allowlist of workspace roots: the serve arg/env default,
plus roots explicitly registered (config or API). “Runtime-defined” =
choosing within the allowlist; free-form absolute paths only when the
operator explicitly opens it (flag), mirroring
--local-exec-allowed-dir. session/newvalidates the requested cwd against the allowlist and refuses otherwise (typed error, actionable message).local_fscontainment (checkPath/symlink-escape guards,fs.go:195-287) already enforces the sandbox once rooted; the/filesAPI has its own root containment (localfileservice.go:285isWithinRoot) — but it is rooted at ONE fixed root today and must become per-root (resolve against the session’s workspace root, still allowlist-bound).
Decision points
- @-mention semantics: DECIDED (maintainer, 2026-07-16): reference only.
The composer emits
resource_linkblocks, never embedded resources or attachments — the agent must use its tools to read anything. This is a principle, not a default: prompts stay lean, reads are always fresh, every file access goes through the same sandboxed, policy-visible tool path (HITL can see and gate it), and there is exactly one way content enters context. The protocol keeps accepting embedded resources from external ACP clients for conformance (flattenPromptBlocksbehavior unchanged), but beam never emits them and no embed/attach affordance is built. - Root picker UX: pre-session config-options row (same surface as model/think, shipped 2026-07-16) listing allowlisted roots; free-text only when the operator enabled it.
- File explorer placement: DECIDED (maintainer, 2026-07-16): an IDE-style
panel with a toggle. A dedicated workspace panel on the chat page —
toggled like an IDE’s explorer (own toggle affordance, persists collapsed/
expanded state), not a transient popover and not crammed into the session
sidebar. Fed by
/files; it doubles as the @-mention picker’s data source, andFileTree’sdirectoryClickMode: 'navigate'maps onto it.
Walking skeleton
- Wire cwd → tools in serve (the one real plumbing gap): construct
serve’s
local_fslike the stdio path — empty fixed root + cwdResolver reading the session’s cwd (NewACPCwdResolveralready exists), with the serve-level default root used when a session has none. Allowlist check insession/new. This alone makes the agent workspace-scoped. - Beam sends a real cwd: workspace-root picker in the pre-session
controls (options served like config_options, from the allowlist);
newSession(cwd)already accepts it end to end. - File explorer panel:
FileTreefed byGET /files(extended to resolve against the session’s root), file peek viaGET /files/contentrendered with the existingfile_viewpresentation. - @-mentions: composer
@autocomplete over the same/fileslisting; selection attaches aresource_linkblock — reference only, no embed variant. Server-side consumption already works. - Later: multi-root sessions (ACP
AdditionalDirectories, deliberately unset today —initialize.go:85-96), client-FS proxy for editor-hosted beam variants, write-path UX (agent edits surfacing as diffs vs the tree).
Implementation status (2026-07-16)
Walking-skeleton steps 1–4 shipped. Step 5 (multi-root sessions / client-FS proxy / write-path diffs) remains future work.
Slice A — backend (done)
- New package
runtime/vfs(vfs.go,factory.go): the single containment home —Contain/Within/ResolveRoot, plus aFactory(allowlist + default root,"/"/""→ default) andView.runtime/localtools/fs.go(checkPath,absAllowedDir) andruntime/localfileservice/localfileservice.go(resolveExisting/resolveForWrite) both delegate here; the oldresolvePathFollowingExistingSymlinks/isWithinRootcopies are gone. Existing tests stay green;runtime/vfs/vfs_test.gocovers containment, symlink escape, and allowlist. - Allowlist sources (
serve_cmd.gobuildWorkspaceFactory): serve dir is the always-present default root; extended bycontenox serve [dir]...positional args, repeatable--workspace-root, andWORKSPACE_ROOTSenv (serverapi.Config.WorkspaceRoots, path-list separated). Unchanged when nothing is configured. - Per-session cwd → tools (
serve_cmd.go):local_fsis now built with an empty fixed root +acpsvc.NewServeCwdResolver(db, factory.Default()). DEVIATION from “copy acp_cmd’s transport-closure resolver”: serve shares ONElocal_fsacross many per-connection WebSocket transports, so there is no single transport to close over. The serve resolver instead reads the session’s persisted cwd from the DB (message_indices name → cwd KV), falling back to the default root.local_shellstays fixed at the default root (out of scope). session/newvalidation (acpsvc/session.goresolveWorkspaceCwd): requested cwd validated against the Factory; non-allowlisted → typedErrInvalidParams. Compat:"/"/missing → default root. ADDEDresolveExistingSessionCwdforsession/load/session/resumeso the"/"sentinel (what beam sends on every reload) PRESERVES the session’s stored workspace instead of clobbering it to the default — a correctness fix beyond the skeleton.- Workspace-root config option (
acpsvc/config_options.go): aworkspace-rootselect listing allowlisted roots, surfaced inworkspaceConfigOptions/sessionConfigOptionsonly when an allowlist is configured. It is a pre-session picker;set_config_optionfor it is rejected (cwd is immutable after creation). /filesper-root (internal/localfileapi/workspace.go,serverapi/server.go): when an allowlist is present,AddWorkspaceRoutesresolves each request’srootquery param through the Factory (empty/"/"→ default; non-allowlisted → 422) and serves a cached per-rootlocalfileservice. Legacy single-ProjectRootmode is the fallback. The second contenoxDir-rooted instance (chain/hitl files) is untouched.- Gate A:
go build ./...clean;runtime/vfsunit tests;localtools/localfileserviceexisting tests green;internal/localfileapi/workspace_test.go(per-root + allowlist reject);acpsvc/wire_e2e_test.goTestE2E_Wire_SessionWorkspaceCwd(non-allowlisted rejected, allowlisted accepted,"/"→default, local_fs resolves against the session cwd, reload preserves the workspace);acpsvc/content_test.gopins the resource_link wire contract. (Postgres-container tests skip/fail only on the missing-docker path, as before.)
Slice B — frontend (done)
- Root picker: the
workspace-rootconfig option renders automatically viaConfigOptionControlson the empty chat; the pick feedsnewSession(cwd)and is filtered out of the live-session header (AcpChatPage.tsx). - IDE workspace panel (
pages/chat/components/WorkspacePanel.tsx): toggleable (own header button, state persisted vialib/workspacePanelPref.ts), lazily- loadedFileTreefed byhooks/useWorkspaceFiles.ts(+ purelib/workspaceTree.ts), file peek via/files/contentrendered through the existingInlineAttachmentRendererfile_viewpresentation. - @-mentions (
pages/chat/components/MentionMenu.tsx+lib/mentions.ts): composer@autocomplete over the workspace listing (cloning the SlashCommandMenu interaction), inserts a visible@pathtoken, and on submit serializes text + oneresource_linkblock per mention — no embed variant. ControllersendPrompt(text, mentions)andnewSession(cwd)extended. NOTE: mentions autocomplete over currently-loaded files (root + expanded dirs); a deep file needs its directory expanded first (no recursive index yet). - UI constraints: packages/ui primitives + design tokens; single-purpose
hooks with pure helpers; all strings in a new
workspacei18n namespace (en + de), no protocol terms leaked. - Gate B:
npx tsc --noEmitclean;npx vitest run253 passed (addsmentions.test.ts,workspaceTree.test.ts);npm run buildsucceeds; eslint clean on touched files (only the same pre-existing hook+component fast-refresh warning SlashCommandMenu.tsx already emits).
End-to-end (browser pass, done)
Isolated serve on :32125 (write-isolated copy of the DB, temp workspace roots).
Verified via Playwright + DOM assertions: the Workspace picker lists both
allowlisted roots on the empty chat; the panel toggles and lists the fixture
tree; file peek renders hello.txt; @hel opens the menu and selecting inserts
@hello.txt. No prompt turn sent (single GPU slot). No session was created
(lazy creation), so nothing to clean up.
Incidental fix
hooks/acpWorkspaceController.ts: guarded the empty-roster crash — session/list
returns sessions: null (Go marshals a nil slice as null) for a fresh workspace,
which the roster-paging spread turned into a “not iterable” TypeError (the first
empty-workspace load, e.g. any brand-new install). Now page.sessions ?? [].