fix(secrets): bypass in-VM agent onboarding wizards

Claude Code and Codex CLI both show a first-run wizard (theme picker
for Claude, "trust this folder?" + approval-policy prompts for Codex)
that blocks the agent from doing useful work until a human types
something interactively. The original Bash agent-vm pre-wrote three
files to suppress them; we never ported that.

Adds `write_agent_config_defaults` to secrets.rs, called from
`refresh()` regardless of whether the host has credentials for the
provider. Three files get the bypass treatment:

- <state>/claude/settings.json — theme, hasCompletedOnboarding,
  skipDangerousModePermissionPrompt, effortLevel ("xhigh"). Merge
  semantics: existing user settings preserved, our flags forced.
- <state>/claude.json — the per-user onboarding-state file Claude
  Code actually checks for the wizard trigger. Lives at $HOME root,
  outside our existing /agent-vm-state/claude symlink. run.rs now
  also symlinks /root/.claude.json → /agent-vm-state/claude.json
  so user-side state persists across launches.
- <state>/codex/config.toml — sandbox_mode = "danger-full-access"
  and approval_policy = "never". The microVM is the sandbox, so
  Codex's redundant approval prompts add no security and break
  agent-mode flows.

All three writes use atomic write-and-rename; the JSON ones merge
into an existing file so the bypass is force-set every launch
without trampling user tweaks.

Verified inside the guest after a clean state: `cat /root/.claude/
settings.json` and `cat /root/.claude.json` both show the bypass
flags; `cat /agent-vm-state/codex/config.toml` is the expected TOML.
This commit is contained in:
Evgeny Boger 2026-05-18 00:55:48 +03:00
parent 8e03a835bd
commit f1632b8e9e
2 changed files with 86 additions and 0 deletions

View file

@ -164,6 +164,10 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
p.mkdir("/root/.local", None)
.mkdir("/root/.local/share", None)
.symlink("/agent-vm-state/claude", "/root/.claude", true)
// Onboarding-state file lives at $HOME root, not in
// .claude/. Without persistence the in-VM Claude
// re-runs the theme picker every launch.
.symlink("/agent-vm-state/claude.json", "/root/.claude.json", true)
.symlink(
"/agent-vm-state/opencode",
"/root/.local/share/opencode",

View file

@ -68,6 +68,11 @@ pub fn openai_token_path(state_dir: &Path) -> PathBuf {
pub fn refresh(state_dir: &Path) -> Result<CredsState> {
std::fs::create_dir_all(state_dir.join("tokens"))?;
// First-run bypasses, run regardless of whether the user has host
// credentials for the provider. Without these the in-VM agent
// blocks on a terminal-style wizard at first launch.
write_agent_config_defaults(state_dir)?;
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
None
@ -83,6 +88,28 @@ pub fn refresh(state_dir: &Path) -> Result<CredsState> {
})
}
/// Drop the per-agent bypass files (Claude's onboarding flags + Codex's
/// trust/approval settings) into the per-project state dir. Idempotent
/// across launches; merges instead of overwrites so user tweaks
/// survive.
fn write_agent_config_defaults(state_dir: &Path) -> Result<()> {
let claude_dir = state_dir.join("claude");
std::fs::create_dir_all(&claude_dir)?;
write_default_claude_settings(&claude_dir.join("settings.json"))?;
// ~/.claude.json is the per-user onboarding-state file Claude
// Code checks for the "first launch" theme picker. It sits at
// $HOME root (not inside .claude/), so the symlinked state dir
// doesn't catch it — we instead persist it in our state dir and
// run.rs symlinks /root/.claude.json → /agent-vm-state/claude.json.
write_default_claude_root_state(&state_dir.join("claude.json"))?;
let codex_dir = state_dir.join("codex");
std::fs::create_dir_all(&codex_dir)?;
write_default_codex_config(&codex_dir.join("config.toml"))?;
Ok(())
}
fn refresh_anthropic(state_dir: &Path) -> Result<Option<PathBuf>> {
let Some(host_path) = host_claude_creds_path() else {
return Ok(None);
@ -127,6 +154,28 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Option<PathBuf>> {
Ok(Some(token_file))
}
fn write_default_claude_settings(path: &Path) -> Result<()> {
// Read what's there (if anything), force-set the onboarding-bypass
// fields, write back. Merging instead of overwriting means a user
// who tweaked some other setting inside the sandbox keeps their
// change; force-setting `hasCompletedOnboarding` covers the case
// where Claude wrote a partial settings.json mid-wizard.
let mut settings: Value = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or(serde_json::json!({})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let obj = settings.as_object_mut().context("settings.json is not an object")?;
obj.entry("theme".to_string())
.or_insert(Value::String("dark".into()));
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
obj.insert("skipDangerousModePermissionPrompt".into(), Value::Bool(true));
obj.entry("effortLevel".to_string())
.or_insert(Value::String("xhigh".into()));
atomic_write(path, serde_json::to_vec(&settings)?.as_slice(), 0o644)?;
Ok(())
}
fn refresh_openai(state_dir: &Path) -> Result<Option<PathBuf>> {
let Some(host_path) = host_codex_auth_path() else {
return Ok(None);
@ -181,3 +230,36 @@ fn refresh_openai(state_dir: &Path) -> Result<Option<PathBuf>> {
Ok(Some(token_file))
}
fn write_default_claude_root_state(path: &Path) -> Result<()> {
// Merge-on-existing to preserve user-side updates (project entries
// etc.) but force-set the onboarding flag every launch.
let mut state: Value = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or(serde_json::json!({})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let obj = state.as_object_mut().context("~/.claude.json is not an object")?;
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
obj.insert("bypassPermissionsModeAccepted".into(), Value::Bool(true));
atomic_write(path, serde_json::to_vec(&state)?.as_slice(), 0o644)?;
Ok(())
}
fn write_default_codex_config(path: &Path) -> Result<()> {
use std::{io::Write, os::unix::fs::OpenOptionsExt};
let mut f = match std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o644)
.open(path)
{
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("opening {}", path.display())),
};
let body = "sandbox_mode = \"danger-full-access\"\n\
approval_policy = \"never\"\n";
f.write_all(body.as_bytes())?;
Ok(())
}