feat: add GitHub Copilot as a selectable in-VM agent (PLAN D1)

Why
---
Until now the sandbox only knew how to launch Claude inside the microVM.
PLAN.md item D1 ("Copilot agent") calls for first-class support for GitHub
Copilot so that users who work with Copilot get the same isolated,
credential-scoped VM treatment Claude already enjoys, rather than running
Copilot on the host with broad ambient access. Running Copilot inside the VM
puts its network egress, filesystem view, and credential exposure under the
existing sandbox policy instead of the host environment.

How
---
The change spans the image, the credential layer, and the launch path:

- images/Dockerfile: provision the GitHub Copilot CLI in the guest image
  next to the existing agent tooling, so a pulled image can run Copilot with
  no extra in-VM setup.
- crates/agent-vm/src/host_paths.rs: resolve the host-side location of
  Copilot's credential/config files so secrets discovery knows where to read
  them from.
- crates/agent-vm/src/secrets.rs: teach the secrets layer to discover
  Copilot's credentials on the host and forward them into the guest the same
  way the other agents' tokens are handled, keeping the credential surface
  scoped to the VM. This is the largest part of the change.
- crates/agent-vm/src/run.rs: wire Copilot into the launch path so it can be
  chosen as the agent to run, threading the new credential material through to
  the guest invocation.
- crates/agent-vm/src/main.rs: surface the new agent choice at the CLI layer.

Together these complete PLAN.md item D1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-31 20:23:56 +00:00
parent 94ae08e7c1
commit 1b0688a3ce
5 changed files with 365 additions and 15 deletions

View file

@ -47,6 +47,16 @@ pub fn host_opencode_auth_path() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/share/opencode/auth.json"))
}
/// `$HOME/.cache/claude-vm/copilot-token.json` — the GitHub Copilot
/// token cache the original Bash agent-vm's `copilot_token.py` writes
/// after its OAuth device flow (JSON `{"access_token": "<gho_…>"}`).
/// Same not-found convention as the other host credential helpers:
/// callers treat a missing file as "no cached Copilot token" and fall
/// back to the captured `gh auth token`.
pub fn host_copilot_token_path() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("HOME")?).join(".cache/claude-vm/copilot-token.json"))
}
/// Write `data` to `path` atomically (write a sibling tmp file, then
/// `rename`) with the given Unix mode. The tmp file uses a fixed
/// extension so a crashed run leaves an obvious orphan rather than a

View file

