From ef76b22733cf19ab48cb4c6df3bf37b0cbec76e1 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Tue, 23 Jun 2026 00:20:13 +0300 Subject: [PATCH] D2: seed baked LSP plugins into persistent state on first boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image installs the LSP plugins under /root/.claude, but the launcher symlinks /root/.claude -> /agent-vm-state/claude for persistence, which shadows the baked tree — the booted guest's `claude plugin list` was empty. Stash the plugins + settings to /opt/agent-vm/claude-seed (not shadowed) and ship seed-claude-plugins.sh; the launch prelude runs it before exec'ing the agent, copying the stash into the state dir once and merging enabledPlugins. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/agent-vm/src/run.rs | 21 +++++++++++++++++++ images/Dockerfile | 11 ++++++++++ images/seed-claude-plugins.sh | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 images/seed-claude-plugins.sh diff --git a/crates/agent-vm/src/run.rs b/crates/agent-vm/src/run.rs index 3b1ed3b..c4116e6 100644 --- a/crates/agent-vm/src/run.rs +++ b/crates/agent-vm/src/run.rs @@ -1835,6 +1835,16 @@ fn parse_github_slug(url: &str) -> Option { const STRIP_IPV6_NAMESERVERS: &str = "sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true"; +/// Seed the image's baked Claude LSP plugins into the persistent state dir on +/// first boot (PLAN.md D2). The image installs them under `/root/.claude`, but +/// the persistence symlink (`/root/.claude -> /agent-vm-state/claude`) shadows +/// that tree so the booted guest's `claude plugin list` is empty. The image +/// ships `/opt/agent-vm/seed-claude-plugins.sh`, which copies the stash into +/// the state dir once. Guarded on the script's presence so older images (no +/// stash) are an inert no-op, and idempotent so it only does work on first boot. +const SEED_CLAUDE_PLUGINS: &str = + "[ -x /opt/agent-vm/seed-claude-plugins.sh ] && /opt/agent-vm/seed-claude-plugins.sh || true"; + /// Build the `bash -c` line that runs inside the guest: the prelude /// (IPv6-nameserver strip, stdin redirect, optional chrome-CA install, /// optional project runtime hook) followed by `exec`'ing the chosen @@ -1850,6 +1860,7 @@ fn build_agent_shell_line( let path = shell_escape(project_guest_path); let prelude = format!( "{STRIP_IPV6_NAMESERVERS}\n\ + {SEED_CLAUDE_PLUGINS}\n\ [ -t 0 ] || exec < /dev/null\n\ {chrome_mcp_prelude}\ _hook={path}/.agent-vm.runtime.sh\n\ @@ -1919,6 +1930,16 @@ mod tests { assert!(line.contains("exec 'codex' 'exec'")); } + #[test] + fn build_agent_shell_line_seeds_claude_plugins_before_exec() { + // D2: the prelude seeds the baked LSP plugins into the persistent + // state dir, and must do so before the agent execs. + let line = build_agent_shell_line("/work/proj", "", "claude", &[]); + let seed = line.find(SEED_CLAUDE_PLUGINS).expect("seed step present"); + let exec = line.find("exec 'claude'").expect("exec present"); + assert!(seed < exec, "seed must run before exec; got: {line}"); + } + /// Guard the exact `sed` program in `STRIP_IPV6_NAMESERVERS`. The /// regex is load-bearing (see the const's doc comment): an accidental /// edit that dropped the `^` anchor or the `:` class would silently diff --git a/images/Dockerfile b/images/Dockerfile index f9080f1..23d3c9a 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -303,6 +303,17 @@ RUN { /root/.local/bin/claude plugin marketplace add anthropics/claude-plugins-o && echo "==> LSP plugins installed at build time:" \ && { /root/.local/bin/claude plugin list || true; } +# D2 fix: the running guest symlinks /root/.claude -> /agent-vm-state/claude +# (persistence, run.rs), which shadows the plugin tree baked just above so the +# booted guest's `claude plugin list` shows nothing. Stash the plugins + +# settings to a NON-shadowed path and ship a first-boot seed script; the +# launcher prelude calls it before exec'ing the agent. +RUN mkdir -p /opt/agent-vm/claude-seed \ + && cp -a /root/.claude/plugins /opt/agent-vm/claude-seed/plugins \ + && cp -a /root/.claude/settings.json /opt/agent-vm/claude-seed/settings.json +COPY seed-claude-plugins.sh /opt/agent-vm/seed-claude-plugins.sh +RUN chmod +x /opt/agent-vm/seed-claude-plugins.sh + # OpenCode official installer. Installs into ~/.opencode/bin; PATH already # includes it. Symlink into /usr/local/bin so non-login shells find it too. RUN curl -fsSL https://opencode.ai/install | bash \ diff --git a/images/seed-claude-plugins.sh b/images/seed-claude-plugins.sh new file mode 100644 index 0000000..b3f58c6 --- /dev/null +++ b/images/seed-claude-plugins.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Seed the image's baked Claude LSP plugins into the persistent state dir on +# first boot. +# +# The image installs the plugins into /root/.claude/plugins at build time, but +# the launcher symlinks /root/.claude -> /agent-vm-state/claude for persistence +# (run.rs), which shadows the baked tree so `claude plugin list` sees nothing. +# This script (called from the launcher prelude before the agent execs) copies +# the stash into the state dir once and merges the enabledPlugins + +# extraKnownMarketplaces keys into the state settings.json, preserving anything +# the user already set. Best-effort: never fails the launch. +# +# Idempotent: a present /agent-vm-state/claude/plugins means we already seeded +# (or the user manages plugins themselves) — do nothing. +SEED=/opt/agent-vm/claude-seed +STATE=/agent-vm-state/claude + +[ -d "$SEED/plugins" ] || exit 0 +[ -e "$STATE/plugins" ] && exit 0 + +mkdir -p "$STATE" 2>/dev/null +cp -a "$SEED/plugins" "$STATE/plugins" 2>/dev/null || exit 0 + +if [ -f "$SEED/settings.json" ] && command -v node >/dev/null 2>&1; then + node -e ' +const fs = require("fs"); +// With `node -e CODE a b`, user args start at argv[1] (no script path slot). +const [, statePath, seedPath] = process.argv; +const seed = JSON.parse(fs.readFileSync(seedPath, "utf8")); +let st = {}; +try { st = JSON.parse(fs.readFileSync(statePath, "utf8")); } catch (e) {} +st.enabledPlugins = Object.assign({}, seed.enabledPlugins || {}, st.enabledPlugins || {}); +st.extraKnownMarketplaces = Object.assign({}, seed.extraKnownMarketplaces || {}, st.extraKnownMarketplaces || {}); +fs.writeFileSync(statePath, JSON.stringify(st, null, 2)); +' "$STATE/settings.json" "$SEED/settings.json" 2>/dev/null || true +fi + +exit 0