Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/centaur-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ jobs:
working-directory: runtime/node-sync
run: |
sudo -E env "PATH=$PATH" CARGO_TARGET_DIR=/tmp/privileged-target \
cargo test --test git_identity_overlay_visibility -- --nocapture
cargo test --test git_identity_visibility -- --nocapture

# Real overlay + scan-demo: locks in the syscall proofs (whiteout→Delete,
# symlink→metadata-only, openat2 NO_SYMLINKS blocks the /etc/shadow escape).
Expand Down
2 changes: 1 addition & 1 deletion centaur/services/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ RUN mkdir -p ~/.config/amp ~/.codex ~/.pi/agent ~/repos ~/workspace \
&& git config --global --add safe.directory '*' \
&& git config --global user.name "Centaur AI" \
&& git config --global user.email "ai@centaur.local" \
&& git config --global include.path /home/agent/.config/git/atrium-identity
&& git config --global include.path /home/agent/context/.atrium-git-identity

# ==============================================================================
# Final thin stage: only frequently-changing local files below.
Expand Down
15 changes: 15 additions & 0 deletions runtime/node-sync/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ that reaches a claimed warm pod. On 204 the daemon writes nothing and the
image's baked `Centaur AI` identity stands — that fallback is the pre-existing
behavior, which is what makes this lane safe to ship dark.

**The identity file MUST land in the session's context root** (`~/context/.atrium-git-identity`,
the ext4 bind mount) and never in the agent's overlay home. This is load-bearing, not
stylistic, and it shipped wrong twice before anyone measured it. `entrypoint.sh` runs
`git config` at POD CREATION; git resolves the `[include]` target, gets ENOENT, and the
kernel caches a NEGATIVE dentry minutes before the claim that knows who the user is. So
the lookup has always already missed by the time the identity is written, and the only
question is whether that negative entry is invalidated. Overlay mount instances keep
independent dentry trees, so a node-side create — into `upper` OR through `merged`, both
were shipped — never invalidates the pod's entry: readdir lists the file while lookup
returns ENOENT, and the agent commits as `Centaur AI` with the file plainly on disk. A
bind mount of one ext4 superblock shares the dentry tree, so the create is observed even
after a failed lookup. `tests/git_identity_visibility.rs` pins exactly that ordering
(mount, MISS, write, hit); a test that writes before looking up passes on the broken
design too.

