D2: seed baked LSP plugins into persistent state on first boot

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) <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-06-23 00:20:13 +03:00
parent d2de958638
commit ef76b22733
3 changed files with 70 additions and 0 deletions

View file

@ -1835,6 +1835,16 @@ fn parse_github_slug(url: &str) -> Option<String> {
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

View file

@ -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 \

View file

@ -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