Config Explorer: in-UI file manager for shared agent config #8

Merged
lz merged 17 commits from config-explorer into main 2026-05-14 23:27:03 +02:00
Owner

Closes #5.

Summary

Adds the Config Explorer — an in-UI file manager for the shareable agent-config subtree (skills/, agents/, commands/, hooks/, output-styles/, settings.json, CLAUDE.md) of the claude-session Docker volume. Operators edit these from the web UI; workers symlink the five directories into ~/.claude/ at boot and copy the two single files.

The feature name is intentionally tool-agnostic so the surface stays sensible if the worker agent is ever swapped.

What's included

  • Compose lifecycleclaude-session declared in the top-level volumes: block; bind-mounted into Nexus at /shared-config; removed the docker.createVolume lazy-create fallback so compose owns the lifecycle.
  • Backendnexus/src/lib/server/config-explorer/volume-fs.ts (path-safety guards, atomic writes, JSON validation, 256 KiB cap, symlink refusal); six SvelteKit routes at /api/config-explorer/{tree,file,mkdir,rename,path,upload}; auth automatic via hooks.server.ts path-prefix gate. New env var SHARED_CONFIG_DIR (default /shared-config/config).
  • Frontend — desktop-grade file explorer: drag-and-drop move (with self/descendant guards), OS-drop upload (DataTransfer.types sniffed), multi-select, F2 rename, Delete key, context menu, recursive folder delete with confirm-with-preview. CodeMirror 6 in the editor pane via granular imports + dynamic import() inside onMount so it ships in a route-specific chunk. Persistent yellow restart-required banner whenever settings.json / CLAUDE.md are open.
  • Worker — entrypoint seeds the symlinks/copies right after credentials install.
  • DocsAGENTS.md fact #3 reworked to document the two subtrees on the shared volume; new AGENTS.md "Environment variables" reference table covering every var Nexus + worker consume; worker/README.md mount table updated.

