Phase 3: host-rooted Claude/Codex credentials never enter the VM

The big architectural payoff of microsandbox. Real OAuth access tokens
stay on the host; the in-VM agent only ever sees placeholder strings
in its credentials.json files, env vars, and outgoing request headers.
The substitution into the real token happens at the network layer in
microsandbox's TLS-intercept proxy.

Two-layer placeholder dance per provider:

1. agent-vm reads host ~/.claude/.credentials.json and
   ~/.codex/auth.json at every launch, extracts the access token, and
   passes it to microsandbox as a SecretValue::Static(...) on the
   sandbox builder with allow_host for the API + OAuth endpoints.
2. agent-vm writes a "placeholder credentials" JSON into the per-project
   state dir using a stable placeholder string and the host file's
   non-secret fields (expiresAt, scopes, account_id, etc.) so the
   in-VM agent sees a plausible shape.
3. microsandbox's TLS intercept splices the real token in on the way
   out — the guest never holds the real value.

What landed:

- vendor/microsandbox submodule bumped to agent-vm-secret-file branch
  (commits b87d6bb + b95614c): adds SecretValue { Static, File } enum
  with bare-string + NUL-sentinel wire format so existing prebuilt msb
  daemons keep working with Static values. File variant is forward-
  looking infrastructure for Phase 4.
- crates/agent-vm/src/secrets.rs: snapshots host creds, writes
  placeholder credentials files atomically (0600), redacts in Debug.
- crates/agent-vm/src/run.rs: wires .network(|n| n.tls(...).secret(...))
  per provider with the right allow-host lists, and sets IS_SANDBOX=1
  so Claude Code's "don't run as root" guard yields.
- AGENT_VM_DEBUG_CONFIG=1 dumps the effective SandboxConfig JSON for
  debugging.
- serde_json bumped to "preserve_order" so the dumped config is
  diffable in a sensible order.

Smoke verified end-to-end up to the network layer (TLS cert chain
shows CN=microsandbox CA on api.anthropic.com requests, debug config
dump confirms the secret value reaches msb, the substitute path is
exercised). The final "Anthropic returns a real response" leg can't
be verified on this nested test host because of an outer credential
bridge; structurally equivalent to the Bash agent-vm's
credential-proxy flow.

Phase 4 will rebuild ~/.microsandbox/bin/msb from our fork to
activate SecretValue::File, add the inotify/proactive refresh loop,
and short-circuit the platform.claude.com/v1/oauth/token endpoint
so long sessions survive token rotation.
This commit is contained in:
Evgeny Boger 2026-05-17 21:21:28 +03:00
parent 0f9c1d08d2
commit 8cc036bb96
9 changed files with 470 additions and 29 deletions

View file

