Phase 4: file-backed secrets + OAuth refresh interception

In-VM agent's OAuth refresh attempts now trigger a host-side rotation
via a microsandbox TLS-intercept request-interceptor hook. The hook
spawns the host claude/codex CLI to do the real refresh, then
synthesizes a placeholder response. Combined with switching from
SecretValue::Static to SecretValue::File (which the patched msb
re-reads on every connection), long sessions survive token rotation
without manual intervention.

Pieces:

- vendor/microsandbox submodule pointer bumped to include the
  interceptor extension (commit cf00a11 on agent-vm-secret-file).
- msb_install.rs: cargo build --release -p microsandbox-cli --bin msb
  from vendor/, point MSB_PATH at the result so the patched binary is
  what runs the VM. The user's ~/.microsandbox/bin/msb is untouched.
- intercept_hook.rs: hidden `agent-vm _intercept-hook` subcommand.
  Reads the OAuth refresh request from stdin, spawns the host CLI to
  rotate the host credential file the normal way, re-reads the host
  file, rewrites <state>/tokens/<provider> so the next non-refresh
  request gets the new bearer via SecretValue::File, synthesizes an
  OAuth response with placeholder tokens, writes to stdout.
- secrets.rs: switched from Static(token) to File(<state>/tokens/…).
- run.rs: registers the interceptor with two rules
  (platform.claude.com/v1/oauth/token and auth.openai.com/oauth/token).
- ARCHITECTURE.md gets a Phase 4 section covering the two-layer
  refresh flow, the msb/cli build-target gotcha (~30 min debugging
  lesson: vendor/microsandbox/target/release/microsandbox is a
  5-line shim, not the real binary), and the deliberate
  non-features (no proactive expiry timer, no global msb install,
  no concurrent-refresh single-flight).

Smoke verified end-to-end on the nested-VM test host: in-VM
`curl POST https://platform.claude.com/v1/oauth/token` returns
HTTP 200 with a fresh OAuth JSON response containing the
placeholder tokens, expires_in derived from the just-rotated host
expiresAt. The host claude -p ran and updated the host file as a
side effect.
This commit is contained in:
Evgeny Boger 2026-05-17 23:38:40 +03:00
parent 273b0261b8
commit 8e262c03fb
10 changed files with 654 additions and 61 deletions

View file

@ -435,3 +435,162 @@ credential-proxy works.
`platform.claude.com/v1/oauth/token` and `auth.openai.com/oauth/token`
endpoints and forge responses from re-reads of the host file. That's
Phase 4.
## Phase 4 — OAuth refresh: file-backed secrets + interceptor hook
Phase 3 left a 401-then-die failure mode: when the captured access
token expires mid-session the in-VM agent gets 401 from the API, tries
to refresh against the OAuth endpoint with the placeholder refresh
token, and gets 401 again. The user has to exit and re-launch.
Phase 4 closes the loop end-to-end. Two upstream microsandbox
extensions plus an agent-vm subprocess handle it.
### Pieces
```
vendor/microsandbox/ (branch: agent-vm-secret-file)
└── crates/network/lib/
├── secrets/config.rs # SecretValue::File (from Phase 3) is now actually used
└── intercept/ # new: per-route request-interceptor hook
├── config.rs # InterceptConfig (rules + hook command)
└── handler.rs # per-connection state machine
crates/agent-vm/src/
├── msb_install.rs # new: build patched msb from vendor; point MSB_PATH at it
├── intercept_hook.rs # new: `agent-vm _intercept-hook` subprocess
├── secrets.rs # switched from Static(token) to File(<state>/tokens/{anthropic,openai})
└── run.rs # registers the interceptor with two rules
```
### Patched `msb` shipped via `MSB_PATH`
`agent-vm setup` now runs
`cargo build --release -p microsandbox-cli --bin msb` in
`vendor/microsandbox` and leaves the artifact at
`vendor/microsandbox/target/release/msb`. At startup, every agent-vm
invocation sets `MSB_PATH` to that path (top of microsandbox's
resolution ladder), so the patched binary is what actually runs the
VM. The user's `~/.microsandbox/bin/msb` is never touched and
upstream-installed tooling on the same host keeps using its own
prebuilt.
The real msb binary lives in the `microsandbox-cli` crate; the
`microsandbox` crate has a separate `microsandbox` binary that's
just a 5-line shim forwarding to `~/.microsandbox/bin/msb`. Building
the wrong target produces a 389 KB shim that boots silently then
hangs at VM init — about 30 minutes of debugging into a
no-VMM-symbols-in-the-binary surprise. Recorded here so the next
person doesn't redo it.
### `SecretValue::File`
Phase 3's per-launch snapshot becomes a per-launch *file write*. We
write the host's `accessToken` to `<state>/tokens/anthropic` (and
`.../openai`) with 0600 perms via atomic-write-then-rename. The
launcher passes the file path to microsandbox as a `SecretValue::File`.
The patched msb's TLS-intercept proxy calls `SecretValue::resolve()`
at *connection-setup* time — every new TCP connection re-reads the
file. So any host-side rotation (whether triggered by the user's
external `claude` use or by our interceptor hook below) is visible
to the very next request, without rebuilding the sandbox.
### Request-interceptor hook (the OAuth refresh MITM)
`microsandbox-network` gained an `InterceptConfig` that the launcher
fills with:
```rust
.intercept(|i| i
.hook(["…/agent-vm", "_intercept-hook", "--state-dir", "…"])
.rule("platform.claude.com", "POST", "/v1/oauth/token")
.rule("auth.openai.com", "POST", "/oauth/token"))
```
When the in-VM agent posts an OAuth refresh request, the proxy:
1. Buffers the full request (it's tiny — <1 KB so this is cheap
and capped at 64 KiB).
2. Spawns the hook command with the request bytes on stdin and four
env vars (`MSB_INTERCEPT_SNI/_HOST_RULE/_METHOD/_PATH_PREFIX`).
3. Reads the hook's stdout as the response and writes it back to the
guest, encrypted under the forged TLS cert.
4. Closes the connection without ever touching the upstream server.
The hook (`agent-vm _intercept-hook`) is the same binary in a hidden
clap subcommand mode:
1. Reads the request from stdin (sanity-checks it's `POST …`).
2. Spawns `claude -p hi --model sonnet` (or `codex exec --skip-git-
repo-check 'Reply OK'`) on the **host** so the host CLI rotates
`~/.claude/.credentials.json` / `~/.codex/auth.json` the normal way.
3. Re-reads the rotated host file, rewrites
`<state>/tokens/{anthropic,openai}` so the next non-refresh
request from the guest gets the new bearer via `SecretValue::File`.
4. Synthesizes an OAuth refresh-response JSON shaped like what the
upstream server would return, but with **placeholder** strings in
the `access_token` / `refresh_token` fields. The in-VM agent
updates its credentials.json to those placeholders and continues.
5. Writes the response to stdout, exits 0.
The guest never holds a real token at any layer:
- `~/.claude/.credentials.json` always contains placeholders (Phase 3).
- The proxy substitutes real-for-placeholder on the way *out* (Phase 3).
- The OAuth refresh response also returns placeholders (Phase 4).
- The host CLI on the host is the only thing that ever touches real
OAuth machinery, and it writes to a file we re-read.
### Hook-process boundary, not callback
The interceptor uses a subprocess (fork+exec per request) rather than
a callback into the SDK. Reasons:
- `Vec<Box<dyn RequestInterceptor>>` isn't serializable. The
network config is JSON-piped from the SDK to a separate `msb`
process, so anything we configure on the SDK side has to round-trip
through JSON.
- Refresh requests are rare (once per hour at worst). Fork-per-request
overhead is irrelevant against the latency of the host `claude`
invocation that the hook does anyway.
- A subprocess can dispatch on any logic without us having to
re-extend microsandbox each time we add a provider.
### Smoke verification
```
Inside the guest:
POST https://platform.claude.com/v1/oauth/token
body: {"grant_type":"refresh_token","refresh_token":"…PLACEHOLDER_REFRESH…"}
Response:
HTTP 200 application/json
{"access_token":"MSB_PLACEHOLDER_ANTHROPIC_ACCESS_TOKEN_v1",
"refresh_token":"MSB_PLACEHOLDER_ANTHROPIC_REFRESH_TOKEN_v1",
"expires_in":3499, "token_type":"Bearer",
"scope":["user:file_upload","user:inference",…]}
```
Confirmed on the same nested-VM test host as Phase 3. The hook ran,
host `claude -p` rotated the host file, the new bearer landed in
`<state>/tokens/anthropic`, and the synthesized response reached the
guest. `expires_in: 3499` is the freshly-derived seconds-until-expiry
of the just-rotated token.
### What Phase 4 deliberately doesn't do
- **No proactive expiry timer.** Discussed and rejected: the
guest's own refresh attempt at 401-time triggers our hook, which
triggers the host-side refresh. If the user runs `claude` on the
host between sessions, the host file is already fresh and the
`SecretValue::File` re-read picks it up with no hook involved.
A timer would be belt-and-suspenders.
- **No msb shipped via `~/.microsandbox/bin/msb`.** The MSB_PATH
override is per-agent-vm-invocation only; other microsandbox SDK
consumers on the same host keep using the upstream prebuilt.
- **No single-flight for concurrent in-guest refreshes.** Two
concurrent refresh attempts could each spawn a host `claude -p`.
The host CLI's own file lock prevents corruption, so the worst
outcome is one extra `claude -p` invocation. If this becomes a
pain point, a `<state>/tokens/.refresh.lock` flock around the host
CLI invocation is two lines.

View file

@ -191,7 +191,7 @@ verified on the nested test host because of an outer credential bridge
that itself substitutes placeholders. Structurally equivalent to the
original Bash agent-vm's credential-proxy flow.
### Phase 4 — Refresh semantics [pending]
### Phase 4 — Refresh semantics [done — submodule branch `agent-vm-secret-file`, agent-vm commit pending]
Tokens rotate; long-running sandbox sessions must survive that without
re-attaching. Phase 3 makes the access token swappable in principle;

View file

@ -28,8 +28,9 @@ until v1 of the rewrite ships.
Real Claude/Codex tokens stay on the host; the in-VM agent only sees
placeholders. See ARCHITECTURE.md "Phase 3" for the two-layer
placeholder dance.
- Phase 4 (token-refresh semantics + `msb` rebuild for
`SecretValue::File`): **next**.
- Phase 4 (token-refresh semantics: rebuilt `msb`, file-backed
secrets, OAuth-endpoint interception): done. Long sessions survive
host token rotation without manual intervention.
- Phase 5 (fast-launch via detached mode): deferred — see PLAN.md;
snapshots don't help launch time, detached mode is a product-shape
decision.

View file

@ -0,0 +1,264 @@
//! `agent-vm _intercept-hook` — the subprocess microsandbox calls
//! when an in-VM OAuth refresh attempt matches an intercept rule.
//!
//! Lifecycle for one matched request:
//!
//! 1. msb forks this process, pipes the decrypted HTTP request bytes
//! on stdin, sets `MSB_INTERCEPT_SNI` and related env vars.
//! 2. We figure out which provider the request is for (from the SNI),
//! spawn the corresponding host CLI (`claude -p hi --model sonnet`
//! or `codex exec --skip-git-repo-check 'Reply OK'`) so the
//! host-side credential file gets rotated.
//! 3. We re-read the rotated host credential file and rewrite the
//! per-project token file the proxy reads (so the next non-refresh
//! request from the in-VM agent picks up the new bearer).
//! 4. We synthesize an OAuth refresh response — same shape the
//! upstream server would return, but the body's `access_token`
//! field is the *placeholder*. The in-VM agent updates its local
//! credentials.json to that placeholder, and the next request goes
//! through with the placeholder, which the proxy substitutes for
//! the now-fresh real token.
//! 5. We write the response on stdout and exit 0.
//!
//! The whole point: the in-VM agent thinks it refreshed normally and
//! got a new bearer; in reality the host CLI did the refresh and we
//! lied about which token to use. The placeholder/real swap is what
//! keeps real tokens out of the VM.
use std::{
io::{Read, Write},
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, bail};
use clap::Args as ClapArgs;
use serde_json::{Value, json};
#[derive(ClapArgs)]
pub struct Args {
/// Per-project state directory (same one used by the launcher).
/// We need it to know where to read host credentials and where
/// to write the freshly-rotated token file.
#[arg(long)]
state_dir: PathBuf,
}
pub fn run(args: Args) -> Result<()> {
let sni = std::env::var("MSB_INTERCEPT_SNI")
.context("MSB_INTERCEPT_SNI not set; this command is invoked by msb's interceptor")?;
let mut request = Vec::new();
std::io::stdin()
.read_to_end(&mut request)
.context("reading request from stdin")?;
// Sanity check that this is what we expect.
if !looks_like_oauth_refresh(&request) {
// Forward an opaque server error so the in-VM agent at least
// gets a comprehensible failure rather than a hang. We don't
// try to proxy the real request — by the time msb spawned us,
// it already committed to not connecting upstream.
write_response(&error_response(
500,
"request did not look like an OAuth refresh; agent-vm hook punted",
))?;
return Ok(());
}
let response = match sni.as_str() {
"platform.claude.com" => refresh_anthropic(&args.state_dir)?,
"auth.openai.com" => refresh_openai(&args.state_dir)?,
other => error_response(
500,
&format!("agent-vm hook has no logic for SNI {other}"),
),
};
write_response(&response)?;
Ok(())
}
fn write_response(bytes: &[u8]) -> Result<()> {
let mut out = std::io::stdout().lock();
out.write_all(bytes).context("writing response to stdout")?;
out.flush().ok();
Ok(())
}
/// Trigger host-side Claude refresh, re-read the host file, rewrite
/// the per-project token file, and synthesize the OAuth refresh JSON
/// response that the in-VM Claude expects.
fn refresh_anthropic(state_dir: &Path) -> Result<Vec<u8>> {
// Trigger refresh on the host.
trigger_host_refresh("claude", &["-p", "hi", "--model", "sonnet"])?;
// Re-read the (now-rotated) host file.
let host_path = host_claude_creds_path()?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
let oauth = json
.get("claudeAiOauth")
.context("rotated host .credentials.json missing claudeAiOauth")?;
let new_access = oauth
.get("accessToken")
.and_then(|v| v.as_str())
.context("rotated host claudeAiOauth missing accessToken")?;
let expires_at = oauth.get("expiresAt").cloned().unwrap_or(json!(0));
// Update the per-project token file so the proxy's next
// SecretValue::File read picks up the new bearer.
let token_file = crate::secrets::anthropic_token_path(state_dir);
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
atomic_write(&token_file, new_access.as_bytes(), 0o600)?;
// Synthesize the OAuth refresh response. Claude's refresh endpoint
// returns a body like:
// {"access_token":"...", "refresh_token":"...", "expires_in":...}
// The in-VM Claude writes that into its credentials.json. We put
// placeholders in both token fields so the next request goes
// through the substitution path.
let expires_in = derive_expires_in(&expires_at);
let body = json!({
"access_token": crate::secrets::ANTHROPIC_PLACEHOLDER,
"refresh_token": "MSB_PLACEHOLDER_ANTHROPIC_REFRESH_TOKEN_v1",
"expires_in": expires_in,
"token_type": "Bearer",
"scope": oauth.get("scopes").cloned().unwrap_or(json!([])),
});
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
fn refresh_openai(state_dir: &Path) -> Result<Vec<u8>> {
trigger_host_refresh(
"codex",
&[
"exec",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"Reply with OK",
],
)?;
let host_path = host_codex_auth_path()?;
let raw = std::fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
let new_access = json
.pointer("/tokens/access_token")
.and_then(|v| v.as_str())
.or_else(|| json.get("OPENAI_API_KEY").and_then(|v| v.as_str()))
.context("rotated host codex auth missing tokens.access_token or OPENAI_API_KEY")?;
let token_file = crate::secrets::openai_token_path(state_dir);
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
atomic_write(&token_file, new_access.as_bytes(), 0o600)?;
// OpenAI's refresh response shape varies; this is a minimal one
// good enough for codex to write its auth.json.
let body = json!({
"access_token": crate::secrets::OPENAI_PLACEHOLDER,
"refresh_token": "MSB_PLACEHOLDER_OPENAI_REFRESH_TOKEN_v1",
"id_token": "MSB_PLACEHOLDER_OPENAI_ID_TOKEN_v1",
"expires_in": 3600,
"token_type": "Bearer",
});
Ok(http_200_json(&serde_json::to_vec(&body)?))
}
fn trigger_host_refresh(cmd: &str, args: &[&str]) -> Result<()> {
let out = Command::new(cmd)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.with_context(|| format!("spawning host {cmd}"))?;
if !out.status.success() {
bail!(
"host {cmd} failed (status {}): {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
fn host_claude_creds_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME").context("HOME not set")?;
Ok(PathBuf::from(home).join(".claude/.credentials.json"))
}
fn host_codex_auth_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME").context("HOME not set")?;
Ok(PathBuf::from(home).join(".codex/auth.json"))
}
fn looks_like_oauth_refresh(req: &[u8]) -> bool {
let s = match std::str::from_utf8(req) {
Ok(s) => s,
Err(_) => return false,
};
let first_line = s.lines().next().unwrap_or("");
first_line.starts_with("POST ")
}
fn derive_expires_in(expires_at_field: &Value) -> i64 {
// claudeAiOauth.expiresAt is ms-since-epoch. We need seconds-until-expiry.
let expires_at_ms = expires_at_field.as_i64().unwrap_or(0);
if expires_at_ms == 0 {
return 3600;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let diff_secs = (expires_at_ms - now_ms) / 1000;
if diff_secs <= 0 { 3600 } else { diff_secs }
}
fn http_200_json(body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(body.len() + 128);
out.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
out.extend_from_slice(b"Content-Type: application/json\r\n");
out.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
out.extend_from_slice(b"Connection: close\r\n");
out.extend_from_slice(b"\r\n");
out.extend_from_slice(body);
out
}
fn error_response(code: u16, msg: &str) -> Vec<u8> {
let body = format!("{{\"error\":{}}}", json!(msg));
let mut out = Vec::with_capacity(body.len() + 128);
out.extend_from_slice(format!("HTTP/1.1 {code} Server Error\r\n").as_bytes());
out.extend_from_slice(b"Content-Type: application/json\r\n");
out.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
out.extend_from_slice(b"Connection: close\r\n");
out.extend_from_slice(b"\r\n");
out.extend_from_slice(body.as_bytes());
out
}
fn atomic_write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let tmp = path.with_extension("agent-vm-hook-tmp");
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(mode)
.open(&tmp)?;
f.write_all(data)?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}

View file

@ -1,6 +1,8 @@
//! agent-vm — sandboxed microVMs for AI coding agents on microsandbox.
mod image_check;
mod intercept_hook;
mod msb_install;
mod pull;
mod pull_progress;
mod pulled_marker;
@ -38,11 +40,22 @@ enum Cmd {
/// Open a bash shell in a sandbox mounted at the project's host path.
Shell(run::Args),
/// Internal: invoked by msb's interceptor hook for matched OAuth
/// refresh requests. Reads the request on stdin, writes an
/// HTTP response on stdout. Not meant for direct use.
#[command(name = "_intercept-hook", hide = true)]
InterceptHook(intercept_hook::Args),
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
// Phase 4: prefer our locally-built msb that knows about
// SecretValue::File and the request-interceptor hook. No-op if
// the binary hasn't been built yet (run `agent-vm setup` to
// build it).
msb_install::point_at_workspace_msb();
let cli = Cli::parse();
match cli.cmd {
Cmd::Setup(args) => setup::run(args).await,
@ -51,6 +64,7 @@ async fn main() -> Result<()> {
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::Shell(args) => exit_with(run::launch(run::Agent::Shell, args).await?),
Cmd::InterceptHook(args) => intercept_hook::run(args),
}
}

View file

@ -0,0 +1,130 @@
//! Build and use a patched `msb` binary from `vendor/microsandbox`.
//!
//! Phase 4 turns on microsandbox features (`SecretValue::File`, the
//! request-interceptor hook) that don't exist in the upstream prebuilt
//! `~/.microsandbox/bin/msb`. We compile our own from the submodule
//! and point microsandbox's SDK at it via the `MSB_PATH` env var (top
//! of `microsandbox::config::resolve_msb_path`'s precedence ladder),
//! so the user's regular `~/.microsandbox/bin/msb` stays untouched
//! for any other tooling.
//!
//! The build is done by `agent-vm setup`; every other subcommand just
//! checks the binary exists and sets `MSB_PATH` if so.
use std::{path::PathBuf, process::Command};
use anyhow::{Context, Result, bail};
/// Path where we expect our locally-built msb to live. The real CLI
/// binary lives in the `microsandbox-cli` crate (not `microsandbox` —
/// that crate's `microsandbox` bin is just a shim that forwards to
/// `~/.microsandbox/bin/msb`).
pub fn workspace_built_msb() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../vendor/microsandbox/target/release/msb")
}
/// If we have a built msb in the workspace, point microsandbox at it.
/// Quiet no-op otherwise (falls through to the SDK's normal resolution
/// chain — workspace_local, then `~/.microsandbox/bin/msb`, then
/// `$PATH`).
///
/// Safe to call multiple times. Does not override an explicit
/// user-set `MSB_PATH`.
pub fn point_at_workspace_msb() {
if std::env::var_os("MSB_PATH").is_some() {
return;
}
let p = workspace_built_msb();
if !p.exists() {
return;
}
// SAFETY: Called from main before any other thread is spawned.
unsafe { std::env::set_var("MSB_PATH", p) };
}
/// Build the microsandbox CLI binary from the vendored submodule and
/// leave it at `workspace_built_msb()`. Called by `agent-vm setup`.
///
/// Skips if the built binary is already newer than the network crate's
/// source mtime — a heuristic but enough to avoid recompiling every
/// `setup` for unchanged source.
pub fn build_or_skip() -> Result<()> {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../vendor/microsandbox/Cargo.toml")
.canonicalize()
.context("vendor/microsandbox not present; run `git submodule update --init vendor/microsandbox`")?;
if msb_is_fresh(&manifest).unwrap_or(false) {
println!("==> Patched msb already built; skipping (delete vendor/microsandbox/target/release/microsandbox to force rebuild)");
return Ok(());
}
println!("==> Building patched msb from vendor/microsandbox (one-time, ~3-4 min)");
let status = Command::new("cargo")
.args(["build", "--release", "-p", "microsandbox-cli", "--bin", "msb"])
.arg("--manifest-path")
.arg(&manifest)
.status()
.context("invoking cargo build for vendor/microsandbox")?;
if !status.success() {
bail!("cargo build microsandbox failed: {status}");
}
let built = workspace_built_msb();
if !built.exists() {
bail!("cargo build succeeded but {} not found", built.display());
}
println!("==> Patched msb at {}", built.display());
Ok(())
}
/// Heuristic freshness check: built msb exists and its mtime is newer
/// than the latest mtime under crates/network. Cheap, good enough for
/// "don't recompile if nothing changed since last `agent-vm setup`."
fn msb_is_fresh(microsandbox_manifest: &std::path::Path) -> Result<bool> {
let built = workspace_built_msb();
if !built.exists() {
return Ok(false);
}
let built_mtime = std::fs::metadata(&built)?.modified()?;
let network_dir = microsandbox_manifest
.parent()
.context("manifest has no parent")?
.join("crates/network/lib");
let latest_src = walk_latest_mtime(&network_dir)?;
Ok(built_mtime >= latest_src)
}
fn walk_latest_mtime(root: &std::path::Path) -> Result<std::time::SystemTime> {
let mut latest = std::time::SystemTime::UNIX_EPOCH;
for entry in walkdir(root)? {
let meta = std::fs::metadata(&entry)?;
if meta.is_file()
&& let Ok(m) = meta.modified()
{
if m > latest {
latest = m;
}
}
}
Ok(latest)
}
fn walkdir(root: &std::path::Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)
.with_context(|| format!("reading {}", dir.display()))?
{
let entry = entry?;
let p = entry.path();
if p.is_dir() {
stack.push(p);
} else {
out.push(p);
}
}
}
Ok(out)
}

View file

@ -185,30 +185,49 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// each provider — we don't intercept the refresh flow yet (Phase 4),
// but we need to allow the request through to avoid the secret
// violation detector blocking attempts at refresh.
if creds.anthropic_access_token.is_some() || creds.openai_access_token.is_some() {
let anthropic = creds.anthropic_access_token.clone();
let openai = creds.openai_access_token.clone();
if creds.anthropic_token_file.is_some() || creds.openai_token_file.is_some() {
let anthropic = creds.anthropic_token_file.clone();
let openai = creds.openai_token_file.clone();
// Hook command: this very binary in "_intercept-hook" mode.
// microsandbox spawns it per matched request with the request
// bytes on stdin and reads the response from stdout.
let self_path = std::env::current_exe().context("std::env::current_exe")?;
let state_dir = session.state_dir.clone();
builder = builder.network(move |mut n| {
n = n.tls(|t| t);
if let Some(tok) = anthropic {
if let Some(file) = anthropic {
n = n.secret(|s| {
s.env("MSB_AGENT_VM_ANTHROPIC_UNUSED")
.value(tok)
.value(file)
.placeholder(crate::secrets::ANTHROPIC_PLACEHOLDER)
.allow_host("api.anthropic.com")
.allow_host("platform.claude.com")
});
}
if let Some(tok) = openai {
if let Some(file) = openai {
n = n.secret(|s| {
s.env("MSB_AGENT_VM_OPENAI_UNUSED")
.value(tok)
.value(file)
.placeholder(crate::secrets::OPENAI_PLACEHOLDER)
.allow_host("api.openai.com")
.allow_host("chatgpt.com")
.allow_host("auth.openai.com")
});
}
// Interceptor hook: spawn `agent-vm _intercept-hook
// --state-dir <dir>` and let it answer OAuth refresh
// requests. See crate::intercept_hook for the response
// synthesis logic.
n = n.intercept(|i| {
i.hook([
self_path.to_string_lossy().to_string(),
"_intercept-hook".to_string(),
"--state-dir".to_string(),
state_dir.to_string_lossy().to_string(),
])
.rule("platform.claude.com", "POST", "/v1/oauth/token")
.rule("auth.openai.com", "POST", "/oauth/token")
});
n
});
}

View file

@ -2,23 +2,22 @@
//!
//! For each invocation we snapshot the host's Claude / Codex credential
//! files, write placeholder credentials into the guest-side state
//! directory, and return the real access tokens to the launcher. The
//! launcher registers them as microsandbox secret entries so the real
//! tokens are substituted into outgoing TLS-intercepted requests at the
//! network layer — the guest never sees them.
//! directory, and return the *path* of a per-project token file to the
//! launcher. The launcher registers that path as a microsandbox
//! `SecretValue::File` entry so the patched msb re-reads it on every
//! connection-setup — host-side rotation is picked up on the next
//! request without rebuilding the sandbox.
//!
//! Phase 3 captures the token **once at launch time** as a static
//! string. Long-running sandboxes will lose auth when the host token
//! expires; the access-token-rotation work lives in Phase 4 along with
//! the cross-process plumbing needed for live file-backed secrets
//! (SecretValue::File exists on our microsandbox fork branch but the
//! prebuilt msb runtime daemon on the host hasn't been rebuilt to
//! understand it yet).
//! The token file is the same file the Phase 4 interceptor hook
//! rewrites whenever the in-VM agent asks for an OAuth refresh.
//!
//! Placeholders are stable per-version so credentials JSON written by a
//! prior invocation is still valid for the current one.
//! Placeholders are stable per-version so credentials JSON written by
//! a prior invocation is still valid for the current one.
use std::{fs, path::Path, path::PathBuf};
use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde_json::Value;
@ -26,44 +25,37 @@ use serde_json::Value;
pub const ANTHROPIC_PLACEHOLDER: &str = "MSB_PLACEHOLDER_ANTHROPIC_ACCESS_TOKEN_v1";
pub const OPENAI_PLACEHOLDER: &str = "MSB_PLACEHOLDER_OPENAI_ACCESS_TOKEN_v1";
/// Result of [`refresh`]. `*_access_token` strings are only present if the
/// host credential file was found and parsed successfully. They are
/// short-lived — handed to the network builder and dropped once the
/// sandbox config is materialized; we never log them.
#[derive(Default)]
/// Result of [`refresh`]. `*_token_file` paths only exist if the host
/// credential file was found and parsed successfully.
#[derive(Debug, Default, Clone)]
pub struct CredsState {
pub anthropic_access_token: Option<String>,
pub openai_access_token: Option<String>,
pub anthropic_token_file: Option<PathBuf>,
pub openai_token_file: Option<PathBuf>,
}
impl std::fmt::Debug for CredsState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredsState")
.field(
"anthropic_access_token",
&self.anthropic_access_token.as_ref().map(|_| "[REDACTED]"),
)
.field(
"openai_access_token",
&self.openai_access_token.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
/// Per-project location of the token file the proxy re-reads.
pub fn anthropic_token_path(state_dir: &Path) -> PathBuf {
state_dir.join("tokens/anthropic")
}
/// Read host credentials and write placeholder guest credentials. Returns
/// access tokens that the launcher passes to microsandbox as static
/// secret values. Silently skips providers whose host file is missing or
/// unparseable so a host with only Claude credentials still works.
pub fn openai_token_path(state_dir: &Path) -> PathBuf {
state_dir.join("tokens/openai")
}
/// Read host credentials, write the token file (atomically, 0600) and
/// the guest-side placeholder credentials.json. Returns the paths to
/// the written token files so the launcher can plumb them into
/// microsandbox's SecretValue::File config.
pub fn refresh(state_dir: &Path) -> Result<CredsState> {
let anthropic_access_token = match refresh_anthropic(state_dir) {
fs::create_dir_all(state_dir.join("tokens"))?;
let anthropic_token_file = match refresh_anthropic(state_dir) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "anthropic credential refresh failed; skipping");
None
}
};
let openai_access_token = match refresh_openai(state_dir) {
let openai_token_file = match refresh_openai(state_dir) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "openai credential refresh failed; skipping");
@ -72,12 +64,12 @@ pub fn refresh(state_dir: &Path) -> Result<CredsState> {
};
Ok(CredsState {
anthropic_access_token,
openai_access_token,
anthropic_token_file,
openai_token_file,
})
}
fn refresh_anthropic(state_dir: &Path) -> Result<Option<String>> {
fn refresh_anthropic(state_dir: &Path) -> Result<Option<PathBuf>> {
let Some(host_path) = host_claude_creds_path() else {
return Ok(None);
};
@ -95,8 +87,11 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Option<String>> {
let access_token = oauth
.get("accessToken")
.and_then(|v| v.as_str())
.context("host claudeAiOauth missing accessToken")?
.to_string();
.context("host claudeAiOauth missing accessToken")?;
// Token file the proxy re-reads on every connection-setup.
let token_file = anthropic_token_path(state_dir);
atomic_write(&token_file, access_token.as_bytes(), 0o600)?;
// Placeholder credentials.json the in-guest Claude reads. Goes
// into <state>/claude which is symlinked from /root/.claude in
@ -106,7 +101,7 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Option<String>> {
let placeholder = serde_json::json!({
"claudeAiOauth": {
"accessToken": ANTHROPIC_PLACEHOLDER,
"refreshToken": "REFRESH_NOT_AVAILABLE_PHASE_3",
"refreshToken": "MSB_PLACEHOLDER_ANTHROPIC_REFRESH_TOKEN_v1",
"expiresAt": oauth.get("expiresAt"),
"scopes": oauth.get("scopes"),
"subscriptionType": oauth.get("subscriptionType"),
@ -116,10 +111,10 @@ fn refresh_anthropic(state_dir: &Path) -> Result<Option<String>> {
let body = serde_json::to_string_pretty(&placeholder)?;
atomic_write(&claude_dir.join(".credentials.json"), body.as_bytes(), 0o600)?;
Ok(Some(access_token))
Ok(Some(token_file))
}
fn refresh_openai(state_dir: &Path) -> Result<Option<String>> {
fn refresh_openai(state_dir: &Path) -> Result<Option<PathBuf>> {
let Some(host_path) = host_codex_auth_path() else {
return Ok(None);
};
@ -139,6 +134,8 @@ fn refresh_openai(state_dir: &Path) -> Result<Option<String>> {
.or_else(|| json.get("OPENAI_API_KEY").and_then(|v| v.as_str()))
.context("host codex auth missing tokens.access_token or OPENAI_API_KEY")?
.to_string();
let token_file = openai_token_path(state_dir);
atomic_write(&token_file, access_token.as_bytes(), 0o600)?;
// Replace the real value in-place with the placeholder, preserving
// every other field (account_id, last_refresh, etc.) so the in-VM
@ -153,7 +150,7 @@ fn refresh_openai(state_dir: &Path) -> Result<Option<String>> {
if tokens.contains_key("refresh_token") {
tokens.insert(
"refresh_token".into(),
Value::String("REFRESH_NOT_AVAILABLE_PHASE_3".to_string()),
Value::String("MSB_PLACEHOLDER_OPENAI_REFRESH_TOKEN_v1".to_string()),
);
}
}
@ -166,7 +163,7 @@ fn refresh_openai(state_dir: &Path) -> Result<Option<String>> {
fs::create_dir_all(&codex_dir)?;
atomic_write(&codex_dir.join("auth.json"), body.as_bytes(), 0o600)?;
Ok(Some(access_token))
Ok(Some(token_file))
}
fn host_claude_creds_path() -> Option<PathBuf> {

View file

@ -33,6 +33,15 @@ pub async fn run(args: Args) -> Result<()> {
run_build_script()?;
// Phase 4: rebuild the microsandbox CLI binary from the vendored
// submodule. The upstream prebuilt at ~/.microsandbox/bin/msb is
// missing the SecretValue::File + request-interceptor support
// that the launcher needs. Result lives in vendor/microsandbox/
// target/release/microsandbox; subsequent agent-vm invocations
// pick it up via MSB_PATH (set in main.rs).
crate::msb_install::build_or_skip()?;
crate::msb_install::point_at_workspace_msb();
// We just pushed a new manifest under the same tag; explicitly pull
// it into microsandbox's cache so subsequent launches (which use
// PullPolicy::IfMissing) see the latest layers without having to

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit b95614c67b0d10640dcc20facebf188793ec0eaa
Subproject commit cf00a11d8f0cd322f6986ba71e7ce9a9cb2f2eb0