Lanes covered by route-existence + daemon-side parsing only (their payloads
are opaque blobs or one-way writes): `artifacts/raw`, `harness-transcript`,
`harness-state-bundle`, `profile-candidates`, `profile-baseline`,
Expand Down
61 changes: 32 additions & 29 deletions runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,36 +434,46 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i
continue;
}
};
let session = session_config_from_discovered(&discovered, &mounted);
mounted_overlays.insert(discovered.session.clone(), mounted);
let first_seen = !states.contains_key(&session.session);
let atrium_root = scoped_atrium_root(&global.atrium_root, &session.session);
if should_eager_poll_atrium(&session, &atrium_root) {
eager_poll_sessions.insert(session.session.clone());
}
// Git author identity is server-derived per CLAIM, and a claim is a
// mount — so refresh it only when the mount is being (re)established,
// never on every tick. An unconditional per-session GET per tick is
// exactly the steady-state cost the atrium delta protocol exists to
// avoid.
//
// This MUST write through `mounted.merged`, never `discovered.upper`.
// A warm pod's overlay is already mounted long before the claim that
// gives it an identity, and modifying an overlay's upperdir behind a
// live mount is undefined: the file lands on disk but the merged view
// keeps serving its cached (negative) dentry, so the agent never sees
// it — and the path is left so incoherent that a later mkdir through
// the mount fails with ESTALE. Shipped exactly that way once: the
// identity file was present in the upper and the agent still committed
// as "Centaur AI". Same hazard the lowerdir comment above describes.
// It lands in the CONTEXT root — an ext4 bind mount — and NOT in the
// agent's overlay home, which is why `/opt/centaur/gitconfig` includes
// `~/context/.atrium-git-identity`. This is not a stylistic choice; the
// home cannot work:
//
// entrypoint.sh runs `git config` at POD CREATION. git reads the
// global config, sees the [include], opens the target, gets ENOENT,
// and the kernel caches a NEGATIVE dentry for that path — minutes
// before any claim exists. Overlay mount INSTANCES keep independent
// dentry trees, so a later create from the node side (upper OR merged)
// never invalidates the pod's negative entry: readdir lists the file
// while lookup still returns ENOENT, and the agent commits as the
// baked "Centaur AI". Shipped that way twice. A bind mount of one ext4
// superblock shares the dentry tree, so the create IS observed even
// after a failed lookup — measured on a live pod, both ways.
//
// Failures here must NOT skip the mount. The image bakes a
// "Centaur AI" identity and /opt/centaur/gitconfig includes this file
// only if it exists, so an absent identity degrades to exactly the
// pre-existing behavior. Gating the mount on identity would turn a
// surface blip into "the agent's home never mounts" — trading a
// recoverable misattributed commit for an unrecoverable dead session.
// "Centaur AI" identity and only includes this file if it exists, so an
// absent identity degrades to exactly the pre-existing behavior. Gating
// the mount on identity would turn a surface blip into "the agent's home
// never mounts" — trading a recoverable misattributed commit for an
// unrecoverable dead session.
//
// Ordering caveat: mount_overlay writes the ready marker, and a warm
// pod's marker predates this claim entirely, so there is no
// write-before-ready guarantee to be had here — the agent could in
// principle commit in the seconds before this lands. That degrades to
// the baked identity rather than a wrong one, which is why it is
// acceptable; closing it for real needs the claim path itself to carry
// the identity.
// Ordering caveat: a warm pod's ready marker predates this claim, so
// there is no write-before-ready guarantee — the agent could commit in
// the seconds before this lands, degrading to the baked identity rather
// than a wrong one. Closing that needs the claim path to carry it.
if wip_remounted {
let identity_client = HttpAtriumClient::new(
&global.base_url,
Expand All @@ -473,7 +483,7 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i
match identity_client.get_git_identity() {
Ok(identity) => {
if let Err(error) = materialize_git_identity(
&mounted,
&atrium_root,
identity.as_ref(),
Some(discovered.manifest.agent_uid),
) {
Expand All @@ -491,13 +501,6 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i
}
}
}
let session = session_config_from_discovered(&discovered, &mounted);
mounted_overlays.insert(discovered.session.clone(), mounted);
let first_seen = !states.contains_key(&session.session);
let atrium_root = scoped_atrium_root(&global.atrium_root, &session.session);
if should_eager_poll_atrium(&session, &atrium_root) {
eager_poll_sessions.insert(session.session.clone());
}
if first_seen && let Err(error) = write_mount_readme(&atrium_root) {
eprintln!(
"session {}: seed Atrium context README: {error}",
Expand Down
61 changes: 27 additions & 34 deletions runtime/node-sync/src/materializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use crate::http_client::GitIdentity;
use crate::overlay_mount::OverlayMountPlan;
use crate::runtime::{AtriumChannel, AtriumClient, ContextDeltaRequest, ContextDocResponse};
use crate::state::{ContextDocState, DaemonState};

Expand Down Expand Up @@ -86,26 +85,31 @@ If a listed path is missing, it is still materializing — wait a few seconds an
"#;

pub const CONTEXT_READY_MARKER: &str = ".atrium-context-ready";
pub const GIT_IDENTITY_RELATIVE_PATH: &str = ".config/git/atrium-identity";
/// Name of the identity file inside a session's CONTEXT root. The sandbox image
/// includes `~/context/.atrium-git-identity` from /opt/centaur/gitconfig. Dotfile so
/// it stays out of the context doc tree the agent browses.
pub const GIT_IDENTITY_FILE: &str = ".atrium-git-identity";

/// Authoritatively materialize the server-derived identity into a session's home.
/// Authoritatively materialize the server-derived identity into a session's CONTEXT
/// root — the ext4 bind mount the agent sees at `~/context`, never its overlay home.
/// `None` is the 204 fallback and removes a prior identity so the image default wins.
///
/// Takes the mounted `OverlayMountPlan` rather than a bare path ON PURPOSE: this must
/// be written through `plan.merged`, and an earlier version took a `&Path` and got
/// handed the overlay's `upper` instead. That compiled, passed every tempdir unit
/// test, and was silently useless in production — a warm pod's overlay is already
/// mounted long before the claim that gives it an identity, and writing to an
/// upperdir behind a live mount is undefined: the file lands on disk while the merged
/// view keeps serving its cached negative dentry, so the agent never sees it and
/// commits as the baked "Centaur AI". Deriving the path here instead of accepting one
/// makes that mistake unrepresentable rather than merely documented.
/// `context_root` must be the same value `write_mount_readme` is given
/// (`scoped_atrium_root(...)`). Passing an overlay path here is the bug this function
/// has already shipped twice: `entrypoint.sh` runs `git config` at POD CREATION, git
/// resolves the `[include]` target, gets ENOENT, and the kernel caches a NEGATIVE
/// dentry for that path minutes before any claim. Overlay mount instances keep
/// independent dentry trees, so a later create from the node side — upper OR merged —
/// never invalidates the pod's negative entry, and the agent commits as the baked
/// "Centaur AI" while the file plainly exists on disk. A bind mount of one ext4
/// superblock shares the dentry tree, so the create is observed even after a failed
/// lookup. Measured on a live pod, both ways; see tests/git_identity_visibility.rs.
pub fn materialize_git_identity(
plan: &OverlayMountPlan,
context_root: &Path,
identity: Option<&GitIdentity>,
agent_uid: Option<u32>,
) -> Result<(), String> {
let dst = plan.merged.join(GIT_IDENTITY_RELATIVE_PATH);
let dst = context_root.join(GIT_IDENTITY_FILE);
let Some(identity) = identity else {
remove_file_if_present(&dst)?;
return Ok(());
Expand Down Expand Up @@ -999,17 +1003,6 @@ mod tests {
}
}

// Build via the real constructor so the plan cannot drift from production's shape.
// `merged` is a plain tempdir here: these unit tests cover the FILE contract
// (escaping, idempotency, 204-removal). They CANNOT cover overlay visibility, which
// is what actually broke in production — see tests/git_identity_overlay_visibility.rs.
fn plan_for(home: &Path) -> OverlayMountPlan {
let overlays_root = home.join("overlays-root");
std::fs::create_dir_all(&overlays_root).unwrap();
crate::overlay_mount::plan_overlay_mount(&overlays_root, "unit", home, "", &[], None)
.unwrap()
}

// Pinned to `/` for the same reason the writer is: git discovers a repository from
// the cwd even for `--file` reads, so running the suite from inside a linked
// worktree (whose .git is a gitfile) would fail these on repo discovery, not on
Expand All @@ -1027,11 +1020,11 @@ mod tests {
#[test]
fn git_identity_204_removes_a_stale_file() {
let temp = tempfile::tempdir().unwrap();
let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH);
let dst = temp.path().join(GIT_IDENTITY_FILE);
std::fs::create_dir_all(dst.parent().unwrap()).unwrap();
std::fs::write(&dst, b"stale").unwrap();

materialize_git_identity(&plan_for(temp.path()), None, None).unwrap();
materialize_git_identity(temp.path(), None, None).unwrap();

assert!(!dst.exists());
}
Expand All @@ -1040,9 +1033,9 @@ mod tests {
fn git_identity_200_writes_exact_config() {
let temp = tempfile::tempdir().unwrap();
let identity = git_identity("Allan Niemerg");
let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH);
let dst = temp.path().join(GIT_IDENTITY_FILE);

materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap();
materialize_git_identity(temp.path(), Some(&identity), None).unwrap();

assert_eq!(
std::fs::read_to_string(&dst).unwrap(),
Expand All @@ -1063,9 +1056,9 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let hostile = "Eve \"quoted\" \\\\ path\n[include]\n\tpath = /etc/passwd";
let identity = git_identity(hostile);
let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH);
let dst = temp.path().join(GIT_IDENTITY_FILE);

materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap();
materialize_git_identity(temp.path(), Some(&identity), None).unwrap();

let output = git_config_get(&dst, "user.name");
assert!(output.status.success());
Expand All @@ -1084,11 +1077,11 @@ mod tests {
fn git_identity_materialization_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let identity = git_identity("Allan Niemerg");
let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH);
let dst = temp.path().join(GIT_IDENTITY_FILE);

materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap();
materialize_git_identity(temp.path(), Some(&identity), None).unwrap();
let first = std::fs::read(&dst).unwrap();
materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap();
materialize_git_identity(temp.path(), Some(&identity), None).unwrap();

assert_eq!(std::fs::read(&dst).unwrap(), first);
}
Expand Down
Loading
Loading