use crate::adapter::Adapter;
use crate::base::GRAPH_CACHE;
use anyhow::Result;
use std::path::Path;
pub fn base_tag() -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
base_dockerfile().hash(&mut h);
format!("omh/base:{:x}", h.finish())
}
pub fn tag_for(adapter: &Adapter) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
harness_dockerfile(adapter).hash(&mut h);
base_dockerfile().hash(&mut h);
format!("omh/{}:{:x}", adapter.name, h.finish())
}
pub const GUEST_HOME: &str = "/home/agent";
pub fn recipe_digest(recipe: &str) -> Result<String> {
use anyhow::Context;
use std::io::Write;
use std::process::Stdio;
let mut child = std::process::Command::new("git")
.args(["hash-object", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context("running git hash-object")?;
child
.stdin
.take()
.context("no stdin")?
.write_all(recipe.as_bytes())?;
let out = child.wait_with_output()?;
anyhow::ensure!(out.status.success(), "git hash-object failed");
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn base_dockerfile() -> String {
let notes = crate::memory::GUEST_LOCAL_NOTES;
format!(
r#"FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git ripgrep dtach sudo curl less jq procps openssh-server socat \
&& rm -rf /var/lib/apt/lists/*
RUN usermod -l agent -d {GUEST_HOME} -m node \
&& groupmod -n agent node \
&& echo 'agent ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/agent \
&& chmod 0440 /etc/sudoers.d/agent
# Assert the sbx kit contract at build time rather than assuming it. If a future
# base image moves UID 1000, this fails here instead of failing mysteriously
# inside a sandbox.
RUN test "$(id -u agent)" = "1000" && test "$(getent passwd agent | cut -d: -f6)" = "{GUEST_HOME}"
# The base set lives here, not in a harness layer: a code graph is
# harness-agnostic and every session should get the same one.
ARG TARGETARCH
RUN __GRAPH_INSTALL__
# Mount points the launcher expects to exist, owned by the unprivileged user.
# The graph cache is a volume; the image only needs the directory to exist and
# be owned by the agent, or docker creates it as root.
RUN mkdir -p /work /omh/sock /omh/cache /omh/layers {notes} {GRAPH_CACHE} \
&& chown -R agent:agent /work /omh {GUEST_HOME}/.cache
# Session entrypoint: install the key, start sshd, then stay alive so the
# container outlives the command that created it. The key arrives as an env var
# because a bind-mounted authorized_keys lands with host ownership and sshd
# silently refuses to read one it does not trust.
RUN printf '%s\n' \
'#!/bin/sh' \
'set -e' \
'mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"' \
'if [ -n "$OMH_PUBKEY" ]; then' \
' printf "%s\\n" "$OMH_PUBKEY" > "$HOME/.ssh/authorized_keys"' \
' chmod 600 "$HOME/.ssh/authorized_keys"' \
'fi' \
'sudo ssh-keygen -A >/dev/null 2>&1 || true' \
'sudo mkdir -p /run/sshd' \
'sudo /usr/sbin/sshd' \
'exec sleep infinity' \
> /usr/local/bin/omh-session \
&& chmod 0755 /usr/local/bin/omh-session
# Proxy forwarding, for backends that filter egress through one.
ENV HTTP_PROXY="" HTTPS_PROXY="" NO_PROXY=""
USER agent
WORKDIR /work
"#
)
.replace("__GRAPH_INSTALL__", &crate::base::graph_install())
}
pub fn harness_dockerfile(adapter: &Adapter) -> String {
let mut df = format!("FROM {}\nUSER root\nRUN {}\n", base_tag(), adapter.install);
let dirs = mount_parents(adapter);
if !dirs.is_empty() {
df.push_str(&format!(
"RUN mkdir -p {0} && chown -R agent:agent {0}\n",
dirs.join(" ")
));
}
df.push_str("USER agent\nWORKDIR /work\n");
df
}
fn mount_parents(adapter: &Adapter) -> Vec<String> {
let mut dirs: Vec<String> = adapter
.capabilities
.values()
.map(|b| b.path.clone())
.chain(adapter.creds.iter().cloned())
.chain(adapter.token.iter().cloned())
.filter_map(|template| {
let path = crate::adapter::expand(template.trim_end_matches('/'), GUEST_HOME);
if !path.starts_with(GUEST_HOME) {
return None;
}
path.parent().map(|p| p.display().to_string())
})
.filter(|d| d != GUEST_HOME)
.collect();
dirs.sort();
dirs.dedup();
dirs
}
pub fn build_args(tag: &str, context: &Path) -> Vec<String> {
vec![
"build".into(),
"-t".into(),
tag.into(),
"-f".into(),
"-".into(),
context.to_string_lossy().into_owned(),
]
}
pub fn ensure(program: &str, adapter: &Adapter) -> Result<()> {
let base = base_tag();
if !exists(program, &base) {
eprintln!("omh: building {base} (first run only)");
build(program, &base, &base_dockerfile())?;
}
let t = tag_for(adapter);
if !exists(program, &t) {
eprintln!("omh: building {t}");
build(program, &t, &harness_dockerfile(adapter))?;
}
Ok(())
}
fn build(program: &str, tag: &str, dockerfile: &str) -> Result<()> {
use anyhow::Context;
use std::io::Write;
use std::process::Stdio;
let context = std::env::temp_dir().join("omh-build-context");
std::fs::create_dir_all(&context)?;
let mut child = std::process::Command::new(program)
.args(build_args(tag, &context))
.stdin(Stdio::piped())
.spawn()
.with_context(|| format!("running {program} build"))?;
child
.stdin
.as_mut()
.context("build stdin")?
.write_all(dockerfile.as_bytes())?;
let status = child.wait()?;
if !status.success() {
anyhow::bail!("failed to build {tag}");
}
Ok(())
}
pub fn ensure_network(program: &str, name: &str) -> Result<()> {
let present = std::process::Command::new(program)
.args(["network", "inspect", name])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if present {
return Ok(());
}
let out = std::process::Command::new(program)
.args(["network", "create", name])
.output()?;
if !out.status.success() {
anyhow::bail!(
"creating network {name}: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
pub fn container_running(program: &str, name: &str) -> bool {
std::process::Command::new(program)
.args(["inspect", "-f", "{{.State.Running}}", name])
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false)
}
pub fn container_remove(program: &str, name: &str) -> Result<()> {
let out = std::process::Command::new(program)
.args(["rm", "-f", name])
.output()?;
if !out.status.success() {
anyhow::bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(())
}
pub fn exists(program: &str, tag: &str) -> bool {
std::process::Command::new(program)
.args(["image", "inspect", tag])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn adapters() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/adapters"))
}
fn claude() -> Adapter {
Adapter::find(adapters(), "claude").unwrap()
}
#[test]
fn tags_name_their_harness() {
assert!(tag_for(&claude()).starts_with("omh/claude:"));
}
#[test]
fn a_changed_base_recipe_is_a_different_base_tag() {
let before = base_tag();
assert!(before.starts_with("omh/base:"));
assert_ne!(before, "omh/base:latest", "a mutable tag never rebuilds");
}
#[test]
fn the_harness_layer_pins_an_exact_base() {
let df = harness_dockerfile(&claude());
assert!(df.contains(&base_tag()), "got: {df}");
assert!(!df.contains("omh/base:latest"), "got: {df}");
}
#[test]
fn a_changed_recipe_is_a_different_tag() {
let mut a = claude();
let before = tag_for(&a);
a.install = "npm install -g @anthropic-ai/claude-code@next".into();
assert_ne!(tag_for(&a), before, "a changed recipe must force a rebuild");
}
#[test]
fn an_unchanged_recipe_keeps_its_tag() {
assert_eq!(tag_for(&claude()), tag_for(&claude()));
}
#[test]
fn the_base_satisfies_the_sandbox_contract() {
let df = base_dockerfile();
assert!(
df.contains("-u 1000") || df.contains("1000"),
"UID 1000: {df}"
);
assert!(df.contains("agent"), "agent user");
assert!(df.contains(GUEST_HOME), "home directory");
assert!(df.contains("NOPASSWD"), "passwordless sudo");
}
#[test]
fn the_image_creates_the_home_the_code_mounts_into() {
let df = base_dockerfile();
assert!(
df.contains(&format!("usermod -l agent -d {GUEST_HOME}")),
"the image must create the home the launcher mounts into:\n{df}"
);
assert!(
df.contains(&format!("cut -d: -f6)\" = \"{GUEST_HOME}\"")),
"and assert it at build time, not trust it:\n{df}"
);
}
#[test]
fn the_graph_cache_lives_under_the_agents_home() {
assert!(
GRAPH_CACHE.starts_with(&format!("{GUEST_HOME}/")),
"graph cache {GRAPH_CACHE} is not under {GUEST_HOME}"
);
assert!(
base_dockerfile().contains(GRAPH_CACHE),
"the image must create the cache directory, or docker makes it root-owned"
);
}
#[test]
fn an_image_recipe_digest_is_stable_across_toolchains() {
assert_eq!(
recipe_digest("hello\n").unwrap(),
"ce013625030ba8dba906f756967f9e9ca394464a",
"this is git's SHA-1 of the blob and it is not allowed to change"
);
assert_eq!(recipe_digest("a").unwrap(), recipe_digest("a").unwrap());
assert_ne!(recipe_digest("a").unwrap(), recipe_digest("b").unwrap());
}
#[test]
fn the_image_creates_the_note_store_the_launcher_mounts_into() {
let df = base_dockerfile();
let notes = crate::memory::GUEST_LOCAL_NOTES;
assert!(
df.contains(notes),
"the image must create {notes}, or docker makes it root-owned"
);
let owned = df
.lines()
.find(|l| l.contains("chown -R agent:agent"))
.expect("the image must chown its mount points");
assert!(
owned
.split_whitespace()
.any(|dir| notes == dir || notes.starts_with(&format!("{dir}/"))),
"{notes} is not covered by: {owned}"
);
}
#[test]
fn the_guest_home_is_defined_once() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut declarations = Vec::new();
for entry in std::fs::read_dir(&src).unwrap().flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "rs") {
let body = std::fs::read_to_string(&path).unwrap();
for line in body.lines() {
let l = line.trim();
let is_const = l.starts_with("const ") || l.starts_with("pub const ");
if is_const && l.contains("= \"/home/agent\"") {
declarations.push(format!("{}: {l}", path.display()));
}
}
}
}
assert_eq!(
declarations.len(),
1,
"the agent's home should be declared once:\n {}",
declarations.join("\n ")
);
}
#[test]
fn the_base_provides_what_every_session_needs() {
let df = base_dockerfile();
for tool in ["git", "dtach", "ripgrep"] {
assert!(df.contains(tool), "missing {tool}: {df}");
}
}
#[test]
fn the_base_image_carries_the_base_set() {
let df = base_dockerfile();
assert!(df.contains(crate::base::GRAPH_BIN), "no code graph: {df}");
}
#[test]
fn the_base_owns_the_graph_cache_directory() {
let df = base_dockerfile();
assert!(df.contains(crate::base::GRAPH_CACHE), "got: {df}");
}
#[test]
fn the_base_can_serve_ssh() {
let df = base_dockerfile();
assert!(df.contains("openssh-server"), "got: {df}");
assert!(df.contains("omh-session"), "needs a session entrypoint");
}
#[test]
fn the_session_entrypoint_installs_the_key_with_permissions_sshd_accepts() {
let df = base_dockerfile();
assert!(
df.contains("OMH_PUBKEY"),
"key must come from the environment"
);
assert!(df.contains("chmod 700"), "~/.ssh perms");
assert!(df.contains("chmod 600"), "authorized_keys perms");
}
#[test]
fn the_session_entrypoint_outlives_the_command_that_started_it() {
let df = base_dockerfile();
assert!(df.contains("sshd"), "must start sshd");
assert!(df.contains("sleep infinity"), "PID 1 must not exit");
}
#[test]
fn the_base_creates_the_paths_the_launcher_mounts_into() {
let df = base_dockerfile();
for dir in ["/work", "/omh/sock", "/omh/cache"] {
assert!(df.contains(dir), "missing {dir}: {df}");
}
}
#[test]
fn the_harness_layer_extends_the_base_and_installs_the_harness() {
let df = harness_dockerfile(&claude());
assert!(df.contains(&format!("FROM {}", base_tag())), "got: {df}");
assert!(
df.contains("@anthropic-ai/claude-code"),
"install command: {df}"
);
}
#[test]
fn the_harness_layer_owns_the_directories_it_mounts_into() {
let df = harness_dockerfile(&claude());
assert!(df.contains("/home/agent/.claude"), "config dir: {df}");
assert!(
df.contains("chown"),
"must be owned by agent, not root: {df}"
);
}
#[test]
fn only_directories_under_the_agents_home_are_created() {
for name in ["claude", "opencode"] {
let a = Adapter::find(adapters(), name).unwrap();
for dir in mount_parents(&a) {
assert!(
dir.starts_with("/home/agent"),
"{name}: {dir} is not ours to create"
);
}
}
}
#[test]
fn credential_directories_are_created_too() {
let a = Adapter::find(adapters(), "opencode").unwrap();
let dirs = mount_parents(&a);
assert!(
dirs.iter().any(|d| d.contains("/.local/share")),
"the creds parent must be created: {dirs:?}"
);
}
#[test]
fn every_home_side_mount_has_its_parent_created() {
for name in ["claude", "opencode"] {
let a = Adapter::find(adapters(), name).unwrap();
let created = mount_parents(&a);
let wanted = a
.capabilities
.values()
.map(|b| b.path.clone())
.chain(a.creds.iter().cloned())
.chain(a.token.iter().cloned());
for template in wanted {
let p = crate::adapter::expand(template.trim_end_matches('/'), "/home/agent");
if !p.starts_with(GUEST_HOME) {
continue;
}
let Some(parent) = p.parent().map(|x| x.display().to_string()) else {
continue;
};
if parent == GUEST_HOME {
continue; }
assert!(
created
.iter()
.any(|d| parent == *d || parent.starts_with(&format!("{d}/"))),
"{name}: nothing creates {parent} for {template}"
);
}
}
}
#[test]
fn images_end_as_the_unprivileged_user() {
for df in [base_dockerfile(), harness_dockerfile(&claude())] {
let last_user = df
.lines()
.rfind(|l| l.trim_start().starts_with("USER "))
.unwrap_or("");
assert_eq!(last_user.trim(), "USER agent", "ended privileged:\n{df}");
}
}
#[test]
fn build_reads_the_dockerfile_from_stdin() {
let args = build_args("omh/x:latest", Path::new("/tmp/ctx"));
assert_eq!(args[0], "build");
assert!(args.contains(&"-t".into()) && args.contains(&"omh/x:latest".into()));
assert!(
args.windows(2).any(|w| w[0] == "-f" && w[1] == "-"),
"Dockerfile must come from stdin so nothing is written to disk: {args:?}"
);
}
}