blob: c94f944b451c1beb516d6060408c082a33c1d978 [file]
//! Confinement for a harness: what it may see, and the one way out.
//!
//! A [`Plan`] says what goes into the sandbox: a root with the system's binaries read-only,
//! a private home, the working tree, the amem binary, the egress socket, and nothing
//! else. The network namespace is empty. The harness reaches the egress through a relay
//! on loopback that forwards to the socket, and the egress is the only process with the
//! network.
//!
//! Ten harnesses are known. Each needs three things from the launcher, and the adapter
//! for each is the `setup_*` function below:
//!
//! | Harness | Model calls | Memory over MCP | Config the launcher owns |
//! |---|---|---|---|
//! | Claude Code | `ANTHROPIC_BASE_URL` | `--mcp-config` file | `~/.claude.json` |
//! | Codex | `model_providers.<id>.base_url` | `mcp_servers.<id>.url` | `CODEX_HOME/config.toml` |
//! | Gemini CLI | `GOOGLE_GEMINI_BASE_URL` | `mcpServers.<id>.httpUrl` | `~/.gemini/settings.json` |
//! | Antigravity CLI | `GOOGLE_GEMINI_BASE_URL`; a login tunnels | `mcpServers.<id>.serverUrl` | `~/.gemini/antigravity-cli/settings.json`, `~/.gemini/config/` |
//! | Qwen Code | `OPENAI_BASE_URL`; key only | `mcpServers.<id>.httpUrl` | `~/.qwen/settings.json` |
//! | Grok Build | `GROK_XAI_API_BASE_URL`; a login tunnels | `mcp_servers.<id>.url` | `~/.grok/config.toml` |
//! | Muse Code | `--base-url`, after the harness's arguments | `mcp_servers.<id>`, `streamable_http` | `~/.config/muse/settings.json` |
//! | pi | `baseUrl` of a provider of its own | `mcpServers.<id>.url` | `~/.pi/agent/models.json`, `mcp.json`, `extensions/` |
//! | Copilot CLI | no override; `HTTPS_PROXY` to the egress, which tunnels to allowed hosts | `mcp-config.json` | `COPILOT_HOME` |
//! | OpenCode | `provider.<id>.options.baseURL` | `mcp.<id>.url` | `XDG_CONFIG_HOME/opencode/opencode.json` |
//!
//! Backends:
//! - Linux: user, mount, network, uts, ipc and pid namespaces, native, unprivileged, no
//! bubblewrap or container runtime needed. The plan's mounts build a root of its own,
//! so things sit at fixed paths inside ([`Layout::namespaces`]).
//! - macOS: Seatbelt, through `sandbox-exec`, with a profile made from the plan
//! ([`seatbelt`]). There is no mount namespace, so everything sits at its host path
//! ([`Layout::seatbelt`]) and the plan's mounts are what the profile grants. Dry-coded:
//! the profile is unit-tested everywhere, but the backend has not yet run on a Mac.
//! - Windows: run under WSL2.
//!
//! Where things sit inside is the [`Layout`], a property of the backend: [`prepare`]
//! uses the namespaces layout, [`prepare_in`] the one it is given.
use dm_core::Label;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[cfg(target_os = "linux")]
pub mod linux;
pub mod measure;
#[cfg(target_os = "linux")]
mod scratch;
pub mod seatbelt;
pub mod stop;
pub use measure::Resources;
/// What can go wrong preparing or running a sandbox.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A file under the sandbox home could not be written.
#[error("{path}: {source}")]
Io {
/// The path.
path: String,
/// Why.
source: std::io::Error,
},
/// The harness binary could not be found on PATH.
#[error("cannot find {0} on PATH")]
NoHarness(String),
/// The combination of harness, auth and provider is not supported.
#[error("{0}")]
Unsupported(String),
/// This platform has no backend for the plan: namespaces need Linux, Seatbelt macOS.
#[error("no confinement backend for this plan on this platform; namespaces need Linux or WSL2, Seatbelt needs macOS")]
NoBackend,
/// The confinement itself failed.
#[error("confinement: {0}")]
Confine(String),
}
fn io(path: &Path) -> impl FnOnce(std::io::Error) -> Error + '_ {
move |source| Error::Io {
path: path.display().to_string(),
source,
}
}
/// Which harness to run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Harness {
/// Claude Code.
Claude,
/// OpenAI Codex.
Codex,
/// Google's Gemini CLI.
Gemini,
/// Google's Antigravity CLI, `agy`, which replaced Gemini CLI for individual accounts.
Antigravity,
/// Alibaba's Qwen Code, a Gemini CLI fork that speaks OpenAI's API.
Qwen,
/// xAI's Grok Build, `grok`.
Grok,
/// Meta's Muse Code, `muse`.
Muse,
/// The pi coding agent, `pi`: four tools and extensions.
Pi,
/// GitHub Copilot CLI.
Copilot,
/// OpenCode.
OpenCode,
/// A program rather than a harness: the first harness argument is run as given, with
/// the egress and the memory server in its environment and nothing else configured.
/// For workloads such as a Gofannon agent, which is ordinary Python calling a gateway.
Command,
}
impl Harness {
/// The binary name on PATH.
#[must_use]
pub fn binary(self) -> &'static str {
match self {
Harness::Claude => "claude",
Harness::Codex => "codex",
Harness::Gemini => "gemini",
Harness::Antigravity => "agy",
Harness::Qwen => "qwen",
Harness::Grok => "grok",
Harness::Muse => "muse",
Harness::Pi => "pi",
Harness::Copilot => "copilot",
Harness::OpenCode => "opencode",
Harness::Command => "command",
}
}
/// The destination this harness's model calls go to by default.
#[must_use]
pub fn provider(self) -> &'static str {
match self {
Harness::Claude | Harness::OpenCode => "anthropic",
Harness::Codex | Harness::Pi | Harness::Command => "openai",
Harness::Gemini | Harness::Antigravity => "gemini",
Harness::Qwen => "dashscope",
Harness::Grok => "xai",
Harness::Muse => "meta",
Harness::Copilot => "github",
}
}
/// The destination a login, rather than a key, talks to. Differs where the
/// subscription backend is not the public API: Codex's ChatGPT login talks to
/// the ChatGPT backend, Gemini's Google login to Code Assist, Grok Build's login to
/// Grok's own proxy and relays.
#[must_use]
pub fn subscription_provider(self) -> &'static str {
match self {
Harness::Codex => "chatgpt",
Harness::Gemini | Harness::Antigravity => "google",
Harness::Grok => "grok",
other => other.provider(),
}
}
/// The harness's non-interactive form: run this prompt, print the answer, exit.
/// Each harness's own permission prompts are turned off, since nobody is there to
/// answer them and a task needs edits and commands, not only memory calls. The
/// sandbox is what bounds the harness: the working tree, the private home, and the
/// egress are all it can reach, whatever it is allowed to try.
#[must_use]
pub fn headless(self, prompt: &str) -> Vec<String> {
let p = prompt.to_string();
match self {
// headless, Claude Code omits its thinking unless asked, which leaves a bare
// "Thinking" in the transcript before each decision; summaries say why
Harness::Claude => vec![
"-p".into(),
p,
"--dangerously-skip-permissions".into(),
"--thinking-display".into(),
"summarized".into(),
],
// codex exec cannot ask, and refuses an MCP tool call that would need
// approval; its own gate and sandbox are bypassed, since ours is the boundary
Harness::Codex => vec![
"exec".into(),
"--skip-git-repo-check".into(),
"--dangerously-bypass-approvals-and-sandbox".into(),
// hooks the launcher wrote are ours to trust; nobody is there to say so
"--dangerously-bypass-hook-trust".into(),
p,
],
Harness::Gemini => vec!["-p".into(), p, "--approval-mode".into(), "yolo".into()],
// agy split Gemini CLI's yolo into two: every permission, and file edits
Harness::Antigravity => vec![
"-p".into(),
p,
"--dangerously-skip-permissions".into(),
"--mode".into(),
"accept-edits".into(),
],
// Qwen Code kept Gemini CLI's yolo, as a flag of its own
Harness::Qwen | Harness::Grok => vec!["-p".into(), p, "--yolo".into()],
// Muse's own approval and bubblewrap sandbox are off, since ours is the
// boundary; --json makes its run a readable record, since its session store
// is binary journals, and the launcher keeps that stream as the transcript
// and its reminder observers, background agents of its own that spend
// model calls at Muse's default effort, are off: nobody is there to remind
Harness::Muse => vec![
"exec".into(),
"--yolo".into(),
"--json".into(),
"--disable-reminders".into(),
p,
],
// pi has no permission system of its own, so nothing to turn off; project-local
// extensions are ignored, so only the launcher's runs; stdin is closed by the
// sandbox, which pi otherwise waits on
Harness::Pi => vec!["-p".into(), "-na".into(), p],
Harness::Copilot => vec!["-p".into(), p, "--allow-all-tools".into()],
Harness::OpenCode => vec!["run".into(), p],
// a program has no headless form of its own; the prompt is its first argument
Harness::Command => vec![p],
}
}
/// The flag that picks a model for one run, where the harness has one.
#[must_use]
pub fn model_flag(self, model: &str) -> Vec<String> {
match self {
Harness::Claude
| Harness::Copilot
| Harness::Antigravity
| Harness::Muse
| Harness::Pi => {
vec!["--model".into(), model.into()]
}
Harness::Codex
| Harness::Gemini
| Harness::Qwen
| Harness::Grok
| Harness::OpenCode => {
vec!["-m".into(), model.into()]
}
Harness::Command => Vec::new(),
}
}
/// Every harness, in the order the launcher lists them.
pub const ALL: [Harness; 11] = [
Harness::Claude,
Harness::Codex,
Harness::Gemini,
Harness::Antigravity,
Harness::Qwen,
Harness::Grok,
Harness::Muse,
Harness::Copilot,
Harness::OpenCode,
Harness::Pi,
Harness::Command,
];
/// The file a subscription login leaves under the home, which subscription mode
/// copies in, and how to make it. Copilot's login is a token, `COPILOT_GITHUB_TOKEN`
/// or `GH_TOKEN`, not a file; Qwen and pi have no login mode the launcher can carry,
/// and a command has no login of its own.
#[must_use]
pub fn login(self) -> Option<Login> {
let (file, how) = match self {
Harness::Claude => (".claude/.credentials.json", "a Claude Code login"),
Harness::Codex => (
".codex/auth.json",
"`codex login` (or `codex login --device-auth`)",
),
Harness::Gemini => (".gemini/oauth_creds.json", "a Gemini CLI Google login"),
Harness::Antigravity => (
".gemini/antigravity-cli/antigravity-oauth-token",
"an Antigravity CLI Google login (run `agy` once on the host)",
),
Harness::Grok => (".grok/auth.json", "`grok login`"),
Harness::Muse => (".config/muse/auth.json", "`muse login`"),
Harness::OpenCode => (
".local/share/opencode/auth.json",
"an OpenCode GitHub Copilot login (`opencode auth login`, GitHub Copilot)",
),
Harness::Qwen | Harness::Copilot | Harness::Pi | Harness::Command => return None,
};
Some(Login { file, how })
}
/// Whether `--provider` may point this harness at another destination.
#[must_use]
pub fn accepts_provider(self) -> bool {
matches!(
self,
Harness::Codex | Harness::Qwen | Harness::OpenCode | Harness::Pi | Harness::Command
)
}
}
/// Where a harness's login lives under the home, and what makes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Login {
/// Relative to the home.
pub file: &'static str,
/// What to run, or do, on the host to leave it there.
pub how: &'static str,
}
/// How the harness's model calls are authenticated.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
/// Through the local LiteLLM gateway with a virtual key the egress injects.
Gateway,
/// Straight to the provider with an API key the egress injects.
Key,
/// Straight to the provider with the harness's own login, which it holds; the egress
/// passes it through.
Subscription,
}
/// One bind mount into the sandbox.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mount {
/// On the host.
pub source: PathBuf,
/// Inside the sandbox.
pub target: PathBuf,
/// Read-only.
pub read_only: bool,
}
/// Everything a backend needs to run one confined session.
#[derive(Clone, Debug)]
pub struct Plan {
/// Bind mounts, applied in order. Parents must come before children.
pub mounts: Vec<Mount>,
/// The environment inside, complete; nothing is inherited.
pub env: BTreeMap<String, String>,
/// The command inside: the relay, then the harness.
pub argv: Vec<String>,
/// Working directory inside.
pub cwd: PathBuf,
/// The hostname inside.
pub hostname: String,
/// The label this session's reads are capped at, from the destination's ceiling.
pub handling_cap: Label,
/// Symlinks to recreate at the root, as `(name, target)`: merged-usr's `/bin ->
/// usr/bin` and friends, taken from the rootfs the plan runs in.
pub links: Vec<(PathBuf, PathBuf)>,
/// Where things sit inside, and so which backend runs the plan.
pub layout: Layout,
/// The host's home, which no backend may hand the harness: Seatbelt's profile
/// refuses a grant of it, or of anything above it.
pub host_home: Option<PathBuf>,
}
/// Which backend a plan is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
/// Linux namespaces: a root of the sandbox's own, built from the plan's mounts.
Namespaces,
/// macOS Seatbelt: the host's own paths, with the plan's mounts as the grants of a
/// deny-by-default profile.
Seatbelt,
}
/// Where a plan puts things as the harness sees them. A property of the backend: one
/// with a mount namespace puts each at a fixed path of the sandbox's own; one without
/// has the harness see each where it is on the host, so the harness's configuration is
/// written against the host's paths.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
/// The backend this layout is for.
pub backend: Backend,
/// The private home.
pub home: PathBuf,
/// The egress socket.
pub socket: PathBuf,
/// The amem binary, which runs the relay and the hooks.
pub memory: PathBuf,
/// The loopback address the relay listens on, as `host:port`.
pub relay: String,
/// The projection, when there is one to mount.
pub projection: Option<PathBuf>,
/// The temporary directory, `TMPDIR`.
pub tmp: PathBuf,
}
impl Layout {
/// The namespaces backend's: the [`INSIDE_HOME`], [`INSIDE_SOCKET`],
/// [`INSIDE_MEMORY`], [`INSIDE_RELAY`] and [`INSIDE_PROJECTION`] constants, and a
/// `/tmp` of the sandbox's own.
#[must_use]
pub fn namespaces() -> Layout {
Layout {
backend: Backend::Namespaces,
home: PathBuf::from(INSIDE_HOME),
socket: PathBuf::from(INSIDE_SOCKET),
memory: PathBuf::from(INSIDE_MEMORY),
relay: INSIDE_RELAY.to_string(),
projection: Some(PathBuf::from(INSIDE_PROJECTION)),
tmp: PathBuf::from("/tmp"),
}
}
/// Seatbelt's: everything at its host path, since macOS has no mount namespace. The
/// relay listens on `relay`, a loopback port the launcher picked for this session,
/// since the host's loopback is shared with every other session; the temporary
/// directory is under the private home, since the host's `/tmp` is shared too.
#[must_use]
pub fn seatbelt(req: &Request<'_>, relay: &str) -> Layout {
Layout {
backend: Backend::Seatbelt,
home: req.home.to_path_buf(),
socket: req.egress_socket.to_path_buf(),
memory: req.memory_binary.to_path_buf(),
relay: relay.to_string(),
projection: req.projection.map(Path::to_path_buf),
tmp: req.home.join(".tmp"),
}
}
/// A path under the private home, as the harness sees it. The box's paths are Unix
/// ones whatever the host, Linux in a container launched from Windows among them, so
/// `/` joins them rather than the host's separator.
fn under_home(&self, rel: &str) -> String {
let home = self.home.display().to_string();
format!("{}/{rel}", home.trim_end_matches('/'))
}
/// The shell command that runs the memory's hook for `harness`.
fn hook(&self, harness: &str) -> String {
format!("{} hook {harness}", shell_word(&self.memory))
}
/// The amem binary as a JSON, and so JavaScript, string literal.
fn memory_literal(&self) -> String {
serde_json::to_string(&self.memory.display().to_string()).unwrap_or_default()
}
}
/// A path as one shell word: as it is when nothing in it is special to a shell, single-
/// quoted otherwise. A hook command is run by the harness through a shell, and a host
/// path on macOS may hold a space.
fn shell_word(path: &Path) -> String {
let text = path.display().to_string();
let plain = |c: char| c.is_ascii_alphanumeric() || "/._-+:@%,=".contains(c);
if !text.is_empty() && text.chars().all(plain) {
text
} else {
format!("'{}'", text.replace('\'', "'\\''"))
}
}
/// A string as the inside of a TOML basic string.
fn toml_escaped(text: &str) -> String {
text.replace('\\', "\\\\").replace('"', "\\\"")
}
/// The inputs to a plan.
pub struct Request<'a> {
/// Which harness.
pub harness: Harness,
/// How it authenticates.
pub auth: AuthMode,
/// The destination its model calls go to; `None` means the harness's default.
pub provider: Option<&'a str>,
/// The working tree to mount read-write.
pub workdir: &'a Path,
/// Where the sandbox's private home lives on the host, persisted between sessions.
pub home: &'a Path,
/// The egress unix socket on the host.
pub egress_socket: &'a Path,
/// The `amem` binary on the host, mounted in for the relay.
pub memory_binary: &'a Path,
/// The destination's ceiling, which caps the session's handling.
pub destination_ceiling: Label,
/// Arguments after the harness binary.
pub harness_args: &'a [String],
/// The host's home, read only for the login file a subscription mode copies in:
/// Claude Code's `.claude/.credentials.json`, Codex's `.codex/auth.json`, Gemini's
/// `.gemini/oauth_creds.json`, Grok Build's `.grok/auth.json`. Nothing else is read
/// from it and it is never mounted.
pub host_home: Option<&'a Path>,
/// Credentials the harness itself must hold, passed into its environment. For
/// subscription modes that are a token rather than a file, such as Copilot's
/// `COPILOT_GITHUB_TOKEN` or `GH_TOKEN`.
pub harness_env: &'a [(String, String)],
/// The model the run was given, for an adapter whose config has to name it:
/// OpenCode lists a custom provider's models, and cannot ask models.dev here.
pub model: Option<&'a str>,
/// The harness runs in the harness image rather than from this host's binaries: the
/// harness is at `/usr/local/bin/<name>`, the amem binary is already inside, and
/// nothing of the host's system is mounted. Set by the `wslc` backend.
pub image: bool,
/// Where the working tree appears inside, when not at its host path. The `wslc`
/// backend mounts it at `/work`; the namespace backend keeps the host path.
pub workdir_inside: Option<&'a Path>,
/// An unpacked harness image to pivot into instead of this host's system: its
/// `/usr`, `/etc`, `/opt` and the rest are mounted read-only in place of the host's,
/// so a Linux run is the same environment as a run in the image elsewhere. Implies
/// `image`.
pub rootfs: Option<&'a Path>,
/// The record's projection the launcher rendered for this session, on the host, to
/// mount read-only at the layout's projection; `None` when there is none.
pub projection: Option<&'a Path>,
}
impl Request<'_> {
/// The working tree's path as the harness sees it.
#[must_use]
pub fn workdir_inside(&self) -> &Path {
self.workdir_inside.unwrap_or(self.workdir)
}
}
/// Where things sit inside the sandbox under the namespaces backend, [`Layout::namespaces`].
pub const INSIDE_HOME: &str = "/home/user";
/// The egress socket inside.
pub const INSIDE_SOCKET: &str = "/run/memory/egress.sock";
/// The amem binary inside. Outside `/usr`, which is mounted read-only before this.
pub const INSIDE_MEMORY: &str = "/opt/memory/memory";
/// The loopback address the relay listens on inside.
pub const INSIDE_RELAY: &str = "127.0.0.1:4100";
/// Where the record's projection, the memory as read-only files, appears inside when
/// the launcher rendered one.
pub const INSIDE_PROJECTION: &str = "/memory";
/// Where the launcher renders the projection on the host, relative to the record root,
/// one directory per session as `<harness>/<session>/`: the launcher's own, never under
/// the private home the harness can write.
pub const PROJECTION: &str = "state/projection";
/// Where hooks leave notes for the launcher, under the private home.
pub const OUTBOX: &str = ".memory-outbox";
fn write(path: &Path, text: &str) -> Result<(), Error> {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).map_err(io(p))?;
}
std::fs::write(path, text).map_err(io(path))
}
/// Copy the harness's own login in, as [`Harness::login`] names it.
fn copy_own_login(req: &Request<'_>) -> Result<(), Error> {
let login = req
.harness
.login()
.ok_or_else(|| Error::Unsupported(format!("{} has no login file", req.harness.binary())))?;
copy_login(req, login.file, login.how)
}
/// Copy one login file from the host's home into the sandbox's private home, at the same
/// relative path. The harness then finds its own login where it expects it.
fn copy_login(req: &Request<'_>, rel: &str, login: &str) -> Result<(), Error> {
let src = req.host_home.map(|h| h.join(rel)).filter(|p| p.is_file());
let Some(src) = src else {
return Err(Error::Unsupported(format!(
"subscription mode needs ~/{rel} from {login}; run that login on the host first"
)));
};
let dst = req.home.join(rel);
if let Some(p) = dst.parent() {
std::fs::create_dir_all(p).map_err(io(p))?;
}
std::fs::copy(src, &dst).map_err(io(&dst))?;
Ok(())
}
/// Point the harness's HTTPS at the egress for what its base-URL override does not
/// cover, such as a token refresh; the egress tunnels only to the hosts the
/// destination allows.
fn proxy_via(relay: &str, env: &mut BTreeMap<String, String>) {
env.insert("HTTPS_PROXY".into(), relay.to_string());
env.insert("HTTP_PROXY".into(), relay.to_string());
env.insert("NO_PROXY".into(), "127.0.0.1,localhost".into());
}
/// A variable from the launcher's environment the harness may see, when it is set.
fn env_passthrough(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn find_on_path(name: &str) -> Result<PathBuf, Error> {
let path = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path)
.map(|d| d.join(name))
.find(|p| p.is_file())
.and_then(|p| std::fs::canonicalize(p).ok())
.ok_or_else(|| Error::NoHarness(name.to_string()))
}
/// Directories of the host system mounted read-only so binaries run. `/bin`, `/lib` and
/// friends are symlinks into `/usr` on merged-usr systems; the backend recreates those
/// links inside instead of mounting through them.
fn system_mounts() -> Vec<Mount> {
let mut m = Vec::new();
for d in [
"/usr",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/lib32",
"/etc/alternatives",
"/etc/ssl",
] {
let p = PathBuf::from(d);
if p.exists() && !p.is_symlink() {
m.push(Mount {
source: p.clone(),
target: p,
read_only: true,
});
}
}
system_files(&mut m, Path::new("/"));
m
}
/// The system from an unpacked image: whole directories, read-only, including `/etc`
/// and `/opt` since the image's `passwd`, certificates and `memory` binary are the ones
/// a run in the image elsewhere would see. Merged-usr symlinks are skipped here and
/// recreated by the backend from [`Plan::links`].
fn rootfs_mounts(rootfs: &Path) -> Vec<Mount> {
let mut m = Vec::new();
for d in [
"usr", "bin", "sbin", "lib", "lib64", "lib32", "etc", "opt", "var",
] {
let p = rootfs.join(d);
if p.is_dir() && !p.is_symlink() {
m.push(Mount {
source: p,
target: Path::new("/").join(d),
read_only: true,
});
}
}
m
}
/// Merged-usr's symlinks at the root of `rootfs`, to recreate inside.
fn root_links(rootfs: &Path) -> Vec<(PathBuf, PathBuf)> {
["bin", "sbin", "lib", "lib64", "lib32"]
.iter()
.filter_map(|name| {
std::fs::read_link(rootfs.join(name))
.ok()
.map(|t| (PathBuf::from(name), t))
})
.collect()
}
fn system_files(m: &mut Vec<Mount>, _root: &Path) {
for f in [
"/etc/ld.so.cache",
"/etc/localtime",
"/etc/passwd",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/hosts",
] {
let p = PathBuf::from(f);
if p.exists() {
m.push(Mount {
source: p.clone(),
target: p,
read_only: true,
});
}
}
}
/// What to mount so the harness binary runs, and what to add to PATH for it.
///
/// A Node-installed harness lives under `<prefix>/lib/node_modules/...` and is started by
/// `<prefix>/bin/node`, so the whole prefix is mounted and its `bin` goes on PATH. Any
/// other binary gets its own directory mounted, unless that is already under `/usr`.
fn harness_mounts(real: &Path) -> (Vec<Mount>, Option<PathBuf>) {
let text = real.to_string_lossy();
if let Some(i) = text.find("/lib/node_modules/") {
let prefix = PathBuf::from(&text[..i]);
let m = Mount {
source: prefix.clone(),
target: prefix.clone(),
read_only: true,
};
return (vec![m], Some(prefix.join("bin")));
}
match real.parent() {
Some(dir) if !dir.starts_with("/usr") && !dir.starts_with("/bin") => (
vec![Mount {
source: dir.to_path_buf(),
target: dir.to_path_buf(),
read_only: true,
}],
Some(dir.to_path_buf()),
),
_ => (Vec::new(), None),
}
}
/// The environment every harness starts with: a private home, the terminal's locale and
/// colour settings, and whatever credential the harness must hold itself.
fn base_env(harness_env: &[(String, String)], at: &Layout) -> BTreeMap<String, String> {
let mut env: BTreeMap<String, String> = BTreeMap::new();
env.insert("HOME".into(), at.home.display().to_string());
// where every harness's hooks leave notes for the launcher
env.insert("MEMORY_OUTBOX".into(), at.under_home(OUTBOX));
env.insert("USER".into(), "user".into());
env.insert("LOGNAME".into(), "user".into());
env.insert("TMPDIR".into(), at.tmp.display().to_string());
for k in [
"TERM",
"COLORTERM",
"LANG",
"LC_ALL",
"NO_COLOR",
"FORCE_COLOR",
// the hooks log what they are sent, for an adapter written from a harness's docs
"MEMORY_HOOK_DEBUG",
"MEMORY_EGRESS_DEBUG",
] {
if let Ok(v) = std::env::var(k) {
env.insert(k.into(), v);
}
}
env.entry("TERM".into())
.or_insert_with(|| "xterm-256color".into());
for (k, v) in harness_env {
env.insert(k.clone(), v.clone());
}
env
}
/// Where the projection is inside, when the launcher rendered one: the same read-only
/// mount on every backend, so the hook has one path to know.
fn projection_env(req: &Request<'_>, at: &Layout, env: &mut BTreeMap<String, String>) {
if let Some(inside) = at.projection.as_ref() {
if req.projection.is_some_and(Path::is_dir) {
env.insert("MEMORY_PROJECTION".into(), inside.display().to_string());
}
}
}
/// Everything mounted into the sandbox, in order, and the directory the harness's own
/// binaries want on PATH. The host's system is mounted only where the backend builds a
/// root of its own; under Seatbelt the system is the profile's, and these are its grants.
fn plan_mounts(
req: &Request<'_>,
at: &Layout,
harness_bin: &Path,
) -> (Vec<Mount>, Option<PathBuf>) {
let mut mounts = match at.backend {
Backend::Namespaces => system_mounts(),
Backend::Seatbelt => Vec::new(),
};
mounts.push(Mount {
source: req.memory_binary.to_path_buf(),
target: at.memory.clone(),
read_only: true,
});
let (harness_m, extra_path) = harness_mounts(harness_bin);
mounts.extend(harness_m);
mounts.push(Mount {
source: req.home.to_path_buf(),
target: at.home.clone(),
read_only: false,
});
mounts.push(Mount {
source: req.workdir.to_path_buf(),
target: req.workdir_inside().to_path_buf(),
read_only: false,
});
mounts.extend(projection_mount(req, at));
mounts.push(Mount {
source: req.egress_socket.to_path_buf(),
target: at.socket.clone(),
read_only: false,
});
(mounts, extra_path)
}
/// The projection, when the launcher rendered one: its own directory, mounted read-only,
/// so the harness reads it there and nowhere else and cannot edit it into lies. Every
/// backend that names the layout's projection in the environment mounts it; a container
/// backend does so itself, from the host's view of the path.
fn projection_mount(req: &Request<'_>, at: &Layout) -> Option<Mount> {
let target = at.projection.as_ref()?;
req.projection.filter(|p| p.is_dir()).map(|p| Mount {
source: p.to_path_buf(),
target: target.clone(),
read_only: true,
})
}
/// In the harness image only the two things that are the session's: its private home
/// and the working tree. The runner turns these into the container's bind mounts; the
/// egress socket arrives through a shared volume instead.
fn image_mounts(req: &Request<'_>) -> Vec<Mount> {
vec![
Mount {
source: req.home.to_path_buf(),
target: PathBuf::from(INSIDE_HOME),
read_only: false,
},
Mount {
source: req.workdir.to_path_buf(),
target: req.workdir_inside().to_path_buf(),
read_only: false,
},
]
}
/// The destination this run's model calls go to: the harness's own, the one asked for
/// where the harness takes one, or the gateway's when the gateway is the route.
fn provider_of(req: &Request<'_>) -> Result<String, Error> {
let own = [req.harness.provider(), req.harness.subscription_provider()];
Ok(match req.provider {
Some(p)
if req.harness.accepts_provider()
|| own.contains(&p)
|| req.auth == AuthMode::Gateway =>
{
p.to_string()
}
Some(p) => {
return Err(Error::Unsupported(format!(
"{} does not take --provider ({p}); its model calls go to {}",
req.harness.binary(),
req.harness.provider()
)))
}
None => req.harness.provider().to_string(),
})
}
/// The working tree and the sandbox's own mounts must not overlap. One mounted after the
/// tree, under it or at it, hides that part of the tree; a tree at or above one of them
/// hides the sandbox's own, the home among them. A tree under the home stays: the home
/// is mounted first.
///
/// Where the layout is the host's own, as Seatbelt's is, nothing is mounted and nothing
/// hidden, but the tree is granted read-write: it may not sit in the read-only
/// projection, nor hold the private home, the projection, the amem binary that runs
/// the hooks, or the egress socket.
fn refuse_a_hidden_workdir(req: &Request<'_>, at: &Layout) -> Result<(), Error> {
let inside = req.workdir_inside();
let projection = at.projection.as_deref();
let mounted = at.backend == Backend::Namespaces;
let after_the_tree = [
projection,
mounted.then(|| at.memory.parent().unwrap_or(&at.memory)),
mounted.then(|| at.socket.parent().unwrap_or(&at.socket)),
];
let own = [
Some(at.home.as_path()),
projection,
Some(at.memory.as_path()),
Some(at.socket.as_path()),
];
let hidden = after_the_tree
.into_iter()
.flatten()
.find(|own| inside.starts_with(own))
.or_else(|| {
own.into_iter()
.flatten()
.find(|own| own.starts_with(inside))
});
match hidden {
Some(own) => Err(Error::Unsupported(format!(
"the working tree {} would sit under the sandbox's own {}; run from another directory",
inside.display(),
own.display()
))),
None => Ok(()),
}
}
/// Build the plan for the namespaces backend: write the harness's configuration into the
/// private home and decide mounts, environment, and command.
///
/// # Errors
///
/// A harness not on PATH, an unsupported harness, auth and provider combination, or a
/// file under the home that cannot be written.
pub fn prepare(req: &Request<'_>) -> Result<Plan, Error> {
prepare_in(req, Layout::namespaces())
}
/// Build the plan for the backend `at` is the layout of: the harness's configuration is
/// written against the layout's paths, and the mounts put each thing there.
///
/// # Errors
///
/// As [`prepare`], and the harness image under a layout that is not the namespaces
/// backend's.
pub fn prepare_in(req: &Request<'_>, at: Layout) -> Result<Plan, Error> {
refuse_a_hidden_workdir(req, &at)?;
let image = req.image || req.rootfs.is_some();
if image && at.backend != Backend::Namespaces {
return Err(Error::Unsupported(
"the harness image runs under Linux namespaces or in a container, not under Seatbelt"
.into(),
));
}
let harness_bin = if req.harness == Harness::Command {
program_of(req.harness_args, image)?
} else if image {
PathBuf::from("/usr/local/bin").join(req.harness.binary())
} else {
find_on_path(req.harness.binary())?
};
let harness_args: &[String] = if req.harness == Harness::Command {
req.harness_args.get(1..).unwrap_or(&[])
} else {
req.harness_args
};
std::fs::create_dir_all(req.home).map_err(io(req.home))?;
if at.backend == Backend::Seatbelt {
std::fs::create_dir_all(&at.tmp).map_err(io(&at.tmp))?;
}
let relay = format!("http://{}", at.relay);
let provider = provider_of(req)?;
let mut env = base_env(req.harness_env, &at);
// the memory server's address, for the hooks as for the harness
env.insert("MEMORY_MCP_URL".into(), format!("{relay}/mcp"));
projection_env(req, &at, &mut env);
let (mounts, extra_path) = mounts_of(req, &at, image, &harness_bin);
let links = match at.backend {
Backend::Namespaces => root_links(req.rootfs.unwrap_or(Path::new("/"))),
Backend::Seatbelt => Vec::new(),
};
let memory_dir = at.memory.parent().unwrap_or(Path::new("/"));
let mut path = format!("{}:/usr/local/bin:/usr/bin:/bin", memory_dir.display());
if let Some(p) = extra_path {
path = format!("{}:{path}", p.display());
}
env.insert("PATH".into(), path);
let mut argv = vec![
at.memory.display().to_string(),
"relay".into(),
"--socket".into(),
at.socket.display().to_string(),
"--listen".into(),
at.relay.clone(),
"--".into(),
harness_bin.display().to_string(),
];
// what an adapter adds before the harness's own arguments, and what it adds after
// them: a harness whose flags belong to a subcommand, as Muse's do to `exec`,
// takes them at the end, where every one of them accepts options after the prompt
let mut tail = Vec::new();
setup_harness(req, &at, &relay, &provider, &mut env, &mut argv, &mut tail)?;
argv.extend(harness_args.iter().cloned());
argv.extend(tail);
Ok(Plan {
mounts,
env,
argv,
cwd: req.workdir_inside().to_path_buf(),
hostname: "memory-sandbox".into(),
handling_cap: req.destination_ceiling,
links,
layout: at,
host_home: req.host_home.map(Path::to_path_buf),
})
}
/// The plan's mounts, and the directory the harness's own binaries want on PATH: the
/// image's system with the session's own on top, the session's own alone in a harness
/// container, or this host's system and the harness's install.
fn mounts_of(
req: &Request<'_>,
at: &Layout,
image: bool,
harness_bin: &Path,
) -> (Vec<Mount>, Option<PathBuf>) {
match req.rootfs {
Some(rootfs) => {
let mut m = rootfs_mounts(rootfs);
// the launcher's own binary over the image's copy, so the relay and the
// hooks inside are the launcher's build, not the image's; a container
// backend has no such mount and runs what its image carries
m.push(Mount {
source: req.memory_binary.to_path_buf(),
target: at.memory.clone(),
read_only: true,
});
m.extend(image_mounts(req));
m.extend(projection_mount(req, at));
m.push(Mount {
source: req.egress_socket.to_path_buf(),
target: at.socket.clone(),
read_only: false,
});
(m, None)
}
None if image => (image_mounts(req), None),
None => plan_mounts(req, at, harness_bin),
}
}
/// The adapter for the requested harness: each writes its config into the private home
/// and sets the environment that points it at the relay.
fn setup_harness(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
argv: &mut Vec<String>,
tail: &mut Vec<String>,
) -> Result<(), Error> {
match req.harness {
Harness::Claude => setup_claude(req, at, relay, provider, env, argv),
Harness::Codex => setup_codex(req, at, relay, provider, env),
Harness::Gemini => setup_gemini(req, at, relay, provider, env),
Harness::Antigravity => setup_antigravity(req, at, relay, provider, env),
Harness::Qwen => setup_qwen(req, at, relay, provider, env),
Harness::Grok => setup_grok(req, at, relay, provider, env),
Harness::Muse => setup_muse(req, at, relay, provider, env, tail),
Harness::Pi => setup_pi(req, at, relay, provider, env),
Harness::Copilot => setup_copilot(req, at, relay, env, argv),
Harness::OpenCode => setup_opencode(req, at, relay, provider, env),
Harness::Command => {
setup_command(req, relay, provider, env);
Ok(())
}
}
}
fn setup_claude(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
argv: &mut Vec<String>,
) -> Result<(), Error> {
let mcp = serde_json::json!({
"mcpServers": { "memory": { "type": "http", "url": format!("{relay}/mcp") } }
});
write(&req.home.join(".claude-mcp.json"), &mcp.to_string())?;
claude_global_config(&req.home.join(".claude.json"), req.workdir_inside())?;
claude_hooks(at, &req.home.join(".claude/settings.json"))?;
claude_thinking_shown(&req.home.join(".claude/settings.json"))?;
// anthropic, or the gateway's destination when that is the route
env.insert("ANTHROPIC_BASE_URL".into(), format!("{relay}/{provider}"));
env.insert("DISABLE_AUTOUPDATER".into(), "1".into());
match req.auth {
AuthMode::Subscription => {
copy_own_login(req)?;
}
AuthMode::Gateway | AuthMode::Key => {
// the egress injects the real credential; this one only has to exist
env.insert("ANTHROPIC_AUTH_TOKEN".into(), "egress".into());
}
}
argv.push("--mcp-config".into());
argv.push(at.under_home(".claude-mcp.json"));
argv.push("--strict-mcp-config".into());
Ok(())
}
/// The hooks that leave a handoff the agent did not write, before compaction and at
/// session end, and the one that turns a read of the projection into a `get`, before a
/// tool runs. Where the projection is mounted is set with the environment, below. The
/// hook before compaction and at
/// session end, the `memory` binary inside reads the event and the transcript and
/// drops a note in the outbox for the launcher to file. Merged into the user settings
/// in the private home, which the launcher owns.
fn claude_hooks(at: &Layout, path: &Path) -> Result<(), Error> {
merge_hooks(
at,
path,
"claude",
&["PreCompact", "SessionEnd", "PreToolUse"],
30,
)
}
/// Claude Code asks for its thinking as summaries, so its transcript says why before a
/// decision instead of a bare "Thinking" with nothing in it. Off by default in Claude
/// Code, where thinking comes back as a signature alone; the record is the reason to
/// turn it on.
fn claude_thinking_shown(path: &Path) -> Result<(), Error> {
let mut settings: serde_json::Value = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_else(|| serde_json::json!({}));
if !settings.is_object() {
settings = serde_json::json!({});
}
settings["showThinkingSummaries"] = serde_json::Value::Bool(true);
write(
path,
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)
}
/// Merge the hooks for `events` into a JSON settings file that may hold other things:
/// Claude Code's `settings.json`, Codex's `hooks.json`, Qwen Code's `settings.json`.
/// All share the shape, `hooks → event → matcher groups → command handlers`; they differ
/// in the name of the compaction event and in how long a session-end hook may run.
fn merge_hooks(
at: &Layout,
path: &Path,
harness: &str,
events: &[&str],
timeout: u32,
) -> Result<(), Error> {
let mut settings: serde_json::Value = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_else(|| serde_json::json!({}));
if !settings.is_object() {
settings = serde_json::json!({});
}
let hook = serde_json::json!([{
"matcher": "*",
"hooks": [{ "type": "command", "command": at.hook(harness), "timeout": timeout }]
}]);
for e in events {
settings["hooks"][*e] = hook.clone();
}
write(
path,
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)
}
fn setup_codex(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
let config = if req.auth == AuthMode::Subscription {
// a ChatGPT login talks to the ChatGPT backend, which refuses relayed requests, so
// Codex's HTTPS goes through the egress as a tunnel to the ChatGPT hosts, as
// Copilot CLI's does; the built-in provider and its backend stay as they are
copy_own_login(req)?;
proxy_via(relay, env);
// the forward variant, kept for diagnosis: the backend behind the egress
let base = if std::env::var_os("MEMORY_CHATGPT_FORWARD").is_some() {
format!("chatgpt_base_url = \"{relay}/chatgpt/\"\n\n")
} else {
String::new()
};
format!(
r#"# written by amem launch; edits are overwritten
{base}show_raw_agent_reasoning = true
[features]
hooks = true
[mcp_servers.memory]
url = "{relay}/mcp"
"#
)
} else {
env.insert("DM_EGRESS_TOKEN".into(), "egress".into());
format!(
r#"# written by amem launch; edits are overwritten
model_provider = "egress"
# the reasoning an open model returns is kept in the rollout, so the record has the why
show_raw_agent_reasoning = true
[model_providers.egress]
name = "egress ({provider})"
base_url = "{relay}/{provider}/v1"
env_key = "DM_EGRESS_TOKEN"
wire_api = "responses"
[features]
hooks = true
[mcp_servers.memory]
url = "{relay}/mcp"
"#
)
};
write(&req.home.join(".codex/config.toml"), &config)?;
// Codex routes its tools, MCP included, through a helper it expects beside itself;
// the npm install ships it, a bare release tarball does not
if !req.image && req.rootfs.is_none() {
if let Ok(bin) = find_on_path("codex") {
if !bin.with_file_name("codex-code-mode-host").is_file() {
eprintln!(
"amem: warning: {} has no codex-code-mode-host beside it; Codex's tools, memory among them, will fail. Install it from the same Codex release",
bin.display()
);
}
}
}
// Codex gives a session-end hook three seconds at most; the projection's guard
// before a tool runs has the time a tool hook may take
let hooks = req.home.join(".codex/hooks.json");
merge_hooks(at, &hooks, "codex", &["PreCompact", "SessionEnd"], 3)?;
merge_hooks(at, &hooks, "codex", &["PreToolUse"], 30)?;
env.insert("CODEX_HOME".into(), at.under_home(".codex"));
Ok(())
}
fn setup_gemini(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
let subscription = req.auth == AuthMode::Subscription;
if subscription {
// a Google login talks to Code Assist, not the Gemini API: its endpoint moves to
// the egress, and the token refresh at oauth2.googleapis.com goes through the
// egress as a tunnel
copy_own_login(req)?;
// which account and tier; optional, Gemini asks Google again without it
let _ = copy_login(req, ".gemini/google_accounts.json", "");
proxy_via(relay, env);
env.insert("CODE_ASSIST_ENDPOINT".into(), format!("{relay}/google"));
if let Some(project) = env_passthrough("GOOGLE_CLOUD_PROJECT") {
env.insert("GOOGLE_CLOUD_PROJECT".into(), project);
}
} else {
env.insert(
"GOOGLE_GEMINI_BASE_URL".into(),
format!("{relay}/{provider}"),
);
// the egress injects the real key as x-goog-api-key and drops any ?key= it sees
env.insert("GEMINI_API_KEY".into(), "egress".into());
}
let auth_type = if subscription {
"oauth-personal"
} else {
"gemini-api-key"
};
// Gemini's hook timeout is in milliseconds, unlike Claude Code's and Codex's seconds.
// BeforeTool is Gemini's name for the event before a tool runs; the hook waits ten
// seconds on the memory server and Gemini continues when it kills a hook, so this
// one gets longer than that wait, or a stalled server would be a read let through
let hook = |timeout: u32| {
serde_json::json!([{
"matcher": "*",
"hooks": [{ "type": "command", "command": at.hook("gemini"), "timeout": timeout }]
}])
};
let mut settings = serde_json::json!({
"hooks": { "PreCompress": hook(10_000), "SessionEnd": hook(10_000), "BeforeTool": hook(30_000) },
"security": { "auth": { "selectedType": auth_type } },
"general": { "enableAutoUpdate": false },
"privacy": { "usageStatisticsEnabled": false },
"mcpServers": { "memory": { "httpUrl": format!("{relay}/mcp") } }
});
include_projection(req, at, &mut settings);
write(
&req.home.join(".gemini/settings.json"),
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)?;
// the working tree is the one thing mounted read-write; it is trusted by construction
env.insert("GEMINI_CLI_TRUST_WORKSPACE".into(), "true".into());
Ok(())
}
/// Gemini CLI and its forks refuse their file tools a path outside the workspace, and a
/// model so refused falls back to a shell the hook does not see; when the projection is
/// mounted, the settings include it in the workspace, so a read of it goes through the
/// tool, and the hook, as a `get`.
fn include_projection(req: &Request<'_>, at: &Layout, settings: &mut serde_json::Value) {
if let Some(inside) = at.projection.as_ref() {
if req.projection.is_some_and(Path::is_dir) {
settings["context"]["includeDirectories"] =
serde_json::json!([inside.display().to_string()]);
}
}
}
/// Antigravity CLI keeps Gemini CLI's variables for a key, `GEMINI_API_KEY` behind
/// `GOOGLE_GEMINI_BASE_URL`, once its settings name the Gemini API as the model
/// provider; a Google login is a token file the host's login left, with its HTTPS through
/// the egress as a tunnel to Google's hosts, since the login's endpoint has no override
/// of its own that is known to work. MCP servers and hooks live in files of their own
/// under `.gemini/config`, not in the settings, and a remote server is `serverUrl`.
fn setup_antigravity(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
let subscription = req.auth == AuthMode::Subscription;
let mut settings = serde_json::json!({});
if subscription {
copy_own_login(req)?;
let _ = copy_login(req, ".gemini/google_accounts.json", "");
// the login's model host is one the egress tunnels to; no endpoint override
// for it is known, so the route is pinned egress without metering
proxy_via(relay, env);
if let Some(project) = env_passthrough("GOOGLE_CLOUD_PROJECT") {
env.insert("GOOGLE_CLOUD_PROJECT".into(), project);
}
} else {
// the key is read only when the settings say so; the egress holds the real one
settings["modelProvider"] = "gemini".into();
env.insert(
"GOOGLE_GEMINI_BASE_URL".into(),
format!("{relay}/{provider}"),
);
env.insert("GEMINI_API_KEY".into(), "egress".into());
}
write(
&req.home.join(".gemini/antigravity-cli/settings.json"),
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)?;
let mcp = serde_json::json!({
"mcpServers": { "memory": { "serverUrl": format!("{relay}/mcp") } }
});
write(
&req.home.join(".gemini/config/mcp_config.json"),
&serde_json::to_string_pretty(&mcp).unwrap_or_default(),
)?;
// agy's hooks: named groups of events; the tool events take matcher groups like
// Claude Code's, the loop events a flat list of handlers. `Stop` is the end of a
// turn, which the hook folds into one note, and `PreToolUse` guards the projection
// in interactive runs; the headless flag that skips permissions skips this layer
// too, so a `work` run has no observation here. There is no pre-compaction event.
let command = serde_json::json!({
"type": "command",
"command": at.hook("antigravity"),
"timeout": 30
});
let hooks = serde_json::json!({
"memory": {
"PreToolUse": [{ "matcher": "*", "hooks": [command] }],
"Stop": [command]
}
});
write(
&req.home.join(".gemini/config/hooks.json"),
&serde_json::to_string_pretty(&hooks).unwrap_or_default(),
)?;
env.insert("AGY_CLI_DISABLE_AUTO_UPDATE".into(), "true".into());
Ok(())
}
/// Qwen Code is Gemini CLI's fork for Alibaba's models and speaks OpenAI's API: a key is
/// `OPENAI_API_KEY` behind `OPENAI_BASE_URL`, read once its settings select the OpenAI
/// auth type, and DashScope's compatible endpoint is the default destination, any
/// other OpenAI-compatible one on request. There is no login mode: Qwen's free OAuth
/// tier ended in April 2026 and the harness exits when it is selected. MCP servers and
/// hooks live in the settings, the hooks in Claude Code's shape with timeouts in
/// seconds.
fn setup_qwen(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
if req.auth == AuthMode::Subscription {
return Err(Error::Unsupported(
"qwen has no login mode: Qwen's free OAuth tier ended in April 2026; use \
--auth key with DASHSCOPE_API_KEY (or --provider openai with OPENAI_API_KEY)"
.into(),
));
}
env.insert("OPENAI_BASE_URL".into(), format!("{relay}/{provider}/v1"));
// the egress injects the real key; this one only has to exist
env.insert("OPENAI_API_KEY".into(), "egress".into());
let mut settings = serde_json::json!({
"security": { "auth": { "selectedType": "openai" } },
"general": { "enableAutoUpdate": false },
"privacy": { "usageStatisticsEnabled": false },
"mcpServers": { "memory": { "httpUrl": format!("{relay}/mcp") } }
});
include_projection(req, at, &mut settings);
let path = req.home.join(".qwen/settings.json");
write(
&path,
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)?;
merge_hooks(
at,
&path,
"qwen",
&["PreCompact", "SessionEnd", "PreToolUse"],
30,
)
}
/// Grok Build is a Rust binary that keeps its config in `~/.grok/config.toml`. Only
/// API-key inference reads `GROK_XAI_API_BASE_URL`, so a key is `XAI_API_KEY` behind it;
/// a login is the token file `grok login` left, copied in, and its calls go to Grok's
/// own proxy and websocket relays, through the egress as a tunnel to those hosts in this
/// first cut: the proxy has an override, `GROK_CLI_CHAT_PROXY_BASE_URL`, and speaks the
/// Responses API, so a forward through it is the metered route to prove on a real
/// login. A stored session token outranks a key and goes to the proxy, so a key run
/// removes one left in the home by an earlier login run. MCP servers and hooks live in
/// the same file; a hook group takes no matcher, since an absent one selects every
/// tool, and its timeout is in seconds with a default of five, too short for a `get`.
/// Grok checks a model id against its catalog, so a model of the destination's own,
/// a gateway's say, is declared as a `[model.<id>]` block of the config with its base,
/// key and chat-completions backend, and `-m <id>` then selects it.
fn setup_grok(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
if req.auth == AuthMode::Subscription {
copy_own_login(req)?;
proxy_via(relay, env);
} else {
// xai on a key; the gateway's destination when that is the route
env.insert(
"GROK_XAI_API_BASE_URL".into(),
format!("{relay}/{provider}/v1"),
);
// the egress injects the real key; this one only has to exist
env.insert("XAI_API_KEY".into(), "egress".into());
let stale = req.home.join(".grok/auth.json");
if let Err(e) = std::fs::remove_file(&stale) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(io(&stale)(e));
}
}
}
let command = toml_escaped(&at.hook("grok"));
let hook = |event: &str| {
format!(
r#"[[hooks.{event}]]
hooks = [{{ type = "command", command = "{command}", timeout = 30 }}]
"#
)
};
// a model Grok's catalog does not know, declared as the destination's own
let custom = match (req.auth, req.model) {
(AuthMode::Subscription, _) | (_, None) => String::new(),
(_, Some(m)) => {
let id = m.replace(['"', '\\'], "");
format!(
r#"[model."{id}"]
name = "{id}"
model = "{id}"
base_url = "{relay}/{provider}/v1"
api_key = "egress"
api_backend = "chat_completions"
context_window = 131072
"#
)
}
};
let config = format!(
r#"# written by amem launch; edits are overwritten
[cli]
auto_update = false
[features]
telemetry = false
feedback = false
remote_fetch = false
{custom}[mcp_servers.memory]
url = "{relay}/mcp"
{}{}{}"#,
hook("PreToolUse"),
hook("SessionEnd"),
hook("PreCompact"),
);
write(&req.home.join(".grok/config.toml"), &config)?;
env.insert("GROK_DISABLE_AUTOUPDATER".into(), "1".into());
Ok(())
}
/// Muse Code is a statically linked binary behind a self-updating launcher. Its settings
/// are `~/.config/muse/settings.json`, which must carry `schema_version`, with MCP
/// servers under `mcp_servers` as `streamable_http` entries and hooks in Claude Code's
/// shape under `hooks`, which it reads with the same events and answers; it hands a hook
/// no transcript path, since its session store is binary journals. The model endpoint
/// is set with `--base-url`, which only its `exec` subcommand and the TUI take, so it
/// goes after the harness's arguments; a key is `META_API_KEY`, which outranks the
/// login, so a key run never sees a login the home kept. The first call on that base is
/// `GET /muse-code/models`, the model catalog, before any inference, so the destination
/// must forward the whole of `api.meta.ai`, not a path under it. A login is the file
/// `muse login` leaves, copied in; its calls go to the same host, so the same forward
/// carries them as a pass-through, which is the metered route to prove on a real login.
/// The launcher's update check is off, since the image pins what it carries.
fn setup_muse(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
tail: &mut Vec<String>,
) -> Result<(), Error> {
if req.auth == AuthMode::Subscription {
copy_own_login(req)?;
} else {
// the egress injects the real key; this one only has to exist
env.insert("META_API_KEY".into(), "egress".into());
}
env.insert("MUSE_NO_AUTO_UPDATE".into(), "1".into());
env.insert("MUSE_LOGIN".into(), "0".into());
tail.extend(["--base-url".to_string(), format!("{relay}/{provider}/v1")]);
if req.auth == AuthMode::Gateway {
// Muse asks for `high` effort by default, which a gateway's open models refuse
// (vLLM takes low, medium and xhigh); medium, as Claude Code gets there
tail.extend(["--reasoning-effort".to_string(), "medium".to_string()]);
}
let path = req.home.join(".config/muse/settings.json");
let mut settings: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_else(|| serde_json::json!({}));
if !settings.is_object() {
settings = serde_json::json!({});
}
settings["schema_version"] = serde_json::json!(1);
settings["mcp_servers"]["memory"] = serde_json::json!({
"transport": "streamable_http",
"url": format!("{relay}/mcp"),
"mode": "required",
"enabled": true
});
settings["telemetry"] = serde_json::json!({ "enabled": false });
write(
&path,
&serde_json::to_string_pretty(&settings).unwrap_or_default(),
)?;
merge_hooks(
at,
&path,
"muse",
&["PreCompact", "SessionEnd", "PreToolUse"],
30,
)
}
/// The pi coding agent keeps its configuration under `~/.pi/agent`: `models.json` for
/// providers of one's own, `mcp.json` for MCP servers, `extensions/` for TypeScript it
/// loads directly. A destination is a provider in `models.json`, named after it, with
/// the egress as `baseUrl`, OpenAI's chat completions as the API and the model named,
/// since pi picks a model from the providers' lists and a provider with no models
/// offers none: a pi run wants `--model`. pi has no hooks; an extension of the
/// launcher's runs the hook binary instead, on `tool_call`, where it may block with a
/// reason the model sees, and on `session_shutdown`; there is no event before
/// compaction. Its sessions are JSON Lines under the agent directory. pi's logins are
/// its own built-in provider's and Anthropic's, which Anthropic prohibits for third-party
/// clients, so a login mode is refused.
fn setup_pi(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
if req.auth == AuthMode::Subscription {
return Err(Error::Unsupported(
"pi has no login the launcher can carry: its Anthropic login is something Anthropic prohibits for third-party clients; use --auth key or --auth gateway with --model".into(),
));
}
let Some(model) = req.model.filter(|m| !m.trim().is_empty()) else {
return Err(Error::Unsupported(
"pi picks a model from its providers' lists, so a run needs --model".into(),
));
};
let agent = req.home.join(".pi/agent");
let models = serde_json::json!({
"providers": {
provider: {
"baseUrl": format!("{relay}/{provider}/v1"),
"api": "openai-completions",
"apiKey": "egress",
"models": [{ "id": model.trim(), "name": model.trim() }]
}
}
});
write(
&agent.join("models.json"),
&serde_json::to_string_pretty(&models).unwrap_or_default(),
)?;
let mcp = serde_json::json!({
"mcpServers": { "memory": { "url": format!("{relay}/mcp") } }
});
write(
&agent.join("mcp.json"),
&serde_json::to_string_pretty(&mcp).unwrap_or_default(),
)?;
let memory = at.memory_literal();
let extension = format!(
r#"// written by amem launch; edits are overwritten. The launcher's hooks, as a pi
// extension: pi has none of its own, so the hook binary is run from here.
import {{ spawnSync }} from "node:child_process";
function hook(event: string, payload: Record<string, unknown>): any {{
const r = spawnSync({memory}, ["hook", "pi"], {{
input: JSON.stringify({{ hook_event_name: event, ...payload }}),
encoding: "utf8",
timeout: 30000,
}});
if (!r.stdout) return null;
try {{ return JSON.parse(r.stdout); }} catch {{ return null; }}
}}
function sessionOf(ctx: any): {{ session_id: string; transcript_path: string | null }} {{
const m = ctx?.sessionManager;
return {{
session_id: typeof m?.getSessionId === "function" ? m.getSessionId() ?? "" : "",
transcript_path: typeof m?.getSessionFile === "function" ? m.getSessionFile() ?? null : null,
}};
}}
export default function (pi: any) {{
pi.on("tool_call", async (e: any, ctx: any) => {{
const out = hook("PreToolUse", {{ ...sessionOf(ctx), tool_name: e.toolName, tool_input: e.input }});
const d = out?.hookSpecificOutput;
if (d && d.permissionDecision === "deny") {{
return {{ block: true, reason: d.permissionDecisionReason || "refused" }};
}}
}});
pi.on("session_shutdown", async (_e: any, ctx: any) => {{
hook("SessionEnd", {{ ...sessionOf(ctx), reason: "exit" }});
}});
}}
"#
);
write(&agent.join("extensions/memory.ts"), &extension)?;
// the egress injects the real key; this one only has to exist
env.insert("OPENAI_API_KEY".into(), "egress".into());
env.insert("PI_SKIP_VERSION_CHECK".into(), "1".into());
env.insert("PI_OFFLINE".into(), "1".into());
Ok(())
}
fn setup_copilot(
req: &Request<'_>,
at: &Layout,
relay: &str,
env: &mut BTreeMap<String, String>,
argv: &mut Vec<String>,
) -> Result<(), Error> {
if req.auth != AuthMode::Subscription {
return Err(Error::Unsupported(
"copilot has no API-key mode; it uses your GitHub login. Use --auth subscription with COPILOT_GITHUB_TOKEN or GH_TOKEN set".into(),
));
}
if !env.contains_key("COPILOT_GITHUB_TOKEN") && !env.contains_key("GH_TOKEN") {
return Err(Error::Unsupported(
"copilot needs COPILOT_GITHUB_TOKEN (a token with only the Copilot Requests permission), or GH_TOKEN, in the launcher's environment"
.into(),
));
}
// its built-in GitHub MCP server would act on GitHub with the token, past the memory
// server and its policy. That is all the flag removes: the token is still in the
// harness's environment, and a shell the model runs can use it through the tunnel to
// GitHub's hosts, so only a token with nothing but Copilot Requests bounds what that
// reaches. Copilot's own stripping of the token from its shells is kept on as well; it
// is a habit, not a boundary, since the process holding the token runs as the same user
argv.push("--disable-builtin-mcps".into());
argv.push("--secret-env-vars=COPILOT_GITHUB_TOKEN,GH_TOKEN".into());
let mcp = serde_json::json!({
"mcpServers": { "memory": { "type": "http", "url": format!("{relay}/mcp") } }
});
write(&req.home.join(".copilot/mcp-config.json"), &mcp.to_string())?;
// hooks in Copilot's own file under COPILOT_HOME, with the PascalCase event names
// so the payload is Claude's, hook_event_name, tool_name and tool_input in Claude's
// spellings, and PreCompact's carries the transcript's path; the timeout is in
// seconds, and a command hook that errors denies
let hook = serde_json::json!([{
"type": "command",
"bash": at.hook("copilot"),
"timeoutSec": 30
}]);
let hooks = serde_json::json!({
"version": 1,
"hooks": { "PreToolUse": hook, "PreCompact": hook, "SessionEnd": hook }
});
write(
&req.home.join(".copilot/hooks/memory.json"),
&serde_json::to_string_pretty(&hooks).unwrap_or_default(),
)?;
env.insert("COPILOT_HOME".into(), at.under_home(".copilot"));
env.insert("COPILOT_AUTO_UPDATE".into(), "false".into());
// no base-url override exists, so its HTTPS goes through the egress as a CONNECT
// tunnel to the hosts the github destination allows
proxy_via(relay, env);
Ok(())
}
/// The program a `command` harness runs: the first harness argument, as given when it is
/// a path, found on PATH otherwise. In the image it is taken as given.
fn program_of(args: &[String], image: bool) -> Result<PathBuf, Error> {
let Some(first) = args.first() else {
return Err(Error::Unsupported(
"--harness command needs the program to run after `--`".into(),
));
};
let p = Path::new(first);
if p.is_absolute() || image {
return Ok(p.to_path_buf());
}
find_on_path(first)
}
/// A program gets the egress and the memory server as environment and nothing else:
/// base URLs for the three API shapes at the destination it was given, a placeholder
/// credential the egress replaces, and the memory server's address.
fn setup_command(
req: &Request<'_>,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) {
env.insert("MEMORY_MCP_URL".into(), format!("{relay}/mcp"));
env.insert("MEMORY_PROVIDER".into(), provider.to_string());
env.insert("OPENAI_BASE_URL".into(), format!("{relay}/{provider}/v1"));
env.insert("ANTHROPIC_BASE_URL".into(), format!("{relay}/{provider}"));
env.insert(
"GOOGLE_GEMINI_BASE_URL".into(),
format!("{relay}/{provider}"),
);
if req.auth != AuthMode::Subscription {
for k in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] {
env.insert(k.into(), "egress".into());
}
}
}
/// OpenCode on a GitHub Copilot subscription, which GitHub supports: the login OpenCode
/// made on the host is copied in, and its HTTPS goes through the egress as a tunnel to
/// GitHub's hosts, as Copilot CLI's does. Pinned egress without metering.
fn setup_opencode_copilot(
req: &Request<'_>,
at: &Layout,
relay: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
copy_own_login(req)?;
proxy_via(relay, env);
let config = serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"mcp": {
"memory": { "type": "remote", "url": format!("{relay}/mcp"), "enabled": true }
}
});
write(
&req.home.join(".config/opencode/opencode.json"),
&serde_json::to_string_pretty(&config).unwrap_or_default(),
)?;
env.insert("XDG_CONFIG_HOME".into(), at.under_home(".config"));
env.insert("XDG_DATA_HOME".into(), at.under_home(".local/share"));
Ok(())
}
/// OpenCode's hooks are a plugin, a module OpenCode loads from its plugin directory,
/// so the launcher writes one that hands the three events to the memory's hook as the
/// other harnesses' hook files do: a tool call before it runs, with the decision's
/// reason thrown to refuse it; compaction before it starts; and the end of a turn,
/// which is OpenCode's nearest thing to a session's end. The transcript is kept from
/// the message events as they stream and written when the turn ends, one turn a line
/// in the shape the tail reader knows, with no call to OpenCode's server on that path:
/// a headless run exits on idle and would not wait for one.
const OPENCODE_PLUGIN: &str = r#"// written by amem launch; edits are overwritten
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const MEMORY = "__MEMORY__";
// one event to the memory's hook: JSON in, a decision or nothing out; a hook that
// cannot run is a refusal, as a server that does not answer is
function hook(payload) {
const run = spawnSync(MEMORY, ["hook", "opencode"], {
input: JSON.stringify(payload),
encoding: "utf8",
env: process.env,
timeout: 30000,
});
if (run.error) return { decision: "deny", reason: `the memory's hook could not run: ${run.error.message}` };
const out = (run.stdout || "").trim();
if (!out) return null;
try { return JSON.parse(out); } catch { return null; }
}
// what each session has said so far, from the events as they stream: a message's role
// from message.updated, each part's text, or the tool it used, from message.part.updated,
// which carries the whole part each time
const sessions = new Map();
function session(id) {
let s = sessions.get(id);
if (!s) { s = { order: [], messages: new Map() }; sessions.set(id, s); }
return s;
}
function message(sessionID, messageID) {
const s = session(sessionID);
let m = s.messages.get(messageID);
if (!m) { m = { role: null, parts: new Map() }; s.messages.set(messageID, m); s.order.push(messageID); }
return m;
}
function remember(event) {
if (event.type === "message.updated") {
const info = event.properties.info;
message(info.sessionID, info.id).role = info.role;
} else if (event.type === "message.part.updated") {
const p = event.properties.part;
const m = message(p.sessionID, p.messageID);
if (p.type === "text") m.parts.set(p.id, p.text || "");
else if (p.type === "tool") m.parts.set(p.id, `[used tool ${p.tool}]`);
}
}
// a session id as a file name: OpenCode's are letters, digits and underscores, and
// anything else becomes one rather than a path
function fileName(sessionID) {
return String(sessionID).replace(/[^A-Za-z0-9_-]/g, "_");
}
// the session's turns, one JSON line each, in the shape the hook's tail reader knows
function transcript(sessionID) {
const dir = join(process.env.HOME || "/home/user", ".local", "share", "opencode", "transcripts");
mkdirSync(dir, { recursive: true });
const file = join(dir, `${fileName(sessionID)}.jsonl`);
const s = session(sessionID);
const lines = s.order.map((id) => {
const m = s.messages.get(id);
const content = [...m.parts.values()].filter(Boolean).join("\n");
return JSON.stringify({ type: m.role || "assistant", message: { content } });
});
writeFileSync(file, lines.length ? lines.join("\n") + "\n" : "");
return file;
}
function note(event, sessionID, extra) {
try {
hook({ hook_event_name: event, session_id: sessionID, transcript_path: transcript(sessionID), ...extra });
} catch (err) {
console.warn(`amem: ${event} note not left: ${err.message}`);
}
}
export const MemoryPlugin = async () => ({
"tool.execute.before": async (input, output) => {
const d = hook({ hook_event_name: "PreToolUse", session_id: input.sessionID, tool_name: input.tool, tool_input: output.args });
if (d && d.decision === "deny") throw new Error(d.reason || "refused by the memory's hook");
},
"experimental.session.compacting": async (input) => {
note("PreCompact", input.sessionID, { trigger: "auto" });
},
event: async ({ event }) => {
remember(event);
if (event.type === "session.idle") note("SessionEnd", event.properties.sessionID, { reason: "idle" });
},
});
"#;
fn setup_opencode(
req: &Request<'_>,
at: &Layout,
relay: &str,
provider: &str,
env: &mut BTreeMap<String, String>,
) -> Result<(), Error> {
write(
&req.home.join(".config/opencode/plugins/memory.js"),
&OPENCODE_PLUGIN.replace("\"__MEMORY__\"", &at.memory_literal()),
)?;
if req.auth == AuthMode::Subscription && provider == "github-copilot" {
return setup_opencode_copilot(req, at, relay, env);
}
if req.auth == AuthMode::Subscription {
return Err(Error::Unsupported(
"opencode's Claude Pro/Max login is something Anthropic prohibits for third-party clients; use --auth key, or --provider github-copilot with your GitHub Copilot login".into(),
));
}
let (npm, key_env) = match provider {
"anthropic" => ("@ai-sdk/anthropic", "ANTHROPIC_API_KEY"),
"openai" => ("@ai-sdk/openai", "OPENAI_API_KEY"),
"xai" => ("@ai-sdk/xai", "XAI_API_KEY"),
_ => ("@ai-sdk/openai-compatible", "DM_EGRESS_TOKEN"),
};
let config = serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"provider": {
provider: {
"npm": npm,
"options": {
"baseURL": format!("{relay}/{provider}/v1"),
"apiKey": "egress"
},
// a model OpenCode's catalog does not know, such as a gateway's, has
// to be declared, since models.dev is out of reach from here
"models": req.model.map_or_else(
|| serde_json::json!({}),
|m| serde_json::json!({ m: { "name": m } })
)
}
},
"mcp": {
"memory": { "type": "remote", "url": format!("{relay}/mcp"), "enabled": true }
}
});
write(
&req.home.join(".config/opencode/opencode.json"),
&serde_json::to_string_pretty(&config).unwrap_or_default(),
)?;
env.insert("XDG_CONFIG_HOME".into(), at.under_home(".config"));
env.insert("XDG_DATA_HOME".into(), at.under_home(".local/share"));
env.insert("XDG_CACHE_HOME".into(), at.under_home(".cache"));
env.insert(key_env.into(), "egress".into());
Ok(())
}
/// Claude Code's global config inside: onboarding done, the working tree trusted, so no
/// dialog blocks a confined session. Merged into an existing file.
fn claude_global_config(path: &Path, workdir: &Path) -> Result<(), Error> {
let mut v: serde_json::Value = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v["hasCompletedOnboarding"] = serde_json::Value::Bool(true);
v["projects"][workdir.display().to_string()]["hasTrustDialogAccepted"] =
serde_json::Value::Bool(true);
write(path, &serde_json::to_string_pretty(&v).unwrap_or_default())
}
/// Run a plan under the backend its layout is for, returning the harness's exit code
/// and what the confined process tree cost the host.
///
/// # Errors
///
/// [`Error::NoBackend`] for a namespaces plan off Linux or a Seatbelt plan off macOS,
/// or a confinement failure.
pub fn run(plan: &Plan) -> Result<(i32, Resources), Error> {
match plan.layout.backend {
Backend::Namespaces => {
#[cfg(target_os = "linux")]
{
linux::run(plan)
}
#[cfg(not(target_os = "linux"))]
{
Err(Error::NoBackend)
}
}
Backend::Seatbelt => {
#[cfg(target_os = "macos")]
{
seatbelt::run(plan)
}
#[cfg(not(target_os = "macos"))]
{
Err(Error::NoBackend)
}
}
}
}
/// Run a plan with the harness's standard output captured, for a non-interactive run
/// whose answer the launcher files. Standard error still reaches the terminal.
///
/// # Errors
///
/// As [`run`].
pub fn run_captured(plan: &Plan) -> Result<(i32, Vec<u8>, Resources), Error> {
match plan.layout.backend {
Backend::Namespaces => {
#[cfg(target_os = "linux")]
{
linux::run_captured(plan)
}
#[cfg(not(target_os = "linux"))]
{
Err(Error::NoBackend)
}
}
Backend::Seatbelt => {
#[cfg(target_os = "macos")]
{
seatbelt::run_captured(plan)
}
#[cfg(not(target_os = "macos"))]
{
Err(Error::NoBackend)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_harness_is_in_the_list() {
// a harness added to the enum and not here stops this compiling
let listed = |h: Harness| match h {
Harness::Claude
| Harness::Codex
| Harness::Gemini
| Harness::Antigravity
| Harness::Qwen
| Harness::Grok
| Harness::Muse
| Harness::Copilot
| Harness::OpenCode
| Harness::Pi
| Harness::Command => Harness::ALL.contains(&h),
};
for h in Harness::ALL {
assert!(listed(h));
}
assert_eq!(Harness::ALL.len(), 11);
assert!(Harness::ALL.contains(&Harness::Pi));
}
/// Tests that change the process's PATH take this, since cargo runs tests in
/// parallel and PATH is one variable for the whole process.
static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn path_lock() -> std::sync::MutexGuard<'static, ()> {
PATH_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct Fixture {
_dir: tempfile::TempDir,
bins: PathBuf,
work: PathBuf,
sock: PathBuf,
memory_bin: PathBuf,
host_home: PathBuf,
root: PathBuf,
}
fn fake_bin(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
p
}
fn fixture() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let bins = dir.path().join("bins");
std::fs::create_dir_all(&bins).unwrap();
for b in [
"claude", "codex", "gemini", "agy", "qwen", "grok", "muse", "pi", "copilot", "opencode",
] {
fake_bin(&bins, b);
}
let memory_bin = fake_bin(&bins, "memory");
let work = dir.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let sock = dir.path().join("egress.sock");
std::fs::write(&sock, "").unwrap();
// a host home holding one login per harness, and a file that must not leak
let host_home = dir.path().join("host-home");
for (rel, text) in [
(
".claude/.credentials.json",
r#"{"claudeAiOauth":{"accessToken":"x"}}"#,
),
(".codex/auth.json", r#"{"tokens":{"access_token":"x"}}"#),
(
".gemini/oauth_creds.json",
r#"{"access_token":"x","refresh_token":"y"}"#,
),
(".ssh/id_ed25519", "private"),
] {
let p = host_home.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, text).unwrap();
}
let root = dir.path().to_path_buf();
Fixture {
_dir: dir,
bins,
work,
sock,
memory_bin,
host_home,
root,
}
}
impl Fixture {
#[allow(clippy::too_many_arguments)] // a test fixture mirroring Request's fields
fn plan_with(
&self,
harness: Harness,
auth: AuthMode,
provider: Option<&str>,
home: &str,
args: &[String],
creds: bool,
harness_env: &[(String, String)],
) -> Result<(PathBuf, Plan), Error> {
self.plan_full(
harness,
auth,
provider,
home,
args,
creds,
harness_env,
None,
None,
)
}
fn plan_model(
&self,
harness: Harness,
auth: AuthMode,
home: &str,
model: &str,
) -> Result<(PathBuf, Plan), Error> {
self.plan_full(
harness,
auth,
None,
home,
&[],
false,
&[],
None,
Some(model),
)
}
#[allow(clippy::too_many_arguments)] // the test's twin of Request
fn plan_full(
&self,
harness: Harness,
auth: AuthMode,
provider: Option<&str>,
home: &str,
args: &[String],
creds: bool,
harness_env: &[(String, String)],
projection: Option<&Path>,
model: Option<&str>,
) -> Result<(PathBuf, Plan), Error> {
let home = self.root.join(home);
let _path = path_lock();
let old = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![self.bins.clone()];
paths.extend(std::env::split_paths(&old));
std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
let r = prepare(&Request {
harness,
auth,
provider,
workdir: &self.work,
home: &home,
egress_socket: &self.sock,
memory_binary: &self.memory_bin,
destination_ceiling: if auth == AuthMode::Subscription {
Label::Internal
} else {
Label::Public
},
harness_args: args,
host_home: creds.then_some(self.host_home.as_path()),
harness_env,
model,
image: false,
workdir_inside: None,
rootfs: None,
projection,
});
std::env::set_var("PATH", old);
r.map(|p| (home, p))
}
fn plan(
&self,
harness: Harness,
auth: AuthMode,
home: &str,
args: &[String],
creds: bool,
) -> Result<(PathBuf, Plan), Error> {
self.plan_with(harness, auth, None, home, args, creds, &[])
}
}
fn json_file(p: &Path) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
}
#[test]
fn claude_plan_env_and_config() {
let f = fixture();
let args = vec!["-p".to_string(), "hello".to_string()];
let (home, plan) = f
.plan(Harness::Claude, AuthMode::Subscription, "h", &args, true)
.unwrap();
assert_eq!(
plan.env["ANTHROPIC_BASE_URL"],
"http://127.0.0.1:4100/anthropic"
);
assert!(
!plan.env.contains_key("ANTHROPIC_AUTH_TOKEN"),
"subscription: the harness's own login"
);
assert_eq!(plan.env["HOME"], INSIDE_HOME);
assert!(
home.join(".claude/.credentials.json").is_file(),
"credential copied in"
);
let mcp = json_file(&home.join(".claude-mcp.json"));
assert_eq!(
mcp["mcpServers"]["memory"]["url"],
"http://127.0.0.1:4100/mcp"
);
let g = json_file(&home.join(".claude.json"));
assert_eq!(
g["projects"][f.work.display().to_string()]["hasTrustDialogAccepted"],
true
);
assert_eq!(plan.argv[0], INSIDE_MEMORY);
assert_eq!(plan.argv[1], "relay");
assert!(plan.argv.contains(&"--strict-mcp-config".to_string()));
assert_eq!(&plan.argv[plan.argv.len() - 2..], &args[..]);
assert_eq!(plan.handling_cap, Label::Internal);
}
#[test]
#[cfg(unix)] // rootfs mode is the Linux backend's; the symlink here is unix-only
fn an_unpacked_image_replaces_the_host_system() {
let f = fixture();
let rootfs = f.root.join("rootfs");
for d in ["usr/local/bin", "etc", "opt/memory", "var"] {
std::fs::create_dir_all(rootfs.join(d)).unwrap();
}
std::os::unix::fs::symlink("usr/bin", rootfs.join("bin")).unwrap();
let home = f.root.join("img-home");
let plan = prepare(&Request {
harness: Harness::Claude,
auth: AuthMode::Key,
provider: None,
workdir: &f.work,
home: &home,
egress_socket: &f.sock,
memory_binary: &f.memory_bin,
destination_ceiling: Label::Public,
harness_args: &[],
host_home: None,
harness_env: &[],
model: None,
image: false,
workdir_inside: None,
rootfs: Some(&rootfs),
projection: None,
})
.unwrap();
let from_rootfs = |t: &str| {
plan.mounts
.iter()
.find(|m| m.target == Path::new(t))
.map(|m| m.source.starts_with(&rootfs) && m.read_only)
};
assert_eq!(from_rootfs("/usr"), Some(true));
assert_eq!(from_rootfs("/etc"), Some(true));
assert_eq!(from_rootfs("/opt"), Some(true), "the image's /opt");
// and over its copy of the binary, the launcher's own, after the rootfs mounts
let opt = plan
.mounts
.iter()
.position(|m| m.target == Path::new("/opt"))
.unwrap();
let own = plan
.mounts
.iter()
.position(|m| m.target == Path::new(INSIDE_MEMORY))
.unwrap();
assert!(own > opt, "{:?}", plan.mounts);
assert_eq!(plan.mounts[own].source, f.memory_bin);
assert!(plan.mounts[own].read_only);
assert_eq!(from_rootfs("/var"), Some(true));
assert!(
!plan.mounts.iter().any(|m| m.source == Path::new("/usr")),
"nothing of the host's system"
);
assert!(plan
.mounts
.iter()
.any(|m| m.target == Path::new(INSIDE_SOCKET)));
assert!(plan.argv.contains(&"/usr/local/bin/claude".to_string()));
assert_eq!(
plan.links,
vec![(PathBuf::from("bin"), PathBuf::from("usr/bin"))],
"merged-usr links come from the rootfs, not the host"
);
}
#[test]
#[cfg(unix)] // rootfs mode is the Linux backend's
fn an_unpacked_image_mounts_the_projection_it_names() {
let f = fixture();
let rootfs = f.root.join("rootfs");
std::fs::create_dir_all(rootfs.join("usr/local/bin")).unwrap();
let rendered = f.root.join(PROJECTION).join("gemini");
std::fs::create_dir_all(&rendered).unwrap();
let home = f.root.join("img-home");
let plan = prepare(&Request {
harness: Harness::Gemini,
auth: AuthMode::Key,
provider: None,
workdir: &f.work,
home: &home,
egress_socket: &f.sock,
memory_binary: &f.memory_bin,
destination_ceiling: Label::Public,
harness_args: &[],
host_home: None,
harness_env: &[],
model: None,
image: false,
workdir_inside: None,
rootfs: Some(&rootfs),
projection: Some(&rendered),
})
.unwrap();
// the environment and Gemini's workspace both name it, so it has to be there:
// Gemini CLI exits on an include directory that does not exist
assert_eq!(plan.env["MEMORY_PROJECTION"], INSIDE_PROJECTION);
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.join(".gemini/settings.json")).unwrap(),
)
.unwrap();
assert_eq!(
settings["context"]["includeDirectories"],
serde_json::json!([INSIDE_PROJECTION])
);
let m = plan
.mounts
.iter()
.find(|m| m.target == Path::new(INSIDE_PROJECTION))
.expect("the projection the plan names is mounted");
assert_eq!(m.source, rendered);
assert!(
m.read_only,
"read-only: the files cannot be edited into lies"
);
}
#[test]
fn a_working_tree_under_the_sandbox_s_own_mounts_is_refused() {
let f = fixture();
let args = vec!["true".to_string()];
let plan_at = |workdir: &str| {
prepare(&Request {
harness: Harness::Command,
auth: AuthMode::Key,
provider: None,
workdir: Path::new(workdir),
home: &f.root.join("h"),
egress_socket: &f.sock,
memory_binary: &f.memory_bin,
destination_ceiling: Label::Public,
harness_args: &args,
host_home: None,
harness_env: &[],
model: None,
image: false,
workdir_inside: None,
rootfs: None,
projection: None,
})
};
for under in [
"/memory",
"/memory/w",
"/opt/memory/w",
"/run/memory/w",
"/home/user",
"/home",
"/opt",
"/run",
"/",
] {
assert!(
matches!(plan_at(under), Err(Error::Unsupported(ref m)) if m.contains("sandbox's own")),
"{under} and the sandbox's own mounts overlap"
);
}
// the home is mounted before the tree, so a tree under it stays; and a sibling
// whose name only starts the same is not under it
for fine in [
"/home/user/proj",
"/home/someone",
"/memoryless/w",
"/opt/memory-tools",
] {
assert!(
!matches!(plan_at(fine), Err(Error::Unsupported(ref m)) if m.contains("sandbox's own")),
"{fine}"
);
}
}
#[test]
fn the_projection_is_mounted_read_only_and_named_when_rendered() {
let f = fixture();
let (_home, plan) = f
.plan(Harness::Claude, AuthMode::Subscription, "h", &[], true)
.unwrap();
assert!(
!plan.env.contains_key("MEMORY_PROJECTION")
&& !plan
.mounts
.iter()
.any(|m| m.target == Path::new(INSIDE_PROJECTION)),
"nothing rendered, nothing mounted"
);
let rendered = f.root.join(PROJECTION).join("claude");
std::fs::create_dir_all(&rendered).unwrap();
let (home, plan) = f
.plan_full(
Harness::Claude,
AuthMode::Subscription,
None,
"h2",
&[],
true,
&[],
Some(&rendered),
None,
)
.unwrap();
let m = plan
.mounts
.iter()
.find(|m| m.target == Path::new(INSIDE_PROJECTION))
.expect("the projection is mounted");
assert!(
m.read_only,
"read-only: the files cannot be edited into lies"
);
assert_eq!(m.source, rendered);
assert!(
!m.source.starts_with(&home),
"the launcher's own directory, not the home the harness writes"
);
assert_eq!(plan.env["MEMORY_PROJECTION"], INSIDE_PROJECTION);
assert_eq!(
plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp",
"the hook needs the server's address to turn an open into a get"
);
let settings = json_file(&home.join(".claude/settings.json"));
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"/opt/memory/memory hook claude"
);
assert_eq!(settings["showThinkingSummaries"], true);
}
#[test]
fn claude_plan_mounts() {
let f = fixture();
let (_home, plan) = f
.plan(Harness::Claude, AuthMode::Subscription, "h", &[], true)
.unwrap();
let targets: Vec<&Path> = plan.mounts.iter().map(|m| m.target.as_path()).collect();
assert!(targets.contains(&Path::new(INSIDE_HOME)));
assert!(targets.contains(&f.work.as_path()));
assert!(targets.contains(&Path::new(INSIDE_SOCKET)));
assert!(targets.contains(&Path::new(INSIDE_MEMORY)));
#[cfg(unix)]
assert!(plan
.mounts
.iter()
.any(|m| m.target == Path::new("/usr") && m.read_only));
assert!(!plan
.mounts
.iter()
.any(|m| m.target.starts_with("/etc") && !m.read_only));
assert!(
!targets
.iter()
.any(|t| t.to_string_lossy().contains(".claude")),
"the host's ~/.claude is never mounted"
);
assert!(!Path::new(INSIDE_MEMORY).starts_with("/usr"));
// the plan canonicalizes the harness's directory; on Windows that adds a prefix
let bins = std::fs::canonicalize(&f.bins).unwrap();
assert!(
plan.env["PATH"].starts_with(&format!("{}:", bins.display())),
"{}",
plan.env["PATH"]
);
}
#[test]
fn key_mode_gives_a_placeholder_credential() {
let f = fixture();
let (_home, plan) = f
.plan(Harness::Claude, AuthMode::Key, "h1", &[], false)
.unwrap();
assert_eq!(
plan.env["ANTHROPIC_AUTH_TOKEN"], "egress",
"a placeholder the egress replaces"
);
assert_eq!(plan.handling_cap, Label::Public);
let (_home, plan) = f
.plan(Harness::Claude, AuthMode::Gateway, "h2", &[], false)
.unwrap();
assert_eq!(plan.env["ANTHROPIC_AUTH_TOKEN"], "egress");
}
#[test]
fn claude_headless_asks_for_its_thinking() {
let headless = Harness::Claude.headless("hi");
assert!(
headless
.windows(2)
.any(|w| w == ["--thinking-display", "summarized"]),
"headless, Claude Code omits its thinking unless asked"
);
}
#[test]
fn codex_keeps_the_reasoning_an_open_model_returns() {
let f = fixture();
let (home, _plan) = f
.plan(Harness::Codex, AuthMode::Gateway, "c1", &[], false)
.unwrap();
let cfg = std::fs::read_to_string(home.join(".codex/config.toml")).unwrap();
assert!(cfg.contains("show_raw_agent_reasoning = true"), "{cfg}");
}
#[test]
fn codex_config_and_provider() {
let f = fixture();
let (home, plan) = f
.plan(Harness::Codex, AuthMode::Gateway, "c1", &[], false)
.unwrap();
let cfg = std::fs::read_to_string(home.join(".codex/config.toml")).unwrap();
assert!(
cfg.contains("base_url = \"http://127.0.0.1:4100/openai/v1\""),
"{cfg}"
);
assert!(cfg.contains("[mcp_servers.memory]"));
assert_eq!(plan.env["CODEX_HOME"], "/home/user/.codex");
let hooks = json_file(&home.join(".codex/hooks.json"));
assert_eq!(
hooks["hooks"]["SessionEnd"][0]["hooks"][0]["command"],
"/opt/memory/memory hook codex"
);
assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
assert!(hooks["hooks"]["PreCompact"].is_array());
// the projection's guard, with the time a tool hook may take rather than the
// three seconds Codex gives a session's end
assert_eq!(
hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"/opt/memory/memory hook codex"
);
assert_eq!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["timeout"], 30);
assert_eq!(plan.env["MEMORY_OUTBOX"], "/home/user/.memory-outbox");
assert_eq!(plan.env["DM_EGRESS_TOKEN"], "egress");
let (home, _) = f
.plan_with(
Harness::Codex,
AuthMode::Key,
Some("xai"),
"c2",
&[],
false,
&[],
)
.unwrap();
let cfg = std::fs::read_to_string(home.join(".codex/config.toml")).unwrap();
assert!(
cfg.contains("base_url = \"http://127.0.0.1:4100/xai/v1\""),
"grok through the xai destination: {cfg}"
);
assert!(
matches!(
f.plan_with(
Harness::Claude,
AuthMode::Key,
Some("xai"),
"c4",
&[],
false,
&[]
),
Err(Error::Unsupported(_))
),
"claude does not take a provider"
);
assert!(
f.plan_with(
Harness::Claude,
AuthMode::Key,
Some("anthropic"),
"c5",
&[],
false,
&[]
)
.is_ok(),
"naming the harness's own provider is allowed"
);
}
#[test]
fn codex_subscription_moves_the_chatgpt_backend_to_the_egress() {
let f = fixture();
// without a host login the subscription mode is refused; with one, the ChatGPT
// backend moves to the egress and the login file is copied in
assert!(matches!(
f.plan(Harness::Codex, AuthMode::Subscription, "c3", &[], false),
Err(Error::Unsupported(_))
));
let (home, plan) = f
.plan(Harness::Codex, AuthMode::Subscription, "c5", &[], true)
.unwrap();
let cfg = std::fs::read_to_string(home.join(".codex/config.toml")).unwrap();
assert!(
!cfg.contains("chatgpt_base_url"),
"the backend is reached through the tunnel: {cfg}"
);
assert!(
!cfg.contains("model_provider"),
"the built-in provider stays: {cfg}"
);
assert!(home.join(".codex/auth.json").is_file(), "login copied in");
assert!(
!home.join(".ssh").exists(),
"nothing else from the host home"
);
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
assert!(!plan.env.contains_key("DM_EGRESS_TOKEN"));
}
#[test]
fn gemini_settings_and_env() {
let f = fixture();
let (home, plan) = f
.plan(Harness::Gemini, AuthMode::Key, "g1", &[], false)
.unwrap();
let s = json_file(&home.join(".gemini/settings.json"));
assert_eq!(s["security"]["auth"]["selectedType"], "gemini-api-key");
assert_eq!(
s["mcpServers"]["memory"]["httpUrl"],
"http://127.0.0.1:4100/mcp"
);
assert_eq!(s["general"]["enableAutoUpdate"], false);
assert_eq!(
s["hooks"]["PreCompress"][0]["hooks"][0]["command"],
"/opt/memory/memory hook gemini"
);
assert!(s["hooks"]["SessionEnd"].is_array());
assert_eq!(
s["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 10_000,
"milliseconds, for Gemini"
);
// Gemini CLI's name for the event before a tool runs; it waits ten seconds on
// the memory server, so its own timeout must be longer, or a stalled server
// is a read let through, since Gemini continues when a hook is killed
assert_eq!(
s["hooks"]["BeforeTool"][0]["hooks"][0]["command"],
"/opt/memory/memory hook gemini"
);
assert_eq!(s["hooks"]["BeforeTool"][0]["hooks"][0]["timeout"], 30_000);
assert!(
s.get("context").is_none(),
"no projection, no directory to include"
);
assert_eq!(
plan.env["GOOGLE_GEMINI_BASE_URL"],
"http://127.0.0.1:4100/gemini"
);
assert_eq!(plan.env["GEMINI_API_KEY"], "egress");
assert_eq!(plan.env["GEMINI_CLI_TRUST_WORKSPACE"], "true");
assert!(matches!(
f.plan(Harness::Gemini, AuthMode::Subscription, "g2", &[], false),
Err(Error::Unsupported(_))
));
}
#[test]
fn gemini_subscription_moves_code_assist_to_the_egress() {
let f = fixture();
// a Google login: Code Assist moves to the egress, refresh goes through the tunnel
let (home, plan) = f
.plan(Harness::Gemini, AuthMode::Subscription, "g3", &[], true)
.unwrap();
let s = json_file(&home.join(".gemini/settings.json"));
assert_eq!(s["security"]["auth"]["selectedType"], "oauth-personal");
assert!(
home.join(".gemini/oauth_creds.json").is_file(),
"login copied in"
);
assert_eq!(
plan.env["CODE_ASSIST_ENDPOINT"],
"http://127.0.0.1:4100/google"
);
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
assert!(!plan.env.contains_key("GEMINI_API_KEY"));
assert!(!plan.env.contains_key("GOOGLE_GEMINI_BASE_URL"));
}
#[test]
#[cfg(unix)] // the program under test is the shell, which Windows runners lack
fn a_command_harness_runs_a_program_with_the_egress_in_its_environment() {
let f = fixture();
let args = vec!["sh".to_string(), "-c".to_string(), "echo hi".to_string()];
let (_home, plan) = f
.plan_with(
Harness::Command,
AuthMode::Key,
Some("openai"),
"cmd1",
&args,
false,
&[],
)
.unwrap();
// `sh` resolves to whatever the host's shell is, dash on Debian and Ubuntu
let sh_path = find_on_path("sh").unwrap().display().to_string();
let sh = plan.argv.iter().position(|a| *a == sh_path).unwrap();
assert_eq!(&plan.argv[sh + 1..], &["-c", "echo hi"]);
assert_eq!(plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp");
assert_eq!(
plan.env["OPENAI_BASE_URL"],
"http://127.0.0.1:4100/openai/v1"
);
assert_eq!(plan.env["OPENAI_API_KEY"], "egress");
assert!(matches!(
f.plan(Harness::Command, AuthMode::Key, "cmd2", &[], false),
Err(Error::Unsupported(_))
));
let gw = f
.plan_with(
Harness::Command,
AuthMode::Gateway,
None,
"cmd3",
&["/usr/bin/python3".to_string(), "agent.py".to_string()],
false,
&[],
)
.unwrap()
.1;
assert!(gw.argv.contains(&"/usr/bin/python3".to_string()));
assert_eq!(gw.env["MEMORY_PROVIDER"], "openai");
}
#[test]
fn opencode_on_a_copilot_login_tunnels_like_copilot_cli() {
let f = fixture();
std::fs::create_dir_all(f.host_home.join(".local/share/opencode")).unwrap();
std::fs::write(
f.host_home.join(".local/share/opencode/auth.json"),
r#"{"github-copilot":{"type":"oauth"}}"#,
)
.unwrap();
let (home, plan) = f
.plan_with(
Harness::OpenCode,
AuthMode::Subscription,
Some("github-copilot"),
"oc1",
&[],
true,
&[],
)
.unwrap();
assert!(home.join(".local/share/opencode/auth.json").is_file());
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
let c = json_file(&home.join(".config/opencode/opencode.json"));
assert!(
c.get("provider").is_none(),
"no base-URL override: the tunnel carries it"
);
assert_eq!(c["mcp"]["memory"]["type"], "remote");
assert!(matches!(
f.plan(Harness::OpenCode, AuthMode::Subscription, "oc2", &[], true),
Err(Error::Unsupported(_))
));
}
/// Hooks in Copilot's own file under COPILOT_HOME, with the PascalCase events so the
/// payload is Claude's; a command hook that errors denies the call, and the timeout
/// is in seconds.
fn assert_copilot_hooks(home: &Path) {
let hooks = json_file(&home.join(".copilot/hooks/memory.json"));
assert_eq!(hooks["version"], 1);
for event in ["PreToolUse", "PreCompact", "SessionEnd"] {
let h = &hooks["hooks"][event][0];
assert_eq!(h["type"], "command", "{event}");
assert_eq!(h["bash"], "/opt/memory/memory hook copilot", "{event}");
assert_eq!(h["timeoutSec"], 30, "{event}");
}
}
/// OpenCode has no hook files: a plugin in the global plugin directory under the
/// private home runs the memory's hook for the three events, and parses as a module
/// where a node is on the PATH to say so.
fn assert_opencode_plugin(home: &Path) {
let path = home.join(".config/opencode/plugins/memory.js");
let plugin = std::fs::read_to_string(&path).unwrap();
for needle in [
"\"/opt/memory/memory\"",
"\"opencode\"",
"\"tool.execute.before\"",
"\"experimental.session.compacting\"",
"\"session.idle\"",
"PreToolUse",
"PreCompact",
"SessionEnd",
] {
assert!(plugin.contains(needle), "{needle} in\n{plugin}");
}
// driving it takes a fake amem binary, which is a shell script
#[cfg(unix)]
if let Ok(node) = find_on_path("node") {
drive_opencode_plugin(&node, &plugin);
}
}
/// The plugin under a node, against a fake amem binary that records what it is
/// sent and refuses a tool call: a denial throws its reason; the turns arrive as
/// events and the idle event writes the transcript and leaves the session-end note
/// without awaiting anything, since OpenCode's run exits on idle; a session id is a
/// file name, not a path.
#[cfg(unix)]
fn drive_opencode_plugin(node: &Path, plugin: &str) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("memory");
let record = dir.path().join("record");
std::fs::write(
&fake,
format!(
"#!/bin/sh\nin=$(cat)\nprintf '%s\\n---\\n' \"$in\" >> {}\ncase \"$in\" in *PreToolUse*) printf '%s' '{{\"decision\":\"deny\",\"reason\":\"no: projection\"}}';; esac\n",
record.display()
),
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let module = dir.path().join("memory.mjs");
std::fs::write(
&module,
plugin.replace(INSIDE_MEMORY, &fake.display().to_string()),
)
.unwrap();
let harness = dir.path().join("drive.mjs");
std::fs::write(&harness, OPENCODE_HARNESS).unwrap();
let out = std::process::Command::new(node)
.arg(&harness)
.arg(&module)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
out.status.success(),
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let transcript = std::fs::read_to_string(
dir.path()
.join(".local/share/opencode/transcripts/ses_abc.jsonl"),
)
.unwrap();
assert_eq!(
transcript,
"{\"type\":\"user\",\"message\":{\"content\":\"Load T0.\"}}\n\
{\"type\":\"assistant\",\"message\":{\"content\":\"Loading.\\n[used tool mcp__memory__t0]\"}}\n"
);
let sent = std::fs::read_to_string(&record).unwrap();
assert!(
sent.contains("\"hook_event_name\":\"PreToolUse\"")
&& sent.contains("\"tool_input\":{\"filePath\":\"/memory/x\"}"),
"{sent}"
);
assert!(
sent.contains("\"hook_event_name\":\"SessionEnd\"")
&& sent.contains("\"session_id\":\"ses_abc\"")
&& sent.contains("transcripts/ses_abc.jsonl"),
"{sent}"
);
assert!(
sent.contains("\"hook_event_name\":\"PreCompact\""),
"{sent}"
);
assert!(
!dir.path().join(".local/share/evil.jsonl").exists()
&& !dir.path().join(".local/share/opencode/evil.jsonl").exists(),
"a session id walked out of the transcripts directory"
);
}
/// Drives the plugin: a denied tool call, the events of one turn, a compaction and
/// an idle, with a client whose messages call must never be awaited.
#[cfg(unix)]
const OPENCODE_HARNESS: &str = r#"
const [plugin] = process.argv.slice(2);
const mod = await import(plugin);
const client = { session: { messages: async () => { throw new Error("awaited the client on the idle path"); } } };
const hooks = await mod.MemoryPlugin({ client });
let threw = null;
try {
await hooks["tool.execute.before"]({ tool: "read", sessionID: "ses_abc", callID: "c1" }, { args: { filePath: "/memory/x" } });
} catch (e) { threw = e.message; }
if (threw !== "no: projection") { console.error(`deny not thrown: ${threw}`); process.exit(1); }
const ev = (type, properties) => hooks.event({ event: { type, properties } });
await ev("message.updated", { info: { id: "m1", sessionID: "ses_abc", role: "user" } });
await ev("message.part.updated", { part: { id: "p1", messageID: "m1", sessionID: "ses_abc", type: "text", text: "Load" } });
await ev("message.part.updated", { part: { id: "p1", messageID: "m1", sessionID: "ses_abc", type: "text", text: "Load T0." } });
await ev("message.updated", { info: { id: "m2", sessionID: "ses_abc", role: "assistant" } });
await ev("message.part.updated", { part: { id: "p2", messageID: "m2", sessionID: "ses_abc", type: "text", text: "Loading." } });
await ev("message.part.updated", { part: { id: "p3", messageID: "m2", sessionID: "ses_abc", type: "tool", tool: "mcp__memory__t0" } });
await hooks["experimental.session.compacting"]({ sessionID: "ses_abc" }, { context: [] });
await ev("session.idle", { sessionID: "ses_abc" });
await ev("session.idle", { sessionID: "../../evil" });
"#;
#[test]
fn copilot_tunnels_through_the_egress_with_its_github_login() {
let f = fixture();
let token = vec![("GH_TOKEN".to_string(), "gho_test".to_string())];
let (home, plan) = f
.plan_with(
Harness::Copilot,
AuthMode::Subscription,
None,
"p1",
&[],
false,
&token,
)
.unwrap();
let mcp = json_file(&home.join(".copilot/mcp-config.json"));
assert_eq!(mcp["mcpServers"]["memory"]["type"], "http");
assert_eq!(plan.env["COPILOT_HOME"], "/home/user/.copilot");
assert_copilot_hooks(&home);
assert_eq!(plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp");
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
assert_eq!(plan.env["NO_PROXY"], "127.0.0.1,localhost");
assert_eq!(
plan.env["GH_TOKEN"], "gho_test",
"the harness holds its own login"
);
// its own GitHub MCP server would act with that login, outside every grant
assert!(
plan.argv.iter().any(|a| a == "--disable-builtin-mcps"),
"{:?}",
plan.argv
);
assert!(
plan.argv
.iter()
.any(|a| a == "--secret-env-vars=COPILOT_GITHUB_TOKEN,GH_TOKEN"),
"{:?}",
plan.argv
);
// a token for Copilot alone serves in place of the login, and the login in the
// launcher's own environment does not follow it in
std::env::set_var("GH_TOKEN", "gho_launcher");
let copilot_only = vec![(
"COPILOT_GITHUB_TOKEN".to_string(),
"github_pat_test".to_string(),
)];
let (_, plan) = f
.plan_with(
Harness::Copilot,
AuthMode::Subscription,
None,
"p4",
&[],
false,
&copilot_only,
)
.unwrap();
std::env::remove_var("GH_TOKEN");
assert_eq!(plan.env["COPILOT_GITHUB_TOKEN"], "github_pat_test");
assert!(
!plan.env.values().any(|v| v == "gho_launcher"),
"{:?}",
plan.env
);
assert!(
matches!(
f.plan_with(
Harness::Copilot,
AuthMode::Subscription,
None,
"p2",
&[],
false,
&[]
),
Err(Error::Unsupported(_))
),
"no token, no launch"
);
assert!(matches!(
f.plan(Harness::Copilot, AuthMode::Key, "p3", &[], false),
Err(Error::Unsupported(_))
));
}
#[test]
fn opencode_config_follows_the_provider() {
let f = fixture();
let (home, plan) = f
.plan(Harness::OpenCode, AuthMode::Key, "o1", &[], false)
.unwrap();
let c = json_file(&home.join(".config/opencode/opencode.json"));
assert_eq!(
c["provider"]["anthropic"]["options"]["baseURL"],
"http://127.0.0.1:4100/anthropic/v1"
);
assert_eq!(c["provider"]["anthropic"]["options"]["apiKey"], "egress");
assert_eq!(c["mcp"]["memory"]["type"], "remote");
assert_eq!(c["mcp"]["memory"]["url"], "http://127.0.0.1:4100/mcp");
assert_eq!(c["autoupdate"], false);
assert_eq!(plan.env["XDG_CONFIG_HOME"], "/home/user/.config");
assert_eq!(plan.env["ANTHROPIC_API_KEY"], "egress");
assert_opencode_plugin(&home);
let (home, plan) = f
.plan_with(
Harness::OpenCode,
AuthMode::Key,
Some("xai"),
"o2",
&[],
false,
&[],
)
.unwrap();
let c = json_file(&home.join(".config/opencode/opencode.json"));
assert_eq!(
c["provider"]["xai"]["options"]["baseURL"],
"http://127.0.0.1:4100/xai/v1"
);
assert_eq!(plan.env["XAI_API_KEY"], "egress");
assert!(matches!(
f.plan(Harness::OpenCode, AuthMode::Subscription, "o3", &[], false),
Err(Error::Unsupported(_))
));
}
#[test]
fn node_installed_harnesses_mount_their_prefix() {
let (m, path) = harness_mounts(Path::new(
"/home/u/.local/node/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
));
assert_eq!(m.len(), 1);
assert_eq!(m[0].source, Path::new("/home/u/.local/node"));
assert!(m[0].read_only);
assert_eq!(path.as_deref(), Some(Path::new("/home/u/.local/node/bin")));
let (m, path) = harness_mounts(Path::new("/usr/bin/claude"));
assert!(m.is_empty());
assert!(path.is_none());
let (m, _) = harness_mounts(Path::new("/home/u/.local/bin/codex"));
assert_eq!(m[0].source, Path::new("/home/u/.local/bin"));
}
#[test]
fn a_missing_harness_is_a_clear_error() {
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
let _path = path_lock();
let old = std::env::var_os("PATH").unwrap_or_default();
std::env::set_var("PATH", &empty);
let r = prepare(&Request {
projection: None,
harness: Harness::Codex,
auth: AuthMode::Key,
provider: None,
workdir: dir.path(),
home: &dir.path().join("h"),
egress_socket: dir.path(),
memory_binary: dir.path(),
destination_ceiling: Label::Public,
harness_args: &[],
host_home: None,
image: false,
workdir_inside: None,
rootfs: None,
harness_env: &[],
model: None,
});
std::env::set_var("PATH", old);
assert!(matches!(r, Err(Error::NoHarness(_))));
}
/// agy's hooks file: a named group, tool events in matcher groups, loop events flat.
fn assert_agy_hooks(home: &Path) {
let hooks = json_file(&home.join(".gemini/config/hooks.json"));
let group = &hooks["memory"];
assert_eq!(
group["Stop"][0]["command"], "/opt/memory/memory hook antigravity",
"a loop event is a flat list of handlers"
);
assert_eq!(group["Stop"][0]["type"], "command");
assert_eq!(group["PreToolUse"][0]["matcher"], "*");
assert_eq!(
group["PreToolUse"][0]["hooks"][0]["command"], "/opt/memory/memory hook antigravity",
"a tool event is a matcher group"
);
assert_eq!(group["PreToolUse"][0]["hooks"][0]["timeout"], 30);
assert!(
hooks.get("Stop").is_none(),
"events sit under a named group"
);
assert!(
group.get("SessionEnd").is_none(),
"agy's session end is Stop"
);
}
#[test]
fn antigravity_plan_key_and_login() {
let f = fixture();
let (home, plan) = f
.plan(Harness::Antigravity, AuthMode::Key, "a1", &[], false)
.unwrap();
assert_eq!(
plan.env["GOOGLE_GEMINI_BASE_URL"],
"http://127.0.0.1:4100/gemini"
);
assert_eq!(plan.env["GEMINI_API_KEY"], "egress");
assert_eq!(plan.env["AGY_CLI_DISABLE_AUTO_UPDATE"], "true");
assert!(
!plan.env.contains_key("HTTPS_PROXY"),
"a key needs no tunnel"
);
let settings = json_file(&home.join(".gemini/antigravity-cli/settings.json"));
assert_eq!(
settings["modelProvider"], "gemini",
"the key is read only when the settings name the Gemini API"
);
let mcp = json_file(&home.join(".gemini/config/mcp_config.json"));
assert_eq!(
mcp["mcpServers"]["memory"]["serverUrl"],
"http://127.0.0.1:4100/mcp"
);
assert!(
mcp["mcpServers"]["memory"].get("url").is_none(),
"serverUrl, not url"
);
assert_agy_hooks(&home);
assert_eq!(plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp");
assert!(
plan.argv.iter().any(|a| a.ends_with("agy")),
"{:?}",
plan.argv
);
}
#[test]
fn antigravity_plan_login_and_shape() {
let f = fixture();
// a Google login needs the token file the host's login left
assert!(matches!(
f.plan(
Harness::Antigravity,
AuthMode::Subscription,
"a2",
&[],
false
),
Err(Error::Unsupported(_))
));
std::fs::create_dir_all(f.host_home.join(".gemini/antigravity-cli")).unwrap();
std::fs::write(
f.host_home
.join(".gemini/antigravity-cli/antigravity-oauth-token"),
"tok",
)
.unwrap();
let (home, plan) = f
.plan(
Harness::Antigravity,
AuthMode::Subscription,
"a3",
&[],
true,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(home.join(".gemini/antigravity-cli/antigravity-oauth-token"))
.unwrap(),
"tok"
);
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
assert!(
!plan.env.contains_key("CODE_ASSIST_ENDPOINT"),
"Gemini CLI's variable; nothing shows agy reads it"
);
assert!(!plan.env.contains_key("GEMINI_API_KEY"));
let settings = json_file(&home.join(".gemini/antigravity-cli/settings.json"));
assert!(settings.get("modelProvider").is_none());
let headless = Harness::Antigravity.headless("hi");
assert_eq!(headless[0], "-p");
assert!(headless.contains(&"--dangerously-skip-permissions".to_string()));
assert!(headless.windows(2).any(|w| w == ["--mode", "accept-edits"]));
assert_eq!(Harness::Antigravity.model_flag("m"), vec!["--model", "m"]);
assert_eq!(Harness::Antigravity.provider(), "gemini");
assert_eq!(Harness::Antigravity.subscription_provider(), "google");
assert!(!Harness::Antigravity.accepts_provider());
}
#[test]
fn gemini_and_qwen_are_told_the_projection_is_theirs_to_read() {
// both refuse their file tools a path outside the workspace, so the model would
// fall back to a shell the hook does not see; the projection is included
let f = fixture();
for (harness, settings) in [
(Harness::Gemini, ".gemini/settings.json"),
(Harness::Qwen, ".qwen/settings.json"),
] {
let rendered = f.root.join(PROJECTION).join(harness.binary());
std::fs::create_dir_all(&rendered).unwrap();
let (home, plan) = f
.plan_full(
harness,
AuthMode::Key,
None,
harness.binary(),
&[],
false,
&[],
Some(&rendered),
None,
)
.unwrap();
assert_eq!(plan.env["MEMORY_PROJECTION"], INSIDE_PROJECTION);
let s = json_file(&home.join(settings));
assert_eq!(
s["context"]["includeDirectories"],
serde_json::json!([INSIDE_PROJECTION]),
"{}",
harness.binary()
);
}
}
#[test]
fn muse_plan_key_points_exec_at_the_egress_after_the_prompt() {
let f = fixture();
let args = Harness::Muse.headless("hi");
let (home, plan) = f
.plan(Harness::Muse, AuthMode::Key, "k1", &args, false)
.unwrap();
assert_eq!(plan.env["META_API_KEY"], "egress");
assert_eq!(plan.env["MUSE_NO_AUTO_UPDATE"], "1");
assert!(
!plan.env.contains_key("HTTPS_PROXY"),
"a key needs no tunnel"
);
// only exec takes --base-url, so it follows the prompt, where exec accepts it
let argv = &plan.argv;
let exec = argv.iter().position(|a| a == "exec").unwrap();
let base = argv.iter().position(|a| a == "--base-url").unwrap();
assert!(exec < base, "{argv:?}");
assert_eq!(argv[base + 1], "http://127.0.0.1:4100/meta/v1");
assert!(argv[exec..base].iter().any(|a| a == "hi"), "{argv:?}");
assert!(
!argv.iter().any(|a| a == "--reasoning-effort"),
"Meta's own models take Muse's default effort"
);
let (_, gw) = f
.plan_with(
Harness::Muse,
AuthMode::Gateway,
Some("llmao"),
"k1g",
&args,
false,
&[],
)
.unwrap();
let at = gw
.argv
.iter()
.position(|a| a == "--reasoning-effort")
.unwrap();
assert_eq!(gw.argv[at + 1], "medium");
assert_eq!(
Harness::Muse.headless("hi"),
vec!["exec", "--yolo", "--json", "--disable-reminders", "hi"]
);
assert_eq!(Harness::Muse.model_flag("m"), vec!["--model", "m"]);
assert_eq!(Harness::Muse.provider(), "meta");
assert_eq!(Harness::Muse.subscription_provider(), "meta");
assert!(!Harness::Muse.accepts_provider());
drop(home);
}
#[test]
fn muse_settings_carry_the_schema_version_the_mcp_server_and_the_hooks() {
let f = fixture();
let (home, _) = f
.plan(Harness::Muse, AuthMode::Key, "k1", &[], false)
.unwrap();
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.join(".config/muse/settings.json")).unwrap(),
)
.unwrap();
assert_eq!(settings["schema_version"], 1);
assert_eq!(
settings["mcp_servers"]["memory"]["transport"],
"streamable_http"
);
assert_eq!(
settings["mcp_servers"]["memory"]["url"],
"http://127.0.0.1:4100/mcp"
);
for e in ["PreCompact", "SessionEnd", "PreToolUse"] {
assert_eq!(
settings["hooks"][e][0]["hooks"][0]["command"], "/opt/memory/memory hook muse",
"{e}"
);
}
}
#[test]
fn muse_plan_login_is_the_auth_file_forwarded() {
let f = fixture();
assert!(matches!(
f.plan(Harness::Muse, AuthMode::Subscription, "k2", &[], false),
Err(Error::Unsupported(_))
));
std::fs::create_dir_all(f.host_home.join(".config/muse")).unwrap();
std::fs::write(
f.host_home.join(".config/muse/auth.json"),
r#"{"session":"x"}"#,
)
.unwrap();
let (home, plan) = f
.plan(Harness::Muse, AuthMode::Subscription, "k3", &[], true)
.unwrap();
assert!(
home.join(".config/muse/auth.json").is_file(),
"login copied in"
);
assert!(
!plan.env.contains_key("META_API_KEY"),
"a key would outrank the login"
);
assert!(plan
.argv
.iter()
.any(|a| a == "http://127.0.0.1:4100/meta/v1"));
}
#[test]
fn pi_refuses_a_run_without_a_model_and_a_login() {
let f = fixture();
// no model, no provider list to pick from
assert!(matches!(
f.plan(Harness::Pi, AuthMode::Key, "p0", &[], false),
Err(Error::Unsupported(_))
));
assert!(matches!(
f.plan(Harness::Pi, AuthMode::Subscription, "p0", &[], false),
Err(Error::Unsupported(_))
));
assert_eq!(Harness::Pi.headless("hi"), vec!["-p", "-na", "hi"]);
assert_eq!(Harness::Pi.model_flag("m"), vec!["--model", "m"]);
assert_eq!(Harness::Pi.provider(), "openai");
assert!(Harness::Pi.accepts_provider());
}
#[test]
fn pi_plan_declares_the_destination_as_a_provider_with_the_model_named() {
let f = fixture();
let (home, plan) = f
.plan_model(Harness::Pi, AuthMode::Key, "p1", "gpt-5-mini")
.unwrap();
let models = json_file(&home.join(".pi/agent/models.json"));
assert_eq!(
models["providers"]["openai"]["baseUrl"],
"http://127.0.0.1:4100/openai/v1"
);
assert_eq!(models["providers"]["openai"]["api"], "openai-completions");
assert_eq!(
models["providers"]["openai"]["models"][0]["id"],
"gpt-5-mini"
);
let mcp = json_file(&home.join(".pi/agent/mcp.json"));
assert_eq!(
mcp["mcpServers"]["memory"]["url"],
"http://127.0.0.1:4100/mcp"
);
let ext = std::fs::read_to_string(home.join(".pi/agent/extensions/memory.ts")).unwrap();
assert!(
ext.contains("\"/opt/memory/memory\", [\"hook\", \"pi\"]"),
"{ext}"
);
assert!(ext.contains("tool_call") && ext.contains("session_shutdown"));
assert_eq!(plan.env["OPENAI_API_KEY"], "egress");
assert_eq!(plan.env["PI_OFFLINE"], "1");
}
#[test]
fn grok_plan_key_points_the_api_at_the_egress() {
let f = fixture();
let (home, plan) = f
.plan(Harness::Grok, AuthMode::Key, "k1", &[], false)
.unwrap();
// only API-key inference reads this base; the egress holds the real key
assert_eq!(
plan.env["GROK_XAI_API_BASE_URL"],
"http://127.0.0.1:4100/xai/v1"
);
assert_eq!(plan.env["XAI_API_KEY"], "egress");
assert!(
!plan.env.contains_key("HTTPS_PROXY"),
"a key needs no tunnel"
);
let cfg = std::fs::read_to_string(home.join(".grok/config.toml")).unwrap();
for line in [
"[mcp_servers.memory]",
"url = \"http://127.0.0.1:4100/mcp\"",
"auto_update = false",
"telemetry = false",
"[[hooks.PreToolUse]]",
"[[hooks.SessionEnd]]",
"[[hooks.PreCompact]]",
] {
assert!(cfg.contains(line), "{line} in\n{cfg}");
}
assert_eq!(
cfg.matches("command = \"/opt/memory/memory hook grok\", timeout = 30")
.count(),
3,
"{cfg}"
);
assert!(
!cfg.contains("matcher"),
"no matcher: an absent one selects every tool"
);
assert_eq!(plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp");
assert!(
plan.argv.iter().any(|a| a.ends_with("grok")),
"{:?}",
plan.argv
);
assert_eq!(Harness::Grok.headless("hi"), vec!["-p", "hi", "--yolo"]);
assert_eq!(Harness::Grok.model_flag("m"), vec!["-m", "m"]);
assert_eq!(Harness::Grok.provider(), "xai");
assert_eq!(Harness::Grok.subscription_provider(), "grok");
assert!(!Harness::Grok.accepts_provider());
}
#[test]
fn grok_plan_declares_a_model_of_the_destination_s_own() {
let f = fixture();
let (home, _) = f
.plan_model(Harness::Grok, AuthMode::Key, "k4", "qwen3.8-27b")
.unwrap();
let cfg = std::fs::read_to_string(home.join(".grok/config.toml")).unwrap();
for line in [
"[model.\"qwen3.8-27b\"]",
"model = \"qwen3.8-27b\"",
"base_url = \"http://127.0.0.1:4100/xai/v1\"",
"api_backend = \"chat_completions\"",
] {
assert!(cfg.contains(line), "{line} in\n{cfg}");
}
}
#[test]
fn grok_plan_login_is_the_auth_file_behind_a_tunnel() {
let f = fixture();
// a login is the token file `grok login` left; without it, nothing to copy
assert!(matches!(
f.plan(Harness::Grok, AuthMode::Subscription, "k2", &[], false),
Err(Error::Unsupported(_))
));
std::fs::create_dir_all(f.host_home.join(".grok")).unwrap();
std::fs::write(
f.host_home.join(".grok/auth.json"),
r#"{"access_token":"x"}"#,
)
.unwrap();
let (home, plan) = f
.plan(Harness::Grok, AuthMode::Subscription, "k3", &[], true)
.unwrap();
assert!(home.join(".grok/auth.json").is_file(), "login copied in");
assert_eq!(plan.env["HTTPS_PROXY"], "http://127.0.0.1:4100");
assert!(!plan.env.contains_key("XAI_API_KEY"));
assert!(!plan.env.contains_key("GROK_XAI_API_BASE_URL"));
let cfg = std::fs::read_to_string(home.join(".grok/config.toml")).unwrap();
assert!(cfg.contains("[mcp_servers.memory]"), "{cfg}");
// a stored session token outranks XAI_API_KEY and goes to Grok's own proxy,
// not the key's base, so a key run into the same persisted home removes it
let (home, plan) = f
.plan(Harness::Grok, AuthMode::Key, "k3", &[], true)
.unwrap();
assert_eq!(plan.env["XAI_API_KEY"], "egress");
assert!(
!home.join(".grok/auth.json").exists(),
"a login left behind would carry the key run past the egress"
);
}
fn assert_qwen_hooks(settings: &serde_json::Value) {
// Claude Code's shape, with timeouts in seconds, for all three events
for event in ["PreCompact", "SessionEnd", "PreToolUse"] {
let group = &settings["hooks"][event][0];
assert_eq!(group["matcher"], "*", "{event}");
assert_eq!(
group["hooks"][0]["command"], "/opt/memory/memory hook qwen",
"{event}"
);
assert_eq!(group["hooks"][0]["timeout"], 30, "{event}: seconds");
}
}
#[test]
fn qwen_plan_key_and_provider() {
let f = fixture();
let (home, plan) = f
.plan(Harness::Qwen, AuthMode::Key, "q1", &[], false)
.unwrap();
// an OpenAI-compatible harness: DashScope's compatible endpoint by default,
// behind the egress, with the egress holding the real key
assert_eq!(
plan.env["OPENAI_BASE_URL"],
"http://127.0.0.1:4100/dashscope/v1"
);
assert_eq!(plan.env["OPENAI_API_KEY"], "egress");
assert!(
!plan.env.contains_key("HTTPS_PROXY"),
"a key needs no tunnel"
);
let s = json_file(&home.join(".qwen/settings.json"));
assert_eq!(s["security"]["auth"]["selectedType"], "openai");
assert_eq!(
s["mcpServers"]["memory"]["httpUrl"],
"http://127.0.0.1:4100/mcp"
);
assert_eq!(s["general"]["enableAutoUpdate"], false);
assert_eq!(s["privacy"]["usageStatisticsEnabled"], false);
assert_qwen_hooks(&s);
assert_eq!(plan.env["MEMORY_MCP_URL"], "http://127.0.0.1:4100/mcp");
assert!(
plan.argv.iter().any(|a| a.ends_with("qwen")),
"{:?}",
plan.argv
);
}
#[test]
fn qwen_takes_a_provider_and_keeps_gemini_s_headless_shape() {
let f = fixture();
// another OpenAI-compatible destination on request
let (_, plan) = f
.plan_with(
Harness::Qwen,
AuthMode::Key,
Some("openai"),
"q2",
&[],
false,
&[],
)
.unwrap();
assert_eq!(
plan.env["OPENAI_BASE_URL"],
"http://127.0.0.1:4100/openai/v1"
);
let headless = Harness::Qwen.headless("hi");
assert_eq!(headless, vec!["-p", "hi", "--yolo"]);
assert_eq!(Harness::Qwen.model_flag("m"), vec!["-m", "m"]);
assert_eq!(Harness::Qwen.provider(), "dashscope");
assert_eq!(Harness::Qwen.subscription_provider(), "dashscope");
assert!(Harness::Qwen.accepts_provider());
}
#[test]
fn qwen_has_no_login_mode_since_its_free_tier_ended() {
let f = fixture();
// Qwen OAuth was discontinued in April 2026 and the harness exits on it; a
// cached credential on the host changes nothing, and the refusal says what to
// use instead
std::fs::create_dir_all(f.host_home.join(".qwen")).unwrap();
std::fs::write(
f.host_home.join(".qwen/oauth_creds.json"),
r#"{"access_token":"x"}"#,
)
.unwrap();
for creds in [false, true] {
match f.plan(Harness::Qwen, AuthMode::Subscription, "q3", &[], creds) {
Err(Error::Unsupported(why)) => assert!(
why.contains("--auth key") && why.contains("DASHSCOPE_API_KEY"),
"{why}"
),
other => panic!("a login mode: {other:?}"),
}
}
assert!(
!f.root.join("q3/.qwen/oauth_creds.json").exists(),
"nothing copied in"
);
}
/// A plan for Seatbelt's layout, from the fixture: everything at its host path. Unix
/// only, as the profile's paths are: a Windows path is never one Seatbelt is given.
#[cfg(unix)]
fn seatbelt_plan(
f: &Fixture,
harness: Harness,
home: &str,
projection: Option<&Path>,
model: Option<&str>,
) -> Result<(PathBuf, Plan), Error> {
let home = f.root.join(home);
let _path = path_lock();
let old = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![f.bins.clone()];
paths.extend(std::env::split_paths(&old));
std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
let req = Request {
harness,
auth: AuthMode::Key,
provider: None,
workdir: &f.work,
home: &home,
egress_socket: &f.sock,
memory_binary: &f.memory_bin,
destination_ceiling: Label::Public,
harness_args: &[],
host_home: None,
harness_env: &[],
model,
image: false,
workdir_inside: None,
rootfs: None,
projection,
};
let r = prepare_in(&req, Layout::seatbelt(&req, "127.0.0.1:51234"));
std::env::set_var("PATH", old);
r.map(|p| (home, p))
}
/// Every file under `dir`, with its text.
#[cfg(unix)]
fn files_under(dir: &Path) -> Vec<(PathBuf, String)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
out.extend(files_under(&p));
} else {
out.push((p.clone(), std::fs::read_to_string(&p).unwrap_or_default()));
}
}
out
}
#[test]
fn the_namespaces_layout_is_the_inside_constants() {
let at = Layout::namespaces();
assert_eq!(at.backend, Backend::Namespaces);
assert_eq!(at.home, Path::new(INSIDE_HOME));
assert_eq!(at.socket, Path::new(INSIDE_SOCKET));
assert_eq!(at.memory, Path::new(INSIDE_MEMORY));
assert_eq!(at.relay, INSIDE_RELAY);
assert_eq!(at.projection.as_deref(), Some(Path::new(INSIDE_PROJECTION)));
assert_eq!(at.tmp, Path::new("/tmp"));
assert_eq!(at.hook("claude"), format!("{INSIDE_MEMORY} hook claude"));
assert_eq!(at.memory_literal(), format!("\"{INSIDE_MEMORY}\""));
}
#[test]
fn a_hook_command_quotes_a_path_a_shell_would_split() {
assert_eq!(
shell_word(Path::new("/opt/memory/memory")),
"/opt/memory/memory"
);
assert_eq!(
shell_word(Path::new("/Users/me/My Tools/amem")),
"'/Users/me/My Tools/amem'"
);
assert_eq!(shell_word(Path::new("/a/it's")), r"'/a/it'\''s'");
assert_eq!(toml_escaped(r#"'/a/"b"\c' hook"#), r#"'/a/\"b\"\\c' hook"#);
}
#[test]
#[cfg(unix)]
fn under_seatbelt_the_harness_sees_its_host_paths() {
let f = fixture();
let projection = f.root.join("projection");
std::fs::create_dir_all(&projection).unwrap();
let (home, plan) =
seatbelt_plan(&f, Harness::Claude, "sb", Some(&projection), None).unwrap();
let shown = |p: &Path| p.display().to_string();
assert_eq!(plan.layout.backend, Backend::Seatbelt);
assert_eq!(plan.env["HOME"], shown(&home));
assert_eq!(plan.env["MEMORY_OUTBOX"], shown(&home.join(OUTBOX)));
assert_eq!(plan.env["TMPDIR"], shown(&home.join(".tmp")));
assert!(home.join(".tmp").is_dir());
assert_eq!(plan.env["MEMORY_PROJECTION"], shown(&projection));
assert!(plan.env["PATH"].contains(&shown(f.memory_bin.parent().unwrap())));
assert_eq!(plan.argv[0], shown(&f.memory_bin));
assert_eq!(plan.argv[3], shown(&f.sock));
assert_eq!(plan.argv[5], "127.0.0.1:51234");
assert!(plan.argv.contains(&shown(&home.join(".claude-mcp.json"))));
assert_eq!(plan.cwd, f.work);
let settings = json_file(&home.join(".claude/settings.json"));
assert_eq!(
settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
format!("{} hook claude", f.memory_bin.display())
);
}
#[test]
#[cfg(unix)]
fn under_seatbelt_the_mounts_are_the_profile_grants() {
let f = fixture();
let projection = f.root.join("projection");
std::fs::create_dir_all(&projection).unwrap();
let (home, plan) =
seatbelt_plan(&f, Harness::Claude, "sb", Some(&projection), None).unwrap();
let shown = |p: &Path| p.display().to_string();
// the mounts are grants at the host's paths, and the system is not among them
assert!(plan.mounts.iter().all(|m| m.source == m.target));
assert!(!plan.mounts.iter().any(|m| m.target.starts_with("/usr")));
assert!(plan.links.is_empty());
let profile = seatbelt::profile(&plan).unwrap();
assert!(profile.contains(&format!("(subpath \"{}\")", shown(&home))));
assert!(profile.contains(&format!("(subpath \"{}\")", shown(&projection))));
}
#[test]
#[cfg(unix)]
fn under_seatbelt_no_adapter_names_a_namespaces_path() {
let f = fixture();
let projection = f.root.join("projection");
std::fs::create_dir_all(&projection).unwrap();
for (i, h) in [
Harness::Claude,
Harness::Codex,
Harness::Gemini,
Harness::Antigravity,
Harness::Qwen,
Harness::Grok,
Harness::Muse,
Harness::Pi,
Harness::OpenCode,
]
.into_iter()
.enumerate()
{
let (home, plan) =
seatbelt_plan(&f, h, &format!("h{i}"), Some(&projection), Some("m")).unwrap();
let memory = f.memory_bin.display().to_string();
let mut hooked = false;
for (p, text) in files_under(&home) {
// OpenCode's plugin falls back on the namespaces home only where HOME
// is unset, which no plan leaves it
let text = text.replace(r#"process.env.HOME || "/home/user""#, "");
for inside in [INSIDE_HOME, INSIDE_MEMORY, "/run/memory"] {
assert!(
!text.contains(inside),
"{h:?}: {} names {inside}",
p.display()
);
}
hooked |= text.contains(&memory);
}
assert!(hooked, "{h:?}: no file runs the memory's hook");
for (k, v) in &plan.env {
for inside in [INSIDE_HOME, INSIDE_MEMORY, INSIDE_SOCKET, INSIDE_RELAY] {
assert!(!v.contains(inside), "{h:?}: {k}={v}");
}
}
assert!(!plan.argv.iter().any(|a| a.contains(INSIDE_HOME)), "{h:?}");
}
}
#[test]
fn seatbelt_refuses_the_image_and_a_tree_over_its_own() {
let f = fixture();
let req = |workdir: &'static str, image: bool| {
let workdir = if workdir.is_empty() {
f.root.clone()
} else {
f.work.clone()
};
(workdir, image)
};
for (workdir, image) in [req("", false), req("work", true)] {
let home = f.root.join("sb");
let r = Request {
harness: Harness::Claude,
auth: AuthMode::Key,
provider: None,
workdir: &workdir,
home: &home,
egress_socket: &f.sock,
memory_binary: &f.memory_bin,
destination_ceiling: Label::Public,
harness_args: &[],
host_home: None,
harness_env: &[],
model: None,
image,
workdir_inside: None,
rootfs: None,
projection: None,
};
match prepare_in(&r, Layout::seatbelt(&r, "127.0.0.1:51234")) {
Err(Error::Unsupported(why)) => {
assert!(
why.contains("Seatbelt") || why.contains("working tree"),
"{why}"
);
}
other => panic!("{} image {image}: {other:?}", workdir.display()),
}
}
}
}