mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-09 16:00:54 +00:00
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:
parent
8e03a835bd
commit
f1632b8e9e
2 changed files with 86 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue