mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
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:
parent
273b0261b8
commit
8e262c03fb
10 changed files with 654 additions and 61 deletions
159
ARCHITECTURE.md
159
ARCHITECTURE.md
|
|
@ -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.
|
||||
|
|
|
|||
2
PLAN.md
2
PLAN.md
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
264
crates/agent-vm/src/intercept_hook.rs
Normal file
264
crates/agent-vm/src/intercept_hook.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
130
crates/agent-vm/src/msb_install.rs
Normal file
130
crates/agent-vm/src/msb_install.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
2
vendor/microsandbox
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit b95614c67b0d10640dcc20facebf188793ec0eaa
|
||||
Subproject commit cf00a11d8f0cd322f6986ba71e7ce9a9cb2f2eb0
|
||||
Loading…
Add table
Add a link
Reference in a new issue