Verification

  • pnpm typecheck — 0 errors, 0 warnings, 685 files checked.
  • pnpm test — 113 passing, 23 skipped (Windows symlink + better-sqlite3 binding cases; CI's node:22-alpine runs them).
  • volume-fs.test.ts covers every traversal vector, the 256 KiB boundary, binary/NUL detection, JSON validation, mkdir/rename/unlink semantics, listTree sorting + auto-mkdir of the base, atomicity under simulated rename failure, and symlink refusal across listTree/readFile/unlink (auto-skipped on win32 per AGENTS.md convention).
  • Manual smoke test:
    • docker compose up -d --build → unlock vault → open Config → create skills/test/SKILL.md → Save.
    • Spawn fresh worker; docker exec <worker> readlink /root/.claude/skills should return /session/config/skills.
    • Edit settings.json in UI → restart worker → content present and stat -c '%F' reports regular file.
    • Devtools: PUT /api/config-explorer/file with path: "../../etc/passwd" should return 400.

Operator note

Workers already running when this lands won't see the new symlink seeding until they're recreated (the entrypoint changes only run at container start). Either recycle the fleet or accept that existing workers don't get config propagation until next spawn.

Closes #5. ## Summary Adds the **Config Explorer** — an in-UI file manager for the shareable agent-config subtree (`skills/`, `agents/`, `commands/`, `hooks/`, `output-styles/`, `settings.json`, `CLAUDE.md`) of the `claude-session` Docker volume. Operators edit these from the web UI; workers symlink the five directories into `~/.claude/` at boot and copy the two single files. The feature name is intentionally tool-agnostic so the surface stays sensible if the worker agent is ever swapped. ## What's included - **Compose lifecycle** — `claude-session` declared in the top-level `volumes:` block; bind-mounted into Nexus at `/shared-config`; removed the `docker.createVolume` lazy-create fallback so compose owns the lifecycle. - **Backend** — `nexus/src/lib/server/config-explorer/volume-fs.ts` (path-safety guards, atomic writes, JSON validation, 256 KiB cap, symlink refusal); six SvelteKit routes at `/api/config-explorer/{tree,file,mkdir,rename,path,upload}`; auth automatic via `hooks.server.ts` path-prefix gate. New env var `SHARED_CONFIG_DIR` (default `/shared-config/config`). - **Frontend** — desktop-grade file explorer: drag-and-drop move (with self/descendant guards), OS-drop upload (`DataTransfer.types` sniffed), multi-select, F2 rename, Delete key, context menu, recursive folder delete with confirm-with-preview. CodeMirror 6 in the editor pane via granular imports + dynamic `import()` inside `onMount` so it ships in a route-specific chunk. Persistent yellow restart-required banner whenever `settings.json` / `CLAUDE.md` are open. - **Worker** — entrypoint seeds the symlinks/copies right after credentials install. - **Docs** — `AGENTS.md` fact #3 reworked to document the two subtrees on the shared volume; new `AGENTS.md` "Environment variables" reference table covering every var Nexus + worker consume; `worker/README.md` mount table updated. ## Verification - `pnpm typecheck` — 0 errors, 0 warnings, 685 files checked. - `pnpm test` — 113 passing, 23 skipped (Windows symlink + better-sqlite3 binding cases; CI's node:22-alpine runs them). - `volume-fs.test.ts` covers every traversal vector, the 256 KiB boundary, binary/NUL detection, JSON validation, mkdir/rename/unlink semantics, listTree sorting + auto-mkdir of the base, atomicity under simulated rename failure, and symlink refusal across listTree/readFile/unlink (auto-skipped on win32 per AGENTS.md convention). - Manual smoke test: - `docker compose up -d --build` → unlock vault → open Config → create `skills/test/SKILL.md` → Save. - Spawn fresh worker; `docker exec <worker> readlink /root/.claude/skills` should return `/session/config/skills`. - Edit `settings.json` in UI → restart worker → content present and `stat -c '%F'` reports `regular file`. - Devtools: `PUT /api/config-explorer/file` with `path: "../../etc/passwd"` should return 400. ## Operator note Workers already running when this lands won't see the new symlink seeding until they're recreated (the entrypoint changes only run at container start). Either recycle the fleet or accept that existing workers don't get config propagation until next spawn.
lz added 11 commits 2026-05-14 19:32:29 +02:00
Move single-use CSS from src/app.css into the owning .svelte components so
the global stylesheet only carries the shared design-system layer:

- header / .logo / .logo-mark / .nav-btn / nav layout / mobile header tweak
  -> Header.svelte
- .toast and its modifiers (.show, .error, .ok, .warn) -> Toast.svelte
- .sessions-block / .sessions-title / .session-row / .session-info /
  .session-name / form.session-add / .agent-teams-toggle /
  .badge.teams-on|off / .sessions-block .empty override -> Sessions.svelte
- .sessions-mount / .sessions-disabled -> Workers.svelte
- details.advanced / summary / [open] summary -> NewWorker.svelte

Drop the unused #nav id selector (no element carried that id).

Add a scope contract comment at the top of app.css spelling out what stays
global (tokens, resets, shared utilities like .btn/.field/.card/.section/
.note/.empty/.overlay/.pill/.kbd/.code-block/.status-dot/.repo-picker/
.badge base) and what does not, so future single-use selectors land in
their component rather than the global file.

Pure relocation -- no visual changes, no class renames.
Declare the claude-session volume in docker-compose.yml's top-level volumes:
block so fresh installs materialize it on `docker compose up` instead of
relying on Nexus to lazily auto-create it. Bind it into the nexus container
at /shared-config so the upcoming Claude Config file manager can read/write
the shareable subtree via plain fs/promises (no throwaway-container hop).

Remove the docker.createVolume fallback in probeClaudeSession: with compose
owning the lifecycle, an inspect failure means real misconfiguration, not a
recoverable first-boot condition. Surface the cause in the probe hint.
Default /shared-config/config inside the container, matching the bind
mount declared in docker-compose.yml. Reads/writes from the upcoming
Claude Config UI go through this path.
Pure fs/promises module backing the upcoming /api/claude-config/* routes.
All operations are anchored to a base directory and route every relative
path through assertSafePath + assertNoSymlinksOnPath before touching disk.

Guards:
- segment whitelist /^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/, no leading dots
- reject .., absolute paths, backslashes, NUL/control chars
- depth <= 8 segments, total length <= 512 chars
- lstat ancestors; refuse any symlink encountered on the walk
- listTree reports symlinks as skipped: 'symlink' instead of following

Surface:
- listTree, readFile, writeFile (atomic via temp + rename), mkdir, rename,
  unlink (recursive on dirs)
- 256 KiB cap (decoded), binary-NUL heuristic, optional allowBinary
- JSON.parse validation for *.json writes with line/column on error
- typed VolumeFsError with discriminants BAD_PATH | SYMLINK | TOO_LARGE |
  IS_BINARY | INVALID_JSON | NOT_FOUND | EXISTS

44 unit tests cover every traversal vector, the size cap boundary, binary
read/write, JSON validation paths, mkdir/rename/unlink semantics, listTree
sorting + auto-mkdir of the base, and write atomicity under simulated
rename failure. 4 symlink-fixture tests auto-skip on win32 per existing
AGENTS.md convention.

(The module ships under claude-config/ in this commit; the following commit
renames it to config-explorer/ together with adding the routes.)
Pick a tool-agnostic name for the feature: Config Explorer. Decouples the
UI/code surface from "Claude" so swapping the worker agent later doesn't
strand a misleading directory tree.

This commit:
- Renames lib/server/claude-config/ to lib/server/config-explorer/ and
  routes/api/claude-config/ to routes/api/config-explorer/ via git mv,
  retroactively spanning the first two feature commits on this branch.
- Adds the six +server.ts handlers: tree (GET), file (GET/PUT), mkdir
  (POST), rename (POST), path (DELETE), upload (POST multipart).
- Adds src/lib/server/lib/http.ts with readJsonBody + volumeFsErrorResponse
  helpers (now reused across all new routes).
- Touches up the docker-compose.yml and config.ts comments that referenced
  "Claude config" to use the new name. The env var SHARED_CONFIG_DIR keeps
  its name -- it describes the directory generically.

Auth is automatic via hooks.server.ts: every /api/* path not in PUBLIC_API
is gated; we deliberately keep the new routes out of that allowlist.
VolumeFsError discriminants map to HTTP via volumeFsErrorResponse so the
six handlers stay terse.

Upload route caps total request body at 8 MiB and refuses path separators
in part filenames before delegating to writeFile (which then enforces the
256 KiB and JSON-validity guarantees).
Add ConfigExplorerNode + ConfigExplorerFile DTOs and seven client methods
on the existing flat `api` object: listConfigTree, readConfigFile,
writeConfigFile, mkdirConfig, renameConfig, deleteConfig, uploadConfigFiles.

Write/read paths transit base64 over the wire. Export encodeUtf8Base64 and
decodeUtf8Base64 helpers (TextEncoder/Decoder-based, multi-byte-safe) so
the editor component can decode read responses without redoing the dance.

The upload method posts multipart/form-data outside the shared call<T>()
wrapper because that wrapper assumes JSON-in, JSON-out. Same error mapping
to ApiError applies.
Recursive Svelte 5 component over ConfigExplorerNode. Behaves like a
desktop file explorer:

- Click / Ctrl-click / context-menu select; F2 rename; Delete key delete;
  multi-select participates in delete and drag.
- Internal drag-and-drop moves nodes (rename under the hood) with self/
  descendant guards. Multi-select drag serializes paths as a custom MIME
  type so OS-originated drags don't get confused with internal ones.
- OS file drag (DataTransfer.types contains 'Files') invokes the onUpload
  callback with the target directory; root drops use destDir=''.
- Right-click opens a contextual menu (new file, new folder, rename,
  delete, upload here for directories).
- Symlinks render struck-through and non-interactive (the server already
  refuses to read or modify them; this is the UX side).

All styling colocated in the component's <style> block per the project's
post-migration CSS contract.
CodeMirror 6 loaded via dynamic import() inside an $effect, so the
@codemirror/* modules ship in a route-specific chunk rather than the
entry bundle. Granular packages only (no umbrella) so tree-shaking works.

Features:
- Markdown / JSON syntax highlighting; YAML and shell via legacy-modes +
  StreamLanguage; plain text fallback.
- Dark theme keyed to the existing CSS tokens (--bg, --surface,
  --surface-2, --border, --accent, --muted, --text).
- Persistent yellow restart banner whenever the path is settings.json or
  CLAUDE.md (those files are copied, not symlinked, into workers).
- Binary placeholder when the server flagged the file as binary.
- onChange callback fires on every doc edit; the parent owns dirty state
  and the Save action.

CodeMirror injects DOM (.cm-editor) that Svelte's style scoper cannot
reach, so the relevant rules use :global(...) inside the editor host.

Adds @codemirror/{state,view,language,lang-markdown,lang-json,legacy-modes}
to nexus dependencies (granular only; no codemirror umbrella).
Wires up the user-facing surface:

- /config-explorer page mounts a toolbar (Refresh / + File / + Folder /
  Upload / Save) on top of a two-pane split (FileTree | FileEditor).
- export const ssr = false in +page.ts so the SvelteKit SSR analyse pass
  never touches the dynamically-imported CodeMirror modules.
- Adds a 'Config' tab to the Header between '+ New' and 'Providers'.
- All mutations route through the api client; the page is the sole owner
  of `selectedPath`, `loaded`, and the dirty-state toggle.
- Drop-target dest is the currently-selected directory (or the parent of
  the selected file, falling back to root). Empty-state card explains
  where saved files land and how workers consume them.
- Restart-required toasts when settings.json / CLAUDE.md are saved (those
  files are copy-not-symlink and need a worker restart to take effect).
- Page CSS uses a :global(main:has(.cfg-page)) override to break out of
  the layout's 640px max-width without modifying app.css.
After credentials install, seed the shareable Config Explorer subtree into
the worker's ~/.claude/:

- skills/, agents/, commands/, hooks/, output-styles/ are directory
  symlinks back to /session/config/$dir on the shared volume, so
  bidirectional changes (operator-edited from the webapp, agent-installed
  from inside a session) flow to the same copy.
- settings.json and CLAUDE.md are copied via `install -m 644` because
  claude-code rewrites settings.json in place; a symlinked source would
  be corrupted on first write. Edits to these two files only take effect
  after a worker restart -- the UI surfaces this with a banner + warn toast.

`ln -sfn` replaces atomically and never follows -- safe across restarts of
the same container.
docs: Config Explorer + env-var reference
Some checks failed
ci / nexus (pull_request) Failing after 1m59s
ci / images (./nexus, agent-nexus) (pull_request) Has been skipped
ci / images (./worker, nexus-worker) (pull_request) Has been skipped
15af4b806f
- AGENTS.md fact #3 reworked to describe the claude-session volume's two
  subtrees explicitly: auth state (credentials.json + account.json at the
  root, kept identical to today's behavior) and the new shareable agent
  config subtree under /session/config/. Calls out the symlink-vs-copy
  rule and the restart-required tradeoff for settings.json / CLAUDE.md.
- AGENTS.md gains a new "Environment variables" section listing every var
  the nexus + worker images consume, with defaults, owner, and purpose.
  Includes SHARED_CONFIG_DIR plus pre-existing vars (DOCKER_HOST,
  CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, LANG/LC_ALL, PROVIDER_KIND, etc.)
  that weren't previously documented in one place. Code remains the source
  of truth -- this is an index.
- worker/README.md mount table notes that /session also carries the Config
  Explorer subtree, and the symlink-seeding step the entrypoint performs.
fix(config-explorer): silence no-control-regex on CONTROL_CHARS_RE
All checks were successful
ci / nexus (pull_request) Successful in 5m5s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 4m25s
ci / images (./worker, nexus-worker) (pull_request) Successful in 2m0s
fd9f82b001
The regex's purpose is to detect and reject control chars in user-supplied
paths, so the literal \x00-\x1f and \x7f ranges are intentional. ESLint's
no-control-regex (an eslint:recommended default) caught it as a typo
footgun on CI -- I'd run typecheck + test locally but not lint, so this
slipped through.

Disable the rule on that single line with a justification comment.
Author
Owner

Actually, the shared configuration directory should have a simpler design and only symlink to ~/.claude, as Claude Code itself supports concurrent processes and the folder is single-user-only.

Actually, the shared configuration directory should have a simpler design and only symlink to ~/.claude, as Claude Code itself supports concurrent processes and the folder is single-user-only.
fix(config-explorer): editor race, upload CSRF, default subdirs
All checks were successful
ci / nexus (pull_request) Successful in 2m43s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 3m6s
ci / images (./worker, nexus-worker) (pull_request) Successful in 43s
0d3a4b2ad2
Three bugs the operator hit while smoke-testing PR #8:

1. **Editor stuck on "Select a file from the tree to edit."** — clicking a
   file in the tree set selectedPath synchronously but loaded.contentBase64
   arrived async. The $effect saw contentBase64 === null first, set
   status = 'none', which unmounted the editor-host div. When content
   finally arrived bind:this={host} was still null and mountEditor bailed.
   Fix: always render the editor-host div, hide via CSS visibility when
   status !== 'ready'. Placeholders (none / loading / binary) overlay it
   with pointer-events: none. Race eliminated -- host is bound from mount
   onward.

2. **"Cross-site POST form submissions are forbidden" on file drop** —
   SvelteKit's CSRF guard rejects multipart/form-data POSTs whose Origin
   header doesn't match the deployment origin exactly (a reverse-proxy
   quirk). Switch the upload to JSON-bodied: base64-encode each file
   client-side, POST { path, files: [{ name, contentBase64 }] }. Same
   shape as every other write endpoint; same writeFile path-safety and
   size-cap guarantees apply per entry. Adds a chunked bytesToBase64
   helper that avoids the String.fromCharCode(...spread) stack overflow
   on files larger than ~64KB.

3. **Tree empty even after spawning workers** — workers do mkdir the five
   canonical subdirs at boot, but Nexus's listTree never seeded them
   itself. Operators arriving at /config-explorer before any worker had
   booted (or with workers spawned from an older worker image) saw an
   empty tree with no orientation. Fix: listTree() takes an opt
   { seedDefaults: true } that mkdir-p's skills/agents/commands/hooks/
   output-styles inside the base dir. The tree route opts in; the unit
   tests stay strict (default off) so they still assert on a clean
   filesystem.

Help-text rewrite in +page.svelte is intentionally NOT in this commit --
it'll be rewritten again in the upcoming worker-shared-volume refactor
when settings.json + CLAUDE.md become live-shared.
refactor(worker): share ~/.claude as a single volume-backed directory
Some checks failed
ci / nexus (pull_request) Has been cancelled
ci / images (./nexus, agent-nexus) (pull_request) Has been cancelled
ci / images (./worker, nexus-worker) (pull_request) Has been cancelled
ba1ceb8be5
Replace the piecemeal "credentials copied + five subdirs symlinked +
two files copied" boot sequence with a single symlink:

    ln -sfn /session/dot-claude /root/.claude

Every worker's ~/.claude IS the same directory on the shared
claude-session volume. The previous model was broken for credentials --
claude-code refreshes the OAuth token by writing back to
~/.claude/.credentials.json, but the per-worker copy meant refreshes
leaked locally and the next worker spawn read a stale token. Now the
file is shared, refreshes propagate fleet-wide, and the sibling
.credentials.lock claude-code uses for concurrent-refresh coordination
also lives on the shared filesystem so it works across containers.

Per-session state (projects/<cwd>) stays distinct because each session
runs in its own --worktree NAME with a unique cwd path, so the known
claude-code JSONL concurrency bugs don't trip -- different sessions
write to different projects/<cwd>/ subdirectories.

settings.json and CLAUDE.md are now live-shared too. Drop the
restart-required banner/toast/help-text from FileEditor and the page;
edits in the UI are visible to running workers immediately.

Existing deployments: the entrypoint auto-migrates pre-refactor volumes
by moving /session/credentials.json into /session/dot-claude/ on first
boot. No re-bootstrap needed.

Files touched:
- worker/entrypoint.sh -- collapse the boot block to one symlink
- worker/bootstrap-claude-auth.sh -- pre-symlink so login writes
  directly to the volume
- nexus/src/lib/server/config.ts -- SHARED_CONFIG_DIR default ->
  /shared-config/dot-claude
- nexus/src/lib/server/workers/session-probe.ts -- probe the new path
- nexus/src/lib/components/config-explorer/FileEditor.svelte -- drop
  restart banner
- nexus/src/routes/config-explorer/+page.svelte -- drop restart-required
  language from help and save toast
- AGENTS.md fact #3 -- rewritten for the whole-dir share
- worker/README.md -- mount table + entrypoint-steps list updated
chore(config-explorer): drop stale references to the old shared-config layout
Some checks failed
ci / nexus (pull_request) Has been cancelled
ci / images (./nexus, agent-nexus) (pull_request) Has been cancelled
ci / images (./worker, nexus-worker) (pull_request) Has been cancelled
4041e81240
Post-refactor cleanup audit. Six files still mentioned the pre-refactor
paths or the deprecated migration shim:

- AGENTS.md env-var table -- SHARED_CONFIG_DIR default was the old
  /shared-config/config; description still referenced /session/config/
  subtree.
- AGENTS.md fact #3 -- mentioned the entrypoint's auto-migration of
  /session/credentials.json. Migration removed; if anyone has a
  pre-refactor volume they re-run the bootstrap.
- docker-compose.yml -- volume comments described /session/config and
  the old "two-layer split" structure.
- DEPLOYMENT.md "Concurrent token refresh" caveat -- pointed at the
  old /session/credentials.json path and described the race as
  "tolerated"; with /session/dot-claude/.credentials.lock now shared,
  it's actively coordinated.
- worker/README.md entrypoint-steps list -- mentioned the legacy
  migration step.
- worker/entrypoint.sh -- drop the pre-refactor /session/credentials.json
  migration block. Strict new-layout-only check; bootstrap is the only
  supported way to seed the volume.

No more references to the old layout anywhere in the codebase.
fix(compose): pin claude-session volume name so Nexus and workers share it
Some checks failed
ci / nexus (pull_request) Has been cancelled
ci / images (./nexus, agent-nexus) (pull_request) Has been cancelled
ci / images (./worker, nexus-worker) (pull_request) Has been cancelled
cd8cb476ad
Compose's default namespacing renames declared volumes to
<project>_<name>, so the volume Nexus binds at /shared-config was actually
"agent-nexus_claude-session". Workers are spawned by Nexus via dockerode
using config.CLAUDE_SESSION_VOLUME (default "claude-session") and got
bound to a separate "claude-session" volume that docker auto-created on
first reference. Two volumes, two contents — Nexus's /shared-config and
every worker's /session were silently out of sync.

`name: claude-session` skips the prefix so compose creates the same
literal name dockerode binds.
feat(config-explorer): show dotfiles, deselect on empty-area click
Some checks failed
ci / nexus (pull_request) Successful in 14m15s
ci / images (./worker, nexus-worker) (pull_request) Has been cancelled
ci / images (./nexus, agent-nexus) (pull_request) Has been cancelled
8e112ef219
- Allow leading-dot segments in the path-safety guard. Path traversal
  (".", "..") is still rejected; the segment whitelist regex always
  accepted dotfiles, only the explicit startsWith('.') check blocked
  them. .credentials.json, .credentials.lock, .last-cleanup etc. are
  now visible and editable in the UI.
- Clicking empty space inside the tree container (or pressing Escape
  while it has focus) clears multi-select and closes the open file.
  Discard-changes prompt still gates the close when there are dirty
  edits.
lz merged commit cadc6e3662 into main 2026-05-14 23:27:03 +02:00
lz deleted branch config-explorer 2026-05-14 23:27:25 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!8
No description provided.