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
3 changes: 2 additions & 1 deletion centaur/services/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ RUN mkdir -p ~/.config/amp ~/.codex ~/.pi/agent ~/repos ~/workspace \
&& git config --global core.hooksPath /opt/centaur/git-hooks \
&& git config --global --add safe.directory '*' \
&& git config --global user.name "Centaur AI" \
&& git config --global user.email "ai@centaur.local"
&& git config --global user.email "ai@centaur.local" \
&& git config --global include.path /home/agent/.config/git/atrium-identity

# ==============================================================================
# Final thin stage: only frequently-changing local files below.
Expand Down
20 changes: 20 additions & 0 deletions centaur/services/sandbox/git-hooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@ Remove the generated attribution footer and commit again.
EOF
exit 1
fi

session_id="$(git config --get atrium.sessionId || true)"
harness="$(git config --get atrium.harness || true)"
trailers=()

if [[ -n "$session_id" ]]; then
trailers+=(--trailer "Atrium-Session: $session_id")
fi
if [[ -n "$harness" ]]; then
trailers+=(--trailer "Atrium-Harness: $harness")
fi

if [[ ${#trailers[@]} -gt 0 ]]; then
git interpret-trailers \
--in-place \
--if-exists=replace \
--if-missing=add \
"${trailers[@]}" \
"$message_file"
fi
106 changes: 106 additions & 0 deletions centaur/services/sandbox/test_commit_msg_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from __future__ import annotations

import os
import subprocess
import tempfile
import unittest
from pathlib import Path


HOOK = Path(__file__).with_name("git-hooks") / "commit-msg"


class CommitMsgHookTest(unittest.TestCase):
def setUp(self) -> None:
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
self.repo = Path(temp_dir.name) / "repo"
self.repo.mkdir()
self.env = {
**os.environ,
"BASH_ENV": "/dev/null",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_NOSYSTEM": "1",
}
self.git("init", "-q", "-b", "main", check=True)
self.git("config", "user.name", "Test User", check=True)
self.git("config", "user.email", "test@example.com", check=True)
self.git("config", "core.hooksPath", str(HOOK.parent), check=True)

def git(
self, *args: str, check: bool = False, input: str | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=self.repo,
env=self.env,
input=input,
text=True,
capture_output=True,
check=check,
)

def commit_message(self) -> str:
return self.git("log", "-1", "--pretty=%B", check=True).stdout.rstrip("\n")

def test_appends_trailers_once_and_amend_is_idempotent(self) -> None:
self.git(
"config",
"atrium.sessionId",
"de230f34-b9d9-42df-bce3-9270f2184294",
check=True,
)
self.git("config", "atrium.harness", "claude", check=True)
(self.repo / "example.txt").write_text("content\n")
self.git("add", "example.txt", check=True)

self.git("commit", "-m", "feat: add provenance", check=True)
message = self.commit_message()
self.assertEqual(message.count("Atrium-Session:"), 1)
self.assertEqual(message.count("Atrium-Harness:"), 1)
self.assertTrue(
message.endswith(
"\n\nAtrium-Session: de230f34-b9d9-42df-bce3-9270f2184294\n"
"Atrium-Harness: claude"
)
)

self.git("commit", "--amend", "--no-edit", check=True)
amended = self.commit_message()
self.assertEqual(amended.count("Atrium-Session:"), 1)
self.assertEqual(amended.count("Atrium-Harness:"), 1)

def test_missing_atrium_config_leaves_message_unchanged(self) -> None:
original = "feat: preserve this message\n\nBody remains unchanged."

self.git("commit", "--allow-empty", "-F", "-", input=original, check=True)

self.assertEqual(self.commit_message(), original)

def test_atrium_harness_claude_trailer_is_allowed(self) -> None:
message = "feat: record harness\n\nAtrium-Harness: claude"

result = self.git("commit", "--allow-empty", "-F", "-", input=message)

self.assertEqual(result.returncode, 0, result.stderr)

def test_claude_coauthor_is_still_rejected(self) -> None:
message = (
"feat: forbidden attribution\n\n"
"Co-authored-by: Claude <noreply@anthropic.com>"
)

result = self.git("commit", "--allow-empty", "-F", "-", input=message)

self.assertNotEqual(result.returncode, 0)
self.assertIn("AI co-author attribution", result.stderr)

def test_non_conventional_subject_is_still_rejected(self) -> None:
result = self.git("commit", "--allow-empty", "-m", "not conventional")

self.assertNotEqual(result.returncode, 0)
self.assertIn("conventional commit subject", result.stderr)


if __name__ == "__main__":
unittest.main()
14 changes: 14 additions & 0 deletions runtime/node-sync/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ daemon's tests):
| `GET sessions/:viewerId/atrium/changes` | `atrium-changes.json` |
| `GET sessions/:viewerId/atrium/channels` | `atrium-channels.json` |
| `GET sessions/:id/profile-bundles` | `profile-bundles.json` (element shape daemon-side; live check pins the envelope — seeding a real bundle needs the whole profile-writeback pipeline) |
| `GET sessions/:id/git-identity` | `git-identity.json` (200 with the identity, or **204** when none is resolvable) |

The `git-identity` lane deserves a note, because its shape invites the wrong
home. It looks like a profile bundle and is deliberately not one: profile
bundles are user-authored files that get captured and **written back**, whereas
the git identity is server-derived per claim and must never round-trip (that is
the clobber class fixed in #97). It exists because commit authorship is the one
per-user value this architecture cannot inject at the HTTP boundary — the
iron-proxy rewrites headers, and authorship lives *inside* the payload, below
that seam. A warm pod's env is baked before the claiming principal is known, so
env cannot carry it either; per-session file materialization is the only channel
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.

Lanes covered by route-existence + daemon-side parsing only (their payloads
are opaque blobs or one-way writes): `artifacts/raw`, `harness-transcript`,
Expand Down
8 changes: 8 additions & 0 deletions runtime/node-sync/contract/fixtures/git-identity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"_comment": "GET {session_prefix}/:threadKey/git-identity — the git author identity the daemon materializes into the sandbox's ~/.config/git/atrium-identity. /opt/centaur/gitconfig includes that path AFTER its own [user] block, so these values win (git config is last-wins); when the daemon writes nothing the image's baked 'Centaur AI' identity stands unchanged. A 204 means surface could not resolve an identity for this session — the daemon MUST then leave the file absent rather than write a partial one. This payload is SERVER-DERIVED per claim: it is deliberately NOT a profile bundle, because profile bundles are user-authored, captured, and written back, and an identity that round-trips through writeback would re-introduce the clobber class fixed in #97. Never capture, never write back, never let it be restored from a resumed session's overlay ahead of a fresh fetch.",
"authorName": "Allan Niemerg",
"authorEmail": "123+aniemerg@users.noreply.github.com",
"source": "github_noreply",
"sessionId": "de230f34-b9d9-42df-bce3-9270f2184294",
"harness": "claude"
}
42 changes: 41 additions & 1 deletion runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ use centaur_node_sync::eviction::{
};
use centaur_node_sync::fs_linux;
use centaur_node_sync::http_client::HttpAtriumClient;
use centaur_node_sync::materializer::write_mount_readme;
use centaur_node_sync::materializer::{materialize_git_identity, write_mount_readme};
use centaur_node_sync::overlay::RawEntry;
use centaur_node_sync::overlay_mount::{OverlayMountPlan, mount_overlay, plan_overlay_mount};
use centaur_node_sync::quiesce::{LeaseGate, apply_quiesced_writes};
Expand Down Expand Up @@ -427,6 +427,46 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i
};
let wip_remounted = !has_active_mount
|| mounted_overlays.get(&discovered.session) != Some(&plan);
// 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. Writing into the upper before mount_overlay is deliberate:
// the agent must never observe a home without its identity.
//
// 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.
if wip_remounted {
let identity_client = HttpAtriumClient::new(
&global.base_url,
&global.api_key,
&discovered.atrium_session,
);
match identity_client.get_git_identity() {
Ok(identity) => {
if let Err(error) = materialize_git_identity(
&discovered.upper,
identity.as_ref(),
Some(discovered.manifest.agent_uid),
) {
eprintln!(
"session {}: git identity materialize: {error} (falling back to the image identity)",
discovered.session
);
}
}
Err(error) => {
eprintln!(
"session {}: git identity fetch: {error} (falling back to the image identity)",
discovered.session
);
}
}
}
let mounted = match mount_overlay(plan, Some(discovered.manifest.agent_uid)) {
Ok(plan) => plan,
Err(e) => {
Expand Down
52 changes: 52 additions & 0 deletions runtime/node-sync/src/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,58 @@ impl HttpAtriumClient {
.map_err(|e| HttpFeedError::Parse(e.to_string()))?;
parse_profile_bundles(&value, harness).map_err(HttpFeedError::Parse)
}

pub fn get_git_identity(&self) -> Result<Option<GitIdentity>, HttpFeedError> {
let resp = self
.agent
.get(&self.url("/git-identity"))
.set(AUTH_HEADER, &self.api_key)
.call()
.map_err(|e| http_feed_error("get git identity", e))?;
if resp.status() == 204 {
return Ok(None);
}
let value: serde_json::Value = resp
.into_json()
.map_err(|e| HttpFeedError::Parse(e.to_string()))?;
parse_git_identity(value)
.map(Some)
.map_err(HttpFeedError::Parse)
}
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitIdentity {
#[serde(default)]
pub author_name: String,
#[serde(default)]
pub author_email: String,
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub harness: String,
}

pub fn parse_git_identity(value: serde_json::Value) -> Result<GitIdentity, String> {
let identity = serde_json::from_value::<GitIdentity>(value)
.map_err(|e| format!("parse git-identity response: {e}"))?;
let missing = [
("authorName", identity.author_name.as_str()),
("authorEmail", identity.author_email.as_str()),
("sessionId", identity.session_id.as_str()),
("harness", identity.harness.as_str()),
]
.into_iter()
.filter_map(|(field, value)| value.is_empty().then_some(field))
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"parse git-identity response: missing fields: {}",
missing.join(", ")
));
}
Ok(identity)
}

#[derive(Debug)]
Expand Down
Loading
Loading