Add Claude Config file manager (shared skills/agents/hooks UI) #5

Closed
opened 2026-05-14 00:08:55 +02:00 by lz · 0 comments
Owner

Goal

Add an in-UI file manager for the shared Claude config (skills, agents, commands, hooks, output-styles, settings.json, CLAUDE.md). Operators edit these from the Nexus web UI; workers pick them up at boot via symlinks into /root/.claude/.

Depends on #4 (SvelteKit migration) — do not start until that ships and is merged.

Why

Today the claude-session Docker volume only holds OAuth state (credentials.json, account.json). Operators have no way to ship shared skills / agents / hooks to the fleet — they'd have to bake them into the worker image or sync them out-of-band. This feature gives every worker the same ~/.claude/ static-config subset, managed from one place.

Architectural choices (already decided — don't relitigate)

  • Two-layer split inside the claude-session volume: keep credentials.json + account.json at the root; add /session/config/ for the shareable subset only. The full ~/.claude/ cannot be mounted directly because it mixes shareable static config with per-worker runtime state (projects/, sessions/, shell-snapshots/, history.jsonl, file-history/, telemetry/, plugins/, ide/, backups/, cache/, downloads/, .last-cleanup, mcp-needs-auth-cache.json) that MUST stay local per worker. The sibling file ~/.claude.json also stays local — it's built per-container by worker/entrypoint.sh:113-127.
  • Symlink directories, copy single files at worker boot. Directory symlinks for skills/, agents/, commands/, hooks/, output-styles/ (so any file claude writes inside e.g. skills/foo/ flows back to the volume). Copy settings.json and CLAUDE.md (claude-code may rewrite settings.json; breaking symlinks across tools is a known footgun). Document tradeoff: UI edits to settings.json / CLAUDE.md only take effect after worker restart.
  • Bind-mount claude-session into the Nexus container at /shared-config, declared in docker-compose.yml. Nexus uses plain fs/promises against /shared-config/config/. Do NOT use the throwaway-container pattern from nexus/src/lib/server/workers/session-probe.ts for file ops — ~200ms per op × dozens of files would make the UI unusable. This is a declarative bind set in compose; the docker-proxy allowlist doesn't see it (no API call involved), so no proxy change is needed.
  • Editor: CodeMirror 6, framework-agnostic. Import from granular @codemirror/* packages (not the codemirror umbrella, which is ~135KB gz and defeats tree-shaking). Targeted imports land around 95–110KB gz with markdown + JSON + shell modes. Lazy-load via dynamic import() inside the editor component so the initial bundle stays small.
  • Tree component: roll your own (~100 lines). The vanilla-JS / Svelte tree-library niche has no actively maintained pick that's worth the dep.

Hard constraints (don't skip)

  • Path traversal defense in volume-fs.ts: reject .., leading dots, backslashes, null bytes, control chars; cap depth ≤ 8 and total length ≤ 512; whitelist segments via /^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/; path.resolve then assert prefix of the base directory. Use lstat (not stat) on every read and refuse S_ISLNK — a malicious worker can plant a symlink inside the volume.
  • Cap writes at 256 KB. Refuse binary unless an explicit allowBinary: true flag is passed (default false). Encode contents as base64 over the wire to avoid JSON-escaping issues.
  • Auth gate: every /api/claude-config/* route requires an unlocked session. With the path-prefix gate in hooks.server.ts (added in #4) this is automatic — just don't put any of these routes in the public allowlist.
  • No Co-Authored-By: Claude line in commits. Standing operator instruction.
  • Compose volume declaration: declare claude-session as a plain named volume (claude-session: {}) in the top-level volumes: block. Do NOT use external: true — fresh installs would fail because today the volume is created lazily by probeClaudeSession (nexus/src/lib/server/workers/session-probe.ts:34). Also remove that docker.createVolume fallback in this PR so compose owns the lifecycle.

Scope

Backend

  • New module nexus/src/lib/server/claude-config/:
    • volume-fs.tslistTree(), readFile(rel), writeFile(rel, contentB64), mkdir(rel), unlink(rel), rename(from, to). All operate under SHARED_CONFIG_DIR (new env var, default /shared-config/config). All paths go through safeJoin().
    • volume-fs.test.ts — vitest covering each traversal case + size cap + symlink-refusal (create a symlink fixture).
  • New API routes under nexus/src/routes/api/claude-config/:
    • GET /api/claude-config/tree
    • GET /api/claude-config/file?path=…
    • PUT /api/claude-config/file body { path, contentBase64 }
    • POST /api/claude-config/mkdir body { path }
    • POST /api/claude-config/rename body { from, to }
    • DELETE /api/claude-config/path?path=…
  • Add SHARED_CONFIG_DIR to src/lib/server/config.ts.

Frontend

  • New page route nexus/src/routes/claude-config/+page.svelte — split-pane layout.
  • New components under nexus/src/lib/views/claude-config/:
    • FileTree.svelte — recursive {#each} over tree data; expanded: Set<string> and selected: string | null runes.
    • FileEditor.svelte — on mount, dynamic-imports CodeMirror 6 (await import('@codemirror/view') etc.) and creates an EditorView bound to a <div bind:this={hostEl}>.
  • Nav entry in nexus/src/routes/+layout.svelte, labeled "Claude Config", positioned between Workspaces and Providers.
  • nexus/src/lib/api-client.ts — add claudeConfig namespace with one method per route. Use btoa(unescape(encodeURIComponent(content))) for the write path.
  • Styles in nexus/src/app.css: .cfg-split { display: grid; grid-template-columns: 260px 1fr; }, .cfg-tree, .cfg-tree .dir, .cfg-editor. Reuse existing palette tokens (var(--muted), var(--bg-2)).

Worker / compose

  • worker/entrypoint.sh after the credentials install (~line 54):
    mkdir -p /session/config
    for d in skills agents commands hooks output-styles; do
      [ -d /session/config/$d ] && ln -sfn /session/config/$d /root/.claude/$d
    done
    for f in settings.json CLAUDE.md; do
      [ -f /session/config/$f ] && install -m 644 /session/config/$f /root/.claude/$f
    done
    
  • docker-compose.yml: add claude-session:/shared-config to the nexus service's volumes: list. Declare claude-session: {} in the top-level volumes: block. Volume is still also bound into workers at /session by nexus/src/lib/server/workers/service.ts (unchanged code path).
  • Remove the docker.createVolume fallback in nexus/src/lib/server/workers/session-probe.ts (compose owns the lifecycle now).

Deps

  • Add to nexus/package.json: codemirror, @codemirror/state, @codemirror/view, @codemirror/lang-markdown, @codemirror/lang-json, @codemirror/legacy-modes. Do NOT install the codemirror umbrella alone.

Docs

  • AGENTS.md fact #3: amend to mention /session/config/ subtree alongside the existing two-file auth state.
  • worker/README.md: note the symlink seeding in the mount table area (line 41).

Verification (issue is not done until all pass)

  1. pnpm test green; new volume-fs.test.ts covers each traversal case + size cap + symlink-refusal.
  2. pnpm build clean. Inspect the build output: CodeMirror should land in a route-specific chunk (only loaded when visiting /claude-config), not in the entry chunk.
  3. docker compose up -d --build. Unlock vault → open "Claude Config" → create skills/test/SKILL.md with a one-line body in the CodeMirror editor → Save.
  4. Spin a fresh workspace + session. Inside the worker tmux: ls /root/.claude/skills/test/SKILL.md resolves; cat shows the content.
  5. docker compose restart nexus → tree still shows the file (volume survived).
  6. docker exec <worker> readlink /root/.claude/skills returns /session/config/skills.
  7. Edit settings.json in UI → restart worker → new content present in /root/.claude/settings.json. Verify it's a regular file, not a symlink (stat -c '%F' /root/.claude/settings.jsonregular file).
  8. Security: from devtools, PUT /api/claude-config/file with path: "../../etc/passwd" → 400.

Gotchas

  • Compose volume on fresh installs: see the constraint about not using external: true and removing the createVolume fallback. This is the highest-likelihood regression — one-line miss breaks docker compose up on a clean clone.
  • CodeMirror umbrella package: importing from 'codemirror' (the meta-package) defeats tree-shaking. Use granular @codemirror/* packages.
  • Symlink vs copy semantics: directory symlinks are intentional (read-write through to the volume). The single-file copies are intentional too (settings.json may be rewritten by claude-code; symlinks could break). Don't "unify" these — they're different on purpose.
  • Health of running workers after symlink seeding: the entrypoint changes only run at container start. Workers started before this PR shipped won't have the symlinks until they're recreated. Document this in the PR description for the operator.

References

  • Prior art for throwaway-container volume access (DO NOT use for this feature, but worth understanding): nexus/src/lib/server/workers/session-probe.ts
  • Existing worker entrypoint: worker/entrypoint.sh
  • Existing compose file: docker-compose.yml
  • AGENTS.md fact #3 (current auth state), fact #5 (per-session probe semantics), and the "no soft-delete" rule under "State machine" (not directly relevant but illustrates project conventions)
## Goal Add an in-UI file manager for the shared Claude config (skills, agents, commands, hooks, output-styles, `settings.json`, `CLAUDE.md`). Operators edit these from the Nexus web UI; workers pick them up at boot via symlinks into `/root/.claude/`. **Depends on #4 (SvelteKit migration)** — do not start until that ships and is merged. ## Why Today the `claude-session` Docker volume only holds OAuth state (`credentials.json`, `account.json`). Operators have no way to ship shared skills / agents / hooks to the fleet — they'd have to bake them into the worker image or sync them out-of-band. This feature gives every worker the same `~/.claude/` static-config subset, managed from one place. ## Architectural choices (already decided — don't relitigate) - **Two-layer split inside the `claude-session` volume**: keep `credentials.json` + `account.json` at the root; add `/session/config/` for the shareable subset only. The full `~/.claude/` cannot be mounted directly because it mixes shareable static config with per-worker runtime state (`projects/`, `sessions/`, `shell-snapshots/`, `history.jsonl`, `file-history/`, `telemetry/`, `plugins/`, `ide/`, `backups/`, `cache/`, `downloads/`, `.last-cleanup`, `mcp-needs-auth-cache.json`) that MUST stay local per worker. The sibling file `~/.claude.json` also stays local — it's built per-container by `worker/entrypoint.sh:113-127`. - **Symlink directories, copy single files** at worker boot. Directory symlinks for `skills/`, `agents/`, `commands/`, `hooks/`, `output-styles/` (so any file claude writes inside e.g. `skills/foo/` flows back to the volume). Copy `settings.json` and `CLAUDE.md` (claude-code may rewrite settings.json; breaking symlinks across tools is a known footgun). Document tradeoff: UI edits to `settings.json` / `CLAUDE.md` only take effect after worker restart. - **Bind-mount `claude-session` into the Nexus container** at `/shared-config`, declared in `docker-compose.yml`. Nexus uses plain `fs/promises` against `/shared-config/config/`. Do NOT use the throwaway-container pattern from `nexus/src/lib/server/workers/session-probe.ts` for file ops — ~200ms per op × dozens of files would make the UI unusable. This is a declarative bind set in compose; the docker-proxy allowlist doesn't see it (no API call involved), so no proxy change is needed. - **Editor: CodeMirror 6**, framework-agnostic. Import from granular `@codemirror/*` packages (not the `codemirror` umbrella, which is ~135KB gz and defeats tree-shaking). Targeted imports land around 95–110KB gz with markdown + JSON + shell modes. Lazy-load via dynamic `import()` inside the editor component so the initial bundle stays small. - **Tree component: roll your own** (~100 lines). The vanilla-JS / Svelte tree-library niche has no actively maintained pick that's worth the dep. ## Hard constraints (don't skip) - **Path traversal defense** in `volume-fs.ts`: reject `..`, leading dots, backslashes, null bytes, control chars; cap depth ≤ 8 and total length ≤ 512; whitelist segments via `/^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/`; `path.resolve` then assert prefix of the base directory. Use `lstat` (not `stat`) on every read and refuse `S_ISLNK` — a malicious worker can plant a symlink inside the volume. - **Cap writes at 256 KB**. Refuse binary unless an explicit `allowBinary: true` flag is passed (default false). Encode contents as base64 over the wire to avoid JSON-escaping issues. - **Auth gate**: every `/api/claude-config/*` route requires an unlocked session. With the path-prefix gate in `hooks.server.ts` (added in #4) this is automatic — just don't put any of these routes in the public allowlist. - **No `Co-Authored-By: Claude` line in commits.** Standing operator instruction. - **Compose volume declaration**: declare `claude-session` as a plain named volume (`claude-session: {}`) in the top-level `volumes:` block. Do NOT use `external: true` — fresh installs would fail because today the volume is created lazily by `probeClaudeSession` (`nexus/src/lib/server/workers/session-probe.ts:34`). Also remove that `docker.createVolume` fallback in this PR so compose owns the lifecycle. ## Scope ### Backend - New module `nexus/src/lib/server/claude-config/`: - `volume-fs.ts` — `listTree()`, `readFile(rel)`, `writeFile(rel, contentB64)`, `mkdir(rel)`, `unlink(rel)`, `rename(from, to)`. All operate under `SHARED_CONFIG_DIR` (new env var, default `/shared-config/config`). All paths go through `safeJoin()`. - `volume-fs.test.ts` — vitest covering each traversal case + size cap + symlink-refusal (create a symlink fixture). - New API routes under `nexus/src/routes/api/claude-config/`: - `GET /api/claude-config/tree` - `GET /api/claude-config/file?path=…` - `PUT /api/claude-config/file` body `{ path, contentBase64 }` - `POST /api/claude-config/mkdir` body `{ path }` - `POST /api/claude-config/rename` body `{ from, to }` - `DELETE /api/claude-config/path?path=…` - Add `SHARED_CONFIG_DIR` to `src/lib/server/config.ts`. ### Frontend - New page route `nexus/src/routes/claude-config/+page.svelte` — split-pane layout. - New components under `nexus/src/lib/views/claude-config/`: - `FileTree.svelte` — recursive `{#each}` over tree data; `expanded: Set<string>` and `selected: string | null` runes. - `FileEditor.svelte` — on mount, dynamic-imports CodeMirror 6 (`await import('@codemirror/view')` etc.) and creates an `EditorView` bound to a `<div bind:this={hostEl}>`. - Nav entry in `nexus/src/routes/+layout.svelte`, labeled "Claude Config", positioned between Workspaces and Providers. - `nexus/src/lib/api-client.ts` — add `claudeConfig` namespace with one method per route. Use `btoa(unescape(encodeURIComponent(content)))` for the write path. - Styles in `nexus/src/app.css`: `.cfg-split { display: grid; grid-template-columns: 260px 1fr; }`, `.cfg-tree`, `.cfg-tree .dir`, `.cfg-editor`. Reuse existing palette tokens (`var(--muted)`, `var(--bg-2)`). ### Worker / compose - `worker/entrypoint.sh` after the credentials install (~line 54): ```sh mkdir -p /session/config for d in skills agents commands hooks output-styles; do [ -d /session/config/$d ] && ln -sfn /session/config/$d /root/.claude/$d done for f in settings.json CLAUDE.md; do [ -f /session/config/$f ] && install -m 644 /session/config/$f /root/.claude/$f done ``` - `docker-compose.yml`: add `claude-session:/shared-config` to the `nexus` service's `volumes:` list. Declare `claude-session: {}` in the top-level `volumes:` block. Volume is still also bound into workers at `/session` by `nexus/src/lib/server/workers/service.ts` (unchanged code path). - Remove the `docker.createVolume` fallback in `nexus/src/lib/server/workers/session-probe.ts` (compose owns the lifecycle now). ### Deps - Add to `nexus/package.json`: `codemirror`, `@codemirror/state`, `@codemirror/view`, `@codemirror/lang-markdown`, `@codemirror/lang-json`, `@codemirror/legacy-modes`. Do NOT install the `codemirror` umbrella alone. ### Docs - `AGENTS.md` fact #3: amend to mention `/session/config/` subtree alongside the existing two-file auth state. - `worker/README.md`: note the symlink seeding in the mount table area (line 41). ## Verification (issue is not done until all pass) 1. `pnpm test` green; new `volume-fs.test.ts` covers each traversal case + size cap + symlink-refusal. 2. `pnpm build` clean. Inspect the build output: CodeMirror should land in a route-specific chunk (only loaded when visiting `/claude-config`), not in the entry chunk. 3. `docker compose up -d --build`. Unlock vault → open "Claude Config" → create `skills/test/SKILL.md` with a one-line body in the CodeMirror editor → Save. 4. Spin a fresh workspace + session. Inside the worker tmux: `ls /root/.claude/skills/test/SKILL.md` resolves; `cat` shows the content. 5. `docker compose restart nexus` → tree still shows the file (volume survived). 6. `docker exec <worker> readlink /root/.claude/skills` returns `/session/config/skills`. 7. Edit `settings.json` in UI → restart worker → new content present in `/root/.claude/settings.json`. Verify it's a regular file, not a symlink (`stat -c '%F' /root/.claude/settings.json` → `regular file`). 8. Security: from devtools, `PUT /api/claude-config/file` with `path: "../../etc/passwd"` → 400. ## Gotchas - **Compose volume on fresh installs**: see the constraint about not using `external: true` and removing the `createVolume` fallback. This is the highest-likelihood regression — one-line miss breaks `docker compose up` on a clean clone. - **CodeMirror umbrella package**: importing from `'codemirror'` (the meta-package) defeats tree-shaking. Use granular `@codemirror/*` packages. - **Symlink vs copy semantics**: directory symlinks are intentional (read-write through to the volume). The single-file copies are intentional too (settings.json may be rewritten by claude-code; symlinks could break). Don't "unify" these — they're different on purpose. - **Health of running workers after symlink seeding**: the entrypoint changes only run at container start. Workers started before this PR shipped won't have the symlinks until they're recreated. Document this in the PR description for the operator. ## References - Prior art for throwaway-container volume access (DO NOT use for this feature, but worth understanding): `nexus/src/lib/server/workers/session-probe.ts` - Existing worker entrypoint: `worker/entrypoint.sh` - Existing compose file: `docker-compose.yml` - AGENTS.md fact #3 (current auth state), fact #5 (per-session probe semantics), and the "no soft-delete" rule under "State machine" (not directly relevant but illustrates project conventions)
lz closed this issue 2026-05-14 23:27:04 +02:00
Sign in to join this conversation.
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#5
No description provided.