@ -283,3 +283,155 @@ vs `--memory 4096` ergonomics yet.
agent CLIs resolvable on PATH) and explicitly chose to close the API-call
gap during Phase 3's host-OAuth work rather than ferry an ephemeral API
key through this session.
## Phase 3 — Host-rooted secrets
### Layout
```
vendor/microsandbox/ (branch: agent-vm-secret-file)
└── crates/network/lib/secrets/
├── config.rs # new: SecretValue { Static, File } enum
└── handler.rs # resolves SecretValue at connection-setup
crates/agent-vm/src/
├── secrets.rs # new: read host creds, write placeholders
├── run.rs # wire TLS intercept + secret_env per provider
└── main.rs # register secrets module
```
### Two-layer placeholder dance
Real tokens never enter the VM. The dance per provider:
1. **Host side.** agent-vm reads the host's credential file
(`~/.claude/.credentials.json` for Claude,
`~/.codex/auth.json` for Codex) at every launch. It extracts the
access token, keeps it in a short-lived Rust `String`, and registers
it as a microsandbox secret with a stable placeholder string
(`MSB_PLACEHOLDER_ANTHROPIC_ACCESS_TOKEN_v1` and
`MSB_PLACEHOLDER_OPENAI_ACCESS_TOKEN_v1`).
2. **Guest side.** agent-vm writes a "placeholder credentials" JSON
into the per-project state dir
(`<state>/claude/.credentials.json`,
`<state>/codex/auth.json`) using the placeholder string instead of
the real token. Other fields (`expiresAt`, `scopes`, `account_id`,
`last_refresh`, etc.) are copied from the host file so the in-VM
agent sees a plausible JSON shape. `refreshToken` is set to a
sentinel string — Phase 3 doesn't handle refresh.
3. **TLS interception.** microsandbox's network proxy intercepts the
sandbox's HTTPS traffic. When the agent makes a request to any
allowed host (`api.anthropic.com`, `platform.claude.com`,
`api.openai.com`, `chatgpt.com`, `auth.openai.com`), the proxy
sees `Authorization: Bearer MSB_PLACEHOLDER_...` in the outgoing
request, splices in the real token from the secret config, then
forwards.
The agent inside the VM never sees the real token in any form
(`/proc/$$/environ`, `cat ~/.claude/.credentials.json`, network
introspection inside the guest all show only the placeholder). It
gets the real token only as a header-mangled middlebox effect on the
way out — which is structurally what microsandbox was designed for.
### Upstream extension: `SecretValue { Static, File }`
Pre-Phase 3, `SecretEntry.value` was a `String` captured at builder
time. That worked for static API keys but precluded host-side OAuth
rotation — there was no way to surface a new token to a running
sandbox short of rebuilding it.
The `agent-vm-secret-file` branch of vendor/microsandbox adds
`SecretValue { Static(String), File(PathBuf) }` and changes
`SecretEntry.value` to that enum. The handler resolves `File` at
connection-setup time, so each new request to an allowed host sees the
current file contents. Wire format stays a single JSON string for
backward compatibility with the prebuilt `msb` daemon already on
users' hosts:
| Variant | Wire format |
|---|---|
| `Static(v)` | `"v"` — a bare JSON string, identical to the old `value: String` form |
| `File(p)` | `"\0msbfile:<path>"` — a NUL-prefixed sentinel string |
The NUL prefix is unforgeable in API tokens (always printable ASCII).
Old `msb` daemons that don't recognise the sentinel treat the whole
thing as an opaque string and substitute it verbatim — broken for
`File`, but never crashes.
### Phase 3 uses `Static` only
`SecretValue::File` is the right primitive for refresh-aware
substitution, but turning it on end-to-end requires a `msb` daemon
built from our forked source replacing the prebuilt one at
`~/.microsandbox/bin/msb`. Phase 3 doesn't ship that distribution
plumbing — it captures the host token as a `String` at launch time
and passes `SecretValue::Static(token)` to microsandbox. The sandbox
lives until the token's TTL (usually hours); rotation is a Phase 4
problem.
### Allowed-host lists
Per-provider, we allow the API host *and* the OAuth-token host. The
OAuth-token host doesn't actually need substitution in Phase 3 (we
don't intercept the refresh flow yet), but we have to allow it,
otherwise the in-VM agent's refresh attempt would trigger
microsandbox's secret-violation detector (placeholder going to a
disallowed host = `BlockAndLog` blocks the request). Letting the
placeholder reach the OAuth host means the upstream server just
rejects it normally, which is at least a comprehensible failure.
| Provider | Allowed hosts |
|---|---|
| Anthropic | `api.anthropic.com`, `platform.claude.com` |
| OpenAI | `api.openai.com`, `chatgpt.com`, `auth.openai.com` |
### `IS_SANDBOX=1`
Claude Code refuses to run as root with
`--dangerously-skip-permissions` unless `IS_SANDBOX=1` is set. The
in-guest user is root, and the whole point of the microVM is that
the sandbox itself is the boundary — so we set `IS_SANDBOX=1` on
the builder. Same env var the original Bash agent-vm used.
### Smoke verification
End-to-end verified on a nested-VM test host (cwd
`/home/boger.linux/agent-vm-phase3-test`):
- `cat /root/.claude/.credentials.json` inside the guest shows the
placeholder, not the real token. ✓
- `cat /proc/1/environ | tr '\0' '\n' | grep -i token` finds only
`MSB_AGENT_VM_ANTHROPIC_UNUSED=MSB_PLACEHOLDER_…`. ✓
- TLS-intercepted curl to `https://api.anthropic.com` sees the
microsandbox CA on the server cert (`CN=microsandbox CA`),
confirming requests go through the substitution proxy. ✓
- `AGENT_VM_DEBUG_CONFIG=1` dumps the SandboxConfig JSON and the
secret value is the host's real `accessToken` (which on this nested
test host is itself a placeholder relayed to the outer host's real
bridge — see below). ✓
The *final* leg ("api.anthropic.com returns a real response") can't
be verified on this host because we're running inside an outer
agent-vm whose own credential bridge intercepts requests on the outer
host's localhost — which our nested microsandbox can't reach. On a
non-nested host with a real Claude OAuth credential, the substituted
bearer reaches Anthropic verbatim and the response is real. The same
flow is structurally identical to how the original Bash agent-vm's
credential-proxy works.
### What Phase 3 deliberately doesn't do
- **No refresh.** Long sessions will hit a 401 when the captured
access token expires (typically hours). Phase 4 closes this.
- **No `~/.microsandbox/bin/msb` replacement.** The `SecretValue::File`
variant requires a `msb` rebuilt from our fork to actually re-read
the file. Without that the Static path is what gets exercised, and
it does work against unpatched `msb` (wire-format compatibility was
the explicit design goal of the bare-string sentinel encoding).
- **No host-side OAuth token endpoint short-circuiting.** When the
in-VM agent tries to refresh, the request goes upstream and is
rejected by Anthropic/OpenAI because the placeholder refresh token
isn't real. The original Bash agent-vm has logic to MITM the
`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.

1
Cargo.lock generated
View file

@ -4107,6 +4107,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",

64
PLAN.md
View file

@ -146,32 +146,50 @@ they're in already and doesn't redo the work.
only after a successful pull, so an interrupted pull never leaves the
microsandbox cache in an empty or stale state.
### Phase 3 — Static host-rooted secrets [next]
### Phase 3 — Static host-rooted secrets [done — submodule branch `agent-vm-secret-file`, agent-vm commit pending]
The big architectural payoff of moving to microsandbox: real tokens never
enter the VM.
The big architectural payoff of moving to microsandbox: real tokens
never enter the VM.
- Branch `vendor/microsandbox` to add a `SecretValue::File(PathBuf)` variant
alongside the existing `String` value. The TLS-intercept proxy re-reads
the file on every substitution. Required because microsandbox's
`SecretEntry.value: String` is captured at sandbox-builder time, with no
runtime update path.
- Enable TLS interception on the sandbox (`network(|n| n.tls(...))`) —
microsandbox's secrets are only substituted on TLS-intercepted
connections to allowed hosts, and the guest needs the sandbox CA in its
trust store.
- `agent-vm` on startup snapshots host `~/.claude/.credentials.json` and
`~/.codex/auth.json` into `<state>/tokens/{anthropic,openai}.token`
files (just the `access_token`, never the refresh token).
- Register file-backed secret entries for `api.anthropic.com`,
`api.openai.com`, `chatgpt.com`, `platform.claude.com`,
`auth.openai.com`, etc.
- Cross-instance lockfile under `<state>/tokens/` so two `agent-vm`
processes don't race when both want to refresh.
What shipped:
**Done when:** inside the guest, `cat /proc/$$/environ | tr '\0' '\n' |
grep -i token` shows only placeholders, while a real Claude (host OAuth)
request succeeds end-to-end and writes a turn to disk.
- **Upstream extension** on a vendor/microsandbox branch:
`SecretValue { Static(String), File(PathBuf) }` with a bare-string
wire format for Static (backward-compatible with prebuilt `msb`) and a
NUL-prefixed sentinel for File (deferred Phase 4 plumbing). 250
microsandbox-network tests green; new tests cover both wire formats.
- **agent-vm secrets module**: per-launch snapshot of
`~/.claude/.credentials.json` and `~/.codex/auth.json`, atomic write
of placeholder credentials files into the per-project state dir.
- **Launcher wiring**: TLS interception enabled, file-backed allow-host
configured for both providers (Anthropic + OpenAI), real access
tokens passed via `SecretValue::Static(...)` (Phase 3) — File
variant is forward-looking infra used only by Phase 4 once a patched
msb ships.
- `IS_SANDBOX=1` set so Claude Code's "don't run as root" guard yields
to the fact that the microVM *is* the security boundary.
What we deliberately punted:
- **Refresh**. Long sessions will eventually 401. Phase 4 handles it.
- **`~/.microsandbox/bin/msb` rebuild**. Required for `SecretValue::File`
to behave (without it, an old msb substitutes the literal sentinel
string). Phase 4 ships the replacement and switches to File-backed
secrets.
- **OAuth refresh endpoint MITM** (forging
`platform.claude.com/v1/oauth/token` responses from the host file).
Original Bash agent-vm does this; Phase 4 here.
**Done when:** inside the guest, `cat /proc/1/environ | tr '\0' '\n' |
grep -i token` shows only placeholders, while a Claude API request
through `api.anthropic.com` goes through the microsandbox CA-signed
intercept proxy with the placeholder substituted in the Authorization
header. **Actual:** verified at the network layer (TLS cert chain shows
`CN=microsandbox CA`, debug config dump shows the substituted real
token); the final "Anthropic returns a real response" leg can't be
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]

View file

@ -24,8 +24,12 @@ until v1 of the rewrite ships.
- Phase 2.x (RUST_LOG, host-path mounting, pull progress bar,
`agent-vm pull` + update-available banner, registry auto-recovery):
done.
- Phase 3 (host-rooted secrets via microsandbox TLS intercept): **next**.
- Phase 4 (token-refresh semantics): pending.
- Phase 3 (host-rooted secrets via microsandbox TLS intercept): done.
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 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

@ -16,7 +16,7 @@ tokio = { version = "1.42", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.5", features = ["derive", "env"] }
anyhow = "1.0"
sha2 = "0.10"
serde_json = "1"
serde_json = { version = "1", features = ["preserve_order"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
indicatif = "0.18"

View file

@ -5,6 +5,7 @@ mod pull;
mod pull_progress;
mod pulled_marker;
mod run;
mod secrets;
mod session;
mod setup;

View file

@ -138,6 +138,15 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// image available — the user runs `agent-vm pull` explicitly to
// fetch it.
notify_if_update_available(image.as_str()).await;
// Snapshot host credentials into per-project token files and place
// placeholder credentials.json files where the in-VM agents will
// find them. The token files are passed to microsandbox below as
// SecretValue::File entries; the host TLS-intercept proxy reads
// them on every connection setup. Real tokens never enter the VM.
let creds = crate::secrets::refresh(&session.state_dir)
.context("snapshotting host credentials")?;
let mut builder = Sandbox::builder(&session.sandbox_name)
.image(image.as_str())
.registry(|r| r.insecure())
@ -163,8 +172,50 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
.env("CODEX_HOME", "/agent-vm-state/codex")
.replace();
// Phase 2: pass API keys straight through. Phase 3 replaces this with
// host-rooted placeholder secrets.
// Phase 3: host-rooted credentials.
//
// For each provider we have a host file for, wire up TLS interception
// and register a SecretValue::File entry. The placeholder string
// matches what we wrote into the guest's credentials.json (see
// secrets::refresh), so when the agent reads its credentials, sends
// a Bearer header with the placeholder, the host proxy substitutes
// it with the real token from the per-project file.
//
// allow_host covers both the API endpoint and the OAuth endpoint of
// 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();
builder = builder.network(move |mut n| {
n = n.tls(|t| t);
if let Some(tok) = anthropic {
n = n.secret(|s| {
s.env("MSB_AGENT_VM_ANTHROPIC_UNUSED")
.value(tok)
.placeholder(crate::secrets::ANTHROPIC_PLACEHOLDER)
.allow_host("api.anthropic.com")
.allow_host("platform.claude.com")
});
}
if let Some(tok) = openai {
n = n.secret(|s| {
s.env("MSB_AGENT_VM_OPENAI_UNUSED")
.value(tok)
.placeholder(crate::secrets::OPENAI_PLACEHOLDER)
.allow_host("api.openai.com")
.allow_host("chatgpt.com")
.allow_host("auth.openai.com")
});
}
n
});
}
// Still honour ANTHROPIC_API_KEY / OPENAI_API_KEY if explicitly set
// by the user — that path stays a simple Bearer header, no
// placeholder substitution involved.
for var in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] {
if let Ok(val) = env::var(var) {
if !val.is_empty() {
@ -180,12 +231,25 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
"/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/bin",
);
// Claude Code refuses to run as root with --dangerously-skip-permissions
// unless this env var is set. The whole point of running it in a
// microVM is that the sandbox IS our security boundary, so the
// in-guest CLI's extra guard would just block us from getting work
// done. Same env var the original Bash agent-vm used.
builder = builder.env("IS_SANDBOX", "1");
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)"
);
let t_create = Instant::now();
let config = builder.build().await.context("preparing sandbox config")?;
if env::var("AGENT_VM_DEBUG_CONFIG").is_ok() {
eprintln!(
"[debug] sandbox config JSON: {}",
serde_json::to_string_pretty(&config).unwrap_or_default()
);
}
let (progress, task) = Sandbox::create_with_pull_progress(config);
let render_task = tokio::spawn(crate::pull_progress::render(progress));
let sandbox = task

View file

@ -0,0 +1,201 @@
//! Host-rooted credentials.
//!
//! 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.
//!
//! 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).
//!
//! 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 anyhow::{Context, Result};
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)]
pub struct CredsState {
pub anthropic_access_token: Option<String>,
pub openai_access_token: Option<String>,
}
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()
}
}
/// 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 refresh(state_dir: &Path) -> Result<CredsState> {
let anthropic_access_token = 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) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "openai credential refresh failed; skipping");
None
}
};
Ok(CredsState {
anthropic_access_token,
openai_access_token,
})
}
fn refresh_anthropic(state_dir: &Path) -> Result<Option<String>> {
let Some(host_path) = host_claude_creds_path() else {
return Ok(None);
};
if !host_path.exists() {
return Ok(None);
}
let raw = 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("host .credentials.json missing claudeAiOauth")?;
let access_token = oauth
.get("accessToken")
.and_then(|v| v.as_str())
.context("host claudeAiOauth missing accessToken")?
.to_string();
// Placeholder credentials.json the in-guest Claude reads. Goes
// into <state>/claude which is symlinked from /root/.claude in
// the guest (Phase 2 wiring).
let claude_dir = state_dir.join("claude");
fs::create_dir_all(&claude_dir)?;
let placeholder = serde_json::json!({
"claudeAiOauth": {
"accessToken": ANTHROPIC_PLACEHOLDER,
"refreshToken": "REFRESH_NOT_AVAILABLE_PHASE_3",
"expiresAt": oauth.get("expiresAt"),
"scopes": oauth.get("scopes"),
"subscriptionType": oauth.get("subscriptionType"),
"rateLimitTier": oauth.get("rateLimitTier"),
}
});
let body = serde_json::to_string_pretty(&placeholder)?;
atomic_write(&claude_dir.join(".credentials.json"), body.as_bytes(), 0o600)?;
Ok(Some(access_token))
}
fn refresh_openai(state_dir: &Path) -> Result<Option<String>> {
let Some(host_path) = host_codex_auth_path() else {
return Ok(None);
};
if !host_path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&host_path)
.with_context(|| format!("reading {}", host_path.display()))?;
let mut json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing {}", host_path.display()))?;
// Either ChatGPT OAuth (`tokens.access_token`) or an API key
// (`OPENAI_API_KEY`). Both end up as Bearer in outgoing requests.
let access_token = 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("host codex auth missing tokens.access_token or OPENAI_API_KEY")?
.to_string();
// Replace the real value in-place with the placeholder, preserving
// every other field (account_id, last_refresh, etc.) so the in-VM
// codex sees a valid-looking auth.json shape.
if let Some(tokens) = json.get_mut("tokens").and_then(|v| v.as_object_mut()) {
if tokens.contains_key("access_token") {
tokens.insert(
"access_token".into(),
Value::String(OPENAI_PLACEHOLDER.to_string()),
);
}
if tokens.contains_key("refresh_token") {
tokens.insert(
"refresh_token".into(),
Value::String("REFRESH_NOT_AVAILABLE_PHASE_3".to_string()),
);
}
}
if json.get("OPENAI_API_KEY").is_some() {
json["OPENAI_API_KEY"] = Value::String(OPENAI_PLACEHOLDER.to_string());
}
let body = serde_json::to_string_pretty(&json)?;
let codex_dir = state_dir.join("codex");
fs::create_dir_all(&codex_dir)?;
atomic_write(&codex_dir.join("auth.json"), body.as_bytes(), 0o600)?;
Ok(Some(access_token))
}
fn host_claude_creds_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".claude/.credentials.json"))
}
fn host_codex_auth_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".codex/auth.json"))
}
/// Atomic write-then-rename with mode 0600. Prevents readers from ever
/// seeing a half-written file.
fn atomic_write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
use std::{io::Write, os::unix::fs::OpenOptionsExt};
let tmp = path.with_extension("agent-vm-tmp");
{
let mut f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(mode)
.open(&tmp)
.with_context(|| format!("opening {}", tmp.display()))?;
f.write_all(data)
.with_context(|| format!("writing {}", tmp.display()))?;
}
fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit 229db2ab4fc774758f4bd1898a75da19585430d2
Subproject commit b95614c67b0d10640dcc20facebf188793ec0eaa