@ -23,9 +23,9 @@ const TOP_AFTER_HELP: &str = "\
Getting started:
agent-vm setup fetch and verify the base image (run once first)
cd ~/your-project
agent-vm claude launch in this project or codex / opencode / shell
agent-vm claude launch in this project or codex / opencode / copilot / shell
claude, codex, opencode and shell share the same options;
claude, codex, opencode, copilot and shell share the same options;
see `agent-vm claude --help` for mounts, ports, networking and credentials.";
#[derive(Parser)]
@ -57,6 +57,9 @@ enum Cmd {
/// Launch OpenCode in a per-project sandbox.
Opencode(run::Args),
/// Launch GitHub Copilot CLI in a per-project sandbox.
Copilot(run::Args),
/// Open a bash shell in a per-project sandbox.
Shell(run::Args),
@ -104,6 +107,7 @@ fn main() -> Result<()> {
Cmd::Claude(args) => exit_with(run::launch(run::Agent::Claude, args).await?),
Cmd::Codex(args) => exit_with(run::launch(run::Agent::Codex, args).await?),
Cmd::Opencode(args) => exit_with(run::launch(run::Agent::Opencode, args).await?),
Cmd::Copilot(args) => exit_with(run::launch(run::Agent::Copilot, args).await?),
Cmd::Shell(args) => exit_with(run::launch(run::Agent::Shell, args).await?),
Cmd::Clipboard(args) => clipboard::run(args),
Cmd::InterceptHook(args) => intercept_hook::run(args).await,

View file

@ -61,6 +61,7 @@ pub enum Agent {
Claude,
Codex,
Opencode,
Copilot,
Shell,
}
@ -70,6 +71,7 @@ impl Agent {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Opencode => "opencode",
Agent::Copilot => "copilot",
Agent::Shell => "bash",
}
}
@ -89,6 +91,14 @@ impl Agent {
match self {
Agent::Claude => &["--dangerously-skip-permissions"],
Agent::Shell => &["-O", "histappend"],
// Copilot CLI's `--allow-all-tools` disables its in-VM
// "may I run this?" confirmations. The microVM is the
// security boundary, so the extra prompts add no
// protection and break non-interactive / agent-mode
// flows — same reasoning as `--dangerously-skip-permissions`
// for Claude. Drop it (`-> &[]`) if the user already
// passed it; the filter in `launch` handles that.
Agent::Copilot => &["--allow-all-tools"],
Agent::Codex | Agent::Opencode => &[],
}
}
@ -96,8 +106,8 @@ impl Agent {
// Footer shown under `-h` (the short summary). A few high-value
// examples plus a pointer to `--help`. Printed verbatim by clap, so
// this is exactly what the user sees. Kept command-agnostic: all four
// launch verbs (claude/codex/opencode/shell) share this `Args`.
// this is exactly what the user sees. Kept command-agnostic: all
// launch verbs (claude/codex/opencode/copilot/shell) share this `Args`.
const LAUNCH_AFTER_HELP: &str = "\
Examples:
agent-vm claude launch Claude Code in the current project
@ -107,7 +117,8 @@ Examples:
Trailing args go to the agent. Run with --help for networking, security, and env details.";
// Fuller footer shown under `--help`. Same command-agnostic constraint.
// Fuller footer shown under `--help`. Same command-agnostic
// constraint: claude/codex/opencode/copilot/shell all share this.
const LAUNCH_AFTER_LONG_HELP: &str = "\
Examples:
agent-vm claude launch in the current project
@ -433,9 +444,34 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
eprintln!("==> GitHub repo scope: <none> (no api.github.com access)");
}
let creds = crate::secrets::refresh(&session.state_dir, &project_guest_path, use_github)
// D1: the Copilot API is reached with a GitHub OAuth token, but
// unlike the gh CLI / repo-push path it is not repo-scoped — so
// Copilot's token capture and egress must follow the *selected
// agent*, not `use_github`. A user running `agent-vm copilot` in a
// non-GitHub project (or with `--no-git`) still expects Copilot to
// work; conversely a claude/codex/opencode/shell session in a
// GitHub repo should NOT get Copilot egress opened or a duplicate
// gh token written to a copilot secret file it never uses.
let want_copilot = matches!(agent, Agent::Copilot);
let creds =
crate::secrets::refresh(&session.state_dir, &project_guest_path, use_github, want_copilot)
.context("snapshotting host credentials")?;
// D1: when Copilot is the selected agent but no usable token could
// be captured, fail loudly here. Otherwise the guest would send
// `Authorization: Bearer msb-copilot-placeholder-v2` to the Copilot
// API with no registered substitution entry — the proxy drops the
// connection (violation scan) or GitHub returns a confusing 401.
if want_copilot && creds.copilot_token_file.is_none() {
anyhow::bail!(
"no GitHub Copilot token found on the host. Sign in on the host \
(e.g. `gh auth login` with a Copilot seat, or run the Copilot \
device-flow login that writes ~/.cache/claude-vm/copilot-token.json) \
and retry, or pick another agent."
);
}
// RAII guard so the Phase-5 host-cred mutation check runs on
// *every* exit path from launch() — including `?` propagation
// from attach/exec_stream errors (review finding #10). Without
@ -563,6 +599,12 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
"/root/.config/opencode",
true,
)
// D1: GitHub Copilot CLI reads/writes ~/.copilot/
// (config.json with trusted_folders + the placeholder
// token, plus its session state). secrets::refresh
// writes the config under <state>/copilot; this exposes
// it at the standard path inside the guest.
.symlink("/agent-vm-state/copilot", "/root/.copilot", true)
// Phase 6: gh/git config sits at /root/.gitconfig and
// /root/.config/gh. write_guest_gh_config writes both
// into state_dir; these symlinks expose them at the
@ -634,7 +676,8 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// the body's refresh_token is a placeholder, not a header.
let has_creds = creds.anthropic_token_file.is_some()
|| creds.openai_token_file.is_some()
|| creds.gh_token_file.is_some();
|| creds.gh_token_file.is_some()
|| creds.copilot_token_file.is_some();
let auto_publish = args.auto_publish;
let allow_lan = args.allow_lan;
let allow_host = args.allow_host;
@ -646,6 +689,17 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
let opencode = creds.opencode_openai_access_token_file.clone();
let gh = creds.gh_token_file.clone();
let has_gh = gh.is_some();
// D1 least-privilege: only wire the Copilot secret (and thus
// open Copilot-API egress) when Copilot is the selected agent.
// `creds.copilot_token_file` can be `Some` for a claude/codex
// session too (gh-fallback capture when `use_github`), but a
// non-Copilot launch must not carry a Copilot egress allow-host
// or a duplicated gh token in a secret it never uses.
let copilot = if want_copilot {
creds.copilot_token_file.clone()
} else {
None
};
let allowed_repos_for_hook = allowed_repos.clone();
let self_path = std::env::current_exe().context("std::env::current_exe")?;
let state_dir = session.state_dir.clone();
@ -743,6 +797,23 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.allow_host(GITHUB_OBJECTS_HOST)
});
}
// D1: GitHub Copilot CLI sends `Authorization: Bearer
// <COPILOT_TOKEN_PLACEHOLDER>` to the Copilot API; the
// proxy swaps the placeholder for the real GitHub OAuth
// token. Copilot streams responses, so disable basic_auth
// to keep the per-chunk fast path (same reasoning as the
// Anthropic/OpenAI secrets above — only the bearer header
// ever needs substitution here).
if let Some(file) = copilot {
n = n.secret(|s| {
s.env("MSB_AGENT_VM_COPILOT_UNUSED")
.value(file)
.placeholder(COPILOT_TOKEN_PLACEHOLDER)
.inject_basic_auth(false)
.allow_host(COPILOT_API_HOST)
.allow_host(COPILOT_API_INDIVIDUAL_HOST)
});
}
n.intercept(|i| {
let mut hook_argv: Vec<String> = vec![
self_path.to_string_lossy().to_string(),
@ -820,6 +891,28 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// done. Same env var the original Bash agent-vm used.
builder = builder.env("IS_SANDBOX", "1");
// D1: GitHub Copilot CLI reads its token from COPILOT_GITHUB_TOKEN.
// Hand it the placeholder; the credential proxy substitutes the real
// GitHub OAuth token for it on the wire to the Copilot API. We set
// it via env (not just ~/.copilot/config.json) because attach()'s
// execve never sources /etc/profile.d, unlike the original Bash
// agent-vm.
//
// Only set when Copilot is the selected agent. Exporting the
// placeholder for a claude/codex/opencode/shell session would have
// the guest emit `Authorization: Bearer msb-copilot-placeholder-v2`
// to the Copilot API with no registered substitution entry (the
// copilot secret is gated on the same condition above), which the
// proxy drops as a violation or GitHub rejects with a 401. By here,
// a Copilot launch is guaranteed to have a captured token (we bailed
// earlier otherwise), so the placeholder always resolves.
if want_copilot {
builder = builder.env(
"COPILOT_GITHUB_TOKEN",
crate::secrets::COPILOT_TOKEN_PLACEHOLDER,
);
}
let profile = env::var("AGENT_VM_PROFILE").is_ok();
eprintln!(
"==> Booting sandbox from {image} ({memory_mib} MiB, {cpus} vCPU; first run pulls layers, otherwise ~3s)"

View file

@ -20,7 +20,8 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::host_paths::{
atomic_write, host_claude_creds_path, host_codex_auth_path, host_opencode_auth_path,
atomic_write, host_claude_creds_path, host_codex_auth_path, host_copilot_token_path,
host_opencode_auth_path,
};
// ---------------------------------------------------------------------------
@ -83,6 +84,17 @@ pub const OPENCODE_OPENAI_REFRESH_PLACEHOLDER: &str = "msb-opencode-placeholder-
/// git credential helper sees this string; the proxy substitutes the
/// real bearer on outbound traffic to GitHub.
pub const GH_TOKEN_PLACEHOLDER: &str = "msb-gh-placeholder-v2";
/// Placeholder for the host's GitHub Copilot token. The in-guest
/// Copilot CLI sees this string (exported as `COPILOT_GITHUB_TOKEN`
/// via `/etc/profile.d` and stored in `~/.copilot/config.json`); the
/// proxy substitutes the real GitHub OAuth token on outbound traffic
/// to the Copilot API. Kept distinct from `GH_TOKEN_PLACEHOLDER` so
/// substituting one can't accidentally rewrite the other in unrelated
/// request bytes — even though both happen to resolve to the same
/// host gh token, they're registered against different allow-host
/// sets (GitHub API vs Copilot API). Mirrors the original Bash
/// agent-vm's `placeholder-copilot-token-injected-by-proxy`.
pub const COPILOT_TOKEN_PLACEHOLDER: &str = "msb-copilot-placeholder-v2";
// Hostnames the secret-substitution proxy + interceptor key off. Kept
// here so the launcher (`run.rs`), the hook (`intercept_hook`), and any
@ -105,6 +117,15 @@ pub const GITHUB_CODELOAD_HOST: &str = "codeload.github.com";
pub const GITHUB_RAW_HOST: &str = "raw.githubusercontent.com";
pub const GITHUB_OBJECTS_HOST: &str = "objects.githubusercontent.com";
/// GitHub Copilot API endpoints. The Copilot CLI sends
/// `Authorization: Bearer <token>` to these; the proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for the real GitHub OAuth token.
/// Both are needed: `api.githubcopilot.com` for business/enterprise
/// seats and `api.individual.githubcopilot.com` for individual ones.
/// Mirrors the original Bash agent-vm's credential-proxy domains.
pub const COPILOT_API_HOST: &str = "api.githubcopilot.com";
pub const COPILOT_API_INDIVIDUAL_HOST: &str = "api.individual.githubcopilot.com";
pub const ANTHROPIC_OAUTH_TOKEN_PATH: &str = "/v1/oauth/token";
pub const OPENAI_OAUTH_TOKEN_PATH: &str = "/oauth/token";
@ -125,6 +146,30 @@ pub struct CredsState {
/// on outbound traffic to GitHub. Only `Some` when the user has
/// `gh` logged in *and* `--no-git` was not passed.
pub gh_token_file: Option<PathBuf>,
/// File holding the host's GitHub Copilot token (a GitHub OAuth
/// token with Copilot access). The proxy substitutes
/// `COPILOT_TOKEN_PLACEHOLDER` for this on outbound traffic to the
/// Copilot API hosts. Sourced from the original Bash agent-vm's
/// device-flow cache (`~/.cache/claude-vm/copilot-token.json`) if
/// present, else falls back to the captured `gh auth token` (a gh
/// login with the Copilot scope works against the Copilot API).
///
/// `Some` whenever a usable token was found and either the Copilot
/// agent is being launched (`want_copilot`) or GitHub egress is
/// already enabled for another agent (`use_github`). Crucially this
/// is NOT gated on `--no-git` for a Copilot launch: the Copilot API
/// is not repo-scoped, so `agent-vm copilot` must work even in a
/// non-GitHub project.
///
/// **Known limitation (no in-session refresh):** unlike the
/// Anthropic/OpenAI access tokens, the Copilot token is not
/// round-tripped through `intercept_hook`'s OAuth-refresh path. If
/// the captured token expires mid-session, the proxy keeps
/// substituting the stale value and Copilot requests start failing
/// with 401 until the next `agent-vm` launch re-captures from the
/// host. gh user tokens are typically long-lived, so the practical
/// impact is low; re-launch to recover.
pub copilot_token_file: Option<PathBuf>,
pub snapshot: Option<HostCredsSnapshot>,
}
@ -174,6 +219,13 @@ pub fn gh_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("gh")
}
/// Per-project location of the Copilot token file the proxy re-reads.
/// Lives in the host-only [`host_secret_dir`], never inside the guest
/// mount (it holds the real GitHub OAuth token).
pub fn copilot_token_path(state_dir: &Path) -> PathBuf {
host_secret_dir(state_dir).join("copilot")
}
/// OpenCode reuses the same OpenAI access token file: both Codex and
/// OpenCode hit api.openai.com / chatgpt.com and the proxy substitutes
/// each provider's distinct placeholder string for the same real
@ -201,6 +253,7 @@ pub fn refresh(
state_dir: &Path,
project_guest_path: &str,
use_github: bool,
want_copilot: bool,
) -> Result<CredsState> {
let _lock = ProjectRefreshLock::acquire(state_dir)
.context("acquiring per-project refresh lock")?;
@ -224,7 +277,7 @@ pub fn refresh(
// 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, project_guest_path)?;
write_agent_config_defaults(state_dir, project_guest_path, want_copilot)?;
let anthropic_token_file = refresh_anthropic(state_dir).unwrap_or_else(|e| {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
@ -270,6 +323,34 @@ pub fn refresh(
None
};
// D1: capture the host's GitHub Copilot token. Prefer the
// device-flow cache the original Bash agent-vm wrote
// (`~/.cache/claude-vm/copilot-token.json`); fall back to the
// `gh auth token` we just captured (a gh login carries the
// Copilot scope for users with a Copilot seat).
//
// Unlike the gh capture, this is NOT gated on `use_github`. The
// Copilot API is reached with a GitHub OAuth token, but it is not
// repo-scoped the way `api.github.com` push is — so the reason to
// run `agent-vm copilot` (GitHub-backed AI) must not be switched off
// just because the project has no detected GitHub remote or the user
// passed `--no-git`. We capture the token whenever the Copilot agent
// is the one being launched (`want_copilot`), or when GitHub egress
// is already enabled for another agent (`use_github`) so an existing
// gh login still flows through. When `want_copilot && !use_github`
// there is no gh fallback token, so capture succeeds only via the
// device-flow cache; the caller surfaces a clear error if nothing
// was obtained rather than letting the guest send an unsubstituted
// placeholder bearer.
let copilot_token_file = if use_github || want_copilot {
refresh_copilot(state_dir, gh_token_file.as_deref()).unwrap_or_else(|e| {
tracing::warn!(error = %e, "copilot credential capture failed; skipping");
None
})
} else {
None
};
// SHA-256 snapshot of host credential files for post-run mutation
// detection. Phase 4's refresh hook *legitimately* rewrites these;
// anything else doing so is a bug to investigate. See
@ -281,6 +362,7 @@ pub fn refresh(
openai_token_file,
opencode_openai_access_token_file,
gh_token_file,
copilot_token_file,
snapshot,
})
}
@ -523,6 +605,71 @@ fn refresh_gh(state_dir: &Path) -> Result<Option<PathBuf>> {
Ok(Some(token_file))
}
/// Parse the original Bash agent-vm's Copilot token cache file. The
/// cache is JSON `{"access_token": "<gho_…>"}` (see
/// `copilot_token.py`); return the non-empty `access_token` string.
/// Split out so it can be unit-tested without touching `$HOME`.
fn extract_copilot_token(raw: &str) -> Option<String> {
let json: Value = serde_json::from_str(raw).ok()?;
let tok = json.get("access_token").and_then(|v| v.as_str())?.trim();
if tok.is_empty() {
None
} else {
Some(tok.to_string())
}
}
/// Capture the host's GitHub Copilot token into a 0600 file under
/// `<state>.secrets/copilot`. The proxy substitutes
/// [`COPILOT_TOKEN_PLACEHOLDER`] for this file's content on outbound
/// traffic to the Copilot API.
///
/// Two sources, in priority order:
/// 1. The device-flow cache the original Bash agent-vm wrote at
/// `~/.cache/claude-vm/copilot-token.json` (JSON
/// `{"access_token": …}`). Reusing it means an existing host
/// Copilot login is honoured without re-running the OAuth device
/// flow inside this Rust launcher.
/// 2. The `gh auth token` we already captured this launch
/// (`gh_token_file`). A gh user OAuth token carries the Copilot
/// scope for accounts with a Copilot seat, so it works against
/// `api.githubcopilot.com`.
///
/// Returns `None` (non-fatal) when neither source yields a token —
/// Copilot is then simply unavailable in the guest, same as the
/// original's "could not obtain Copilot token" warning.
fn refresh_copilot(state_dir: &Path, gh_token_file: Option<&Path>) -> Result<Option<PathBuf>> {
// 1. Device-flow cache from the original Bash agent-vm.
if let Some(cache) = host_copilot_token_path() {
match std::fs::read_to_string(&cache) {
Ok(raw) => {
if let Some(token) = extract_copilot_token(&raw) {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
// Present but unparseable/empty → fall through to gh.
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", cache.display())),
}
}
// 2. Fall back to the gh token captured this launch.
if let Some(gh_file) = gh_token_file {
let token = std::fs::read_to_string(gh_file)
.with_context(|| format!("reading {}", gh_file.display()))?;
let token = token.trim();
if !token.is_empty() {
let token_file = copilot_token_path(state_dir);
atomic_write(&token_file, token.as_bytes(), 0o600)?;
return Ok(Some(token_file));
}
}
Ok(None)
}
/// SHA-256 the three host credential files. Files that don't exist or
/// can't be read are recorded as `None` — only files that successfully
/// hash become anchors for [`verify_snapshot`].
@ -575,7 +722,11 @@ fn hash_file(path: &Path) -> Option<String> {
/// 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, project_guest_path: &str) -> Result<()> {
fn write_agent_config_defaults(
state_dir: &Path,
project_guest_path: &str,
want_copilot: bool,
) -> Result<()> {
let claude_dir = state_dir.join("claude");
std::fs::create_dir_all(&claude_dir)?;
write_default_claude_settings(&claude_dir.join("settings.json"))?;
@ -605,6 +756,24 @@ fn write_agent_config_defaults(state_dir: &Path, project_guest_path: &str) -> Re
std::fs::create_dir_all(&opencode_config_dir)?;
write_default_opencode_config(&opencode_config_dir.join("opencode.json"))?;
// D1: GitHub Copilot CLI reads ~/.copilot/config.json. The
// launcher symlinks /root/.copilot → /agent-vm-state/copilot so
// this file lands at the right path inside the guest. The token
// field is a placeholder; the proxy substitutes the real one
// outbound (see write_default_copilot_config).
//
// Only written when the Copilot agent is actually selected
// (`want_copilot`). Writing the placeholder `github_token` for a
// claude/codex/opencode/shell session would be dead config at best;
// worse, paired with the matching `COPILOT_GITHUB_TOKEN` env it would
// have the guest emit an unsubstituted bearer if anything in the
// session ever hit the Copilot API without a registered secret.
if want_copilot {
let copilot_dir = state_dir.join("copilot");
std::fs::create_dir_all(&copilot_dir)?;
write_default_copilot_config(&copilot_dir.join("config.json"))?;
}
// Persistent per-project bash history. The launcher symlinks
// /root/.bash_history → /agent-vm-state/bash_history; touching
// the file here ensures the symlink target exists on first
@ -1117,6 +1286,43 @@ fn write_default_opencode_config(path: &Path) -> Result<()> {
Ok(())
}
/// Write the GitHub Copilot CLI's `~/.copilot/config.json`. Two
/// purposes, both mirroring the original Bash agent-vm's
/// `_copilot_vm_setup_home`:
///
/// - `trusted_folders = ["/"]` so the CLI never prompts "do you
/// trust this folder?" — the microVM is the sandbox, so trusting
/// every path inside it is correct.
/// - the token field carries [`COPILOT_TOKEN_PLACEHOLDER`], which
/// the proxy substitutes for the real token on outbound traffic.
/// The CLI also honours the `COPILOT_GITHUB_TOKEN` env var (set by
/// the launcher); writing it here too covers config-first reads.
///
/// Merge-on-existing so a user's own settings survive across launches;
/// only the fields we manage are force-set.
fn write_default_copilot_config(path: &Path) -> Result<()> {
let mut config: 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 = config
.as_object_mut()
.context("copilot config.json is not an object")?;
// Trust everything — the VM itself is the security boundary.
obj.insert(
"trusted_folders".into(),
Value::Array(vec![Value::String("/".into())]),
);
// Placeholder token; the proxy swaps it for the real one outbound.
obj.insert(
"github_token".into(),
Value::String(COPILOT_TOKEN_PLACEHOLDER.into()),
);
atomic_write(path, serde_json::to_vec(&config)?.as_slice(), 0o600)?;
Ok(())
}
/// Advisory exclusive flock on `<state_dir>/.refresh.lock`. Held for
/// the duration of [`refresh`] so two concurrent `agent-vm` launchers
/// in the same project don't interleave reads/writes of the shared
@ -1276,6 +1482,36 @@ mod tests {
}
}
/// D1 regression guard for the per-agent Copilot gating (review
/// finding #5). With neither GitHub egress (`use_github=false`) nor
/// the Copilot agent selected (`want_copilot=false`), `refresh` must
/// not capture a Copilot token — both capture conditions are off, so
/// `copilot_token_file` is `None` by construction, independent of any
/// host/`$HOME`/gh state (this combination skips both `refresh_gh`
/// and `refresh_copilot`). Also re-asserts the security-critical
/// property that the copilot token *path* lives *outside* the
/// guest-bind-mounted state dir, same as the other token files.
#[test]
fn copilot_token_not_captured_without_use_github_or_want_copilot() {
let dir = tempfile::tempdir().unwrap();
let sd = dir.path();
let creds = super::refresh(sd, "/workspace/p", false, false).unwrap();
assert!(
creds.copilot_token_file.is_none(),
"copilot token captured despite use_github=false and want_copilot=false"
);
// Independent of capture: the path the proxy would re-read must
// never be under the guest mount (threat-model invariant).
let cp = copilot_token_path(sd);
assert!(
!cp.starts_with(sd),
"copilot token path {} must not be inside the bind-mounted state dir {}",
cp.display(),
sd.display(),
);
assert_eq!(cp.parent().unwrap().parent(), sd.parent());
}
/// **Placeholder distinctness**. If one placeholder were a
/// substring of another, the secret-substitution proxy would
/// swap the wrong token on outbound bytes — silently corrupting

View file

@ -3,8 +3,8 @@
# tool itself (that's the Rust binary published as
# @wirenboard/agent-vm on npm).
#
# Contents: Debian 13 slim + the three AI coding agents (Claude
# Code, OpenCode, Codex), minimum dev tooling, and the docker engine
# Contents: Debian 13 slim + the four AI coding agents (Claude
# Code, OpenCode, Codex, GitHub Copilot), minimum dev tooling, and the docker engine
# (docker.io + containerd + runc + fuse-overlayfs). Other heavy
# extras (LSPs, additional MCP servers, docker-buildx) are
# deliberately deferred.
@ -17,7 +17,7 @@
FROM debian:13-slim
LABEL org.opencontainers.image.title="agent-vm guest template"
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + GitHub Copilot + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`."
ENV DEBIAN_FRONTEND=noninteractive \
HOME=/root \
@ -241,8 +241,15 @@ RUN curl -fsSL https://opencode.ai/install | bash \
# Codex CLI official installer.
RUN curl -fsSL https://github.com/openai/codex/releases/latest/download/install.sh | sh
# D1: GitHub Copilot CLI. Its own RUN layer so the AGENT_INSTALL_CACHEBUST
# arg above invalidates it on the hourly build like the other agents, and
# so a wrong package/binary name fails the image build loudly (via the
# `copilot --version` smoke check) instead of surfacing as a
# command-not-found inside the guest at `agent-vm copilot` time.
RUN npm install -g @github/copilot && copilot --version
# Sanity check at build time so a broken installer surfaces before we push.
RUN claude --version && opencode --version && codex --version \
RUN claude --version && opencode --version && codex --version && copilot --version \
&& dockerd --version && docker --version && containerd --version && runc --version
# Smoke entrypoint; the launcher overrides this in Phase 2.