fix(container): aws mode review round 1

Clone repos in the background after the agent starts (progress in
/run/triple-c-clone; an unfinished clone keeps the workspace from idling),
give the task definition stopTimeout 120 for the SIGTERM WIP push, reserve
the TRIPLEC_ env prefix on both hosts, and stop root writing through
claude-controlled links (~/.claude.json written as claude only via the exact
Hybrid link; ~/.ssh merged only through the exact Hybrid link, Docker paths
unchanged). Keep the user's ssh command and add BatchMode, cover dot-folders,
refuse undecodable or over-long file names by name, and upload LFS objects
before the --no-verify push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-15 09:59:00 -08:00
1 parent 51f71212d2
commit b1ac78613c
7 files changed
+447 -78

No files matched your search

+14 -1
View File
@@ -75,6 +75,9 @@ const KEY_WIP_PUSH: &str = "TRIPLEC_WIP_PUSH";
const KEY_IDLE: &str = "TRIPLEC_IDLE_STOP_MINUTES";
const KEY_STORAGE: &str = "TRIPLEC_STORAGE";
/// The task definition's whole `environment` (R2): constants, no project data.
/// Seconds ECS waits after SIGTERM before SIGKILL (Fargate allows 2–120).
pub const STOP_TIMEOUT_SECS: i32 = 120;
const FIXED_ENVIRONMENT: [(&str, &str); 2] = [("TRIPLEC_HOST", "aws"), ("COLORTERM", "truecolor")];
fn other(message: impl Into<String>) -> AwsError {
@@ -151,7 +154,13 @@ fn payload_map(
k.escape_debug()
));
}
if own.contains(&k.as_str()) || FIXED_ENVIRONMENT.iter().any(|(f, _)| f == k) {
// The whole `TRIPLEC_` namespace is the entrypoint's (the Docker side
// reserves it too: `RESERVED_ENV_PREFIXES`), not just the keys this
// payload writes — `TRIPLEC_AGENT_ADDR`, say, would rebind the agent.
if own.contains(&k.as_str())
|| FIXED_ENVIRONMENT.iter().any(|(f, _)| f == k)
|| k.to_ascii_uppercase().starts_with("TRIPLEC_")
{
log::warn!("Ignoring the environment variable {k}: Triple-C sets it on AWS");
continue;
}
@@ -326,6 +335,10 @@ fn build_request(plan: &WorkspacePlan) -> RegisterTaskDefinitionInput {
.name(CONTAINER_NAME)
.image(&plan.image)
.essential(true)
// SIGTERM → SIGKILL grace. The entrypoint's TERM trap pushes unsaved
// work (`triple-c-wip-push`, bounded at 90 s) before it exits; ECS's
// default 30 s would kill most pushes part-way. 120 is Fargate's max.
.stop_timeout(STOP_TIMEOUT_SECS)
.set_environment(Some(
FIXED_ENVIRONMENT
.iter()
+7
View File
@@ -210,6 +210,8 @@ fn request_shape_matches_the_global_constraints() {
assert_eq!(req.task_role_arn(), Some(plan.task_role_arn.as_str()));
assert_eq!(req.execution_role_arn(), Some(plan.exec_role_arn.as_str()));
assert_eq!(req.ephemeral_storage().unwrap().size_in_gib(), 21);
// Room for the TERM trap's WIP push (90 s) before SIGKILL.
assert_eq!(req.container_definitions()[0].stop_timeout(), Some(120));
let rp = req.runtime_platform().unwrap();
assert_eq!(rp.cpu_architecture(), Some(&CpuArchitecture::X8664));
assert_eq!(
@@ -481,6 +483,8 @@ fn payload_keeps_its_own_keys_over_custom_env() {
let env = pairs(&[
("TRIPLEC_AGENT_TOKEN", "forged"),
("TRIPLEC_HOST", "local"),
("TRIPLEC_AGENT_ADDR", "0.0.0.0:47900"),
("triplec_other", "x"),
("COLORTERM", "16"),
("OK", "1"),
]);
@@ -491,6 +495,9 @@ fn payload_keeps_its_own_keys_over_custom_env() {
// The task definition's fixed environment owns these two names.
assert!(!m.contains_key("TRIPLEC_HOST"));
assert!(!m.contains_key("COLORTERM"));
// The rest of the TRIPLEC_ namespace is the entrypoint's too.
assert!(!m.contains_key("TRIPLEC_AGENT_ADDR"));
assert!(!m.contains_key("triplec_other"));
assert_eq!(m["OK"], "1");
}
+238 -19
View File
@@ -109,7 +109,7 @@ fn files_script(tmp: &Path) -> String {
fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() {
let tmp = tempfile::tempdir().unwrap();
let files = format!(
r#"{{"ssh/id_ed25519":"{}","ssh/config":"{}","ca/corp.crt":"{}","ssh/..":"{}","ssh/a/b":"{}","ssh/":"{}","home/x":"{}","ca/bad":"%%%"}}"#,
r#"{{"ssh/id_ed25519":"{}","ssh/config":"{}","ca/corp.crt":"{}","ssh/..":"{}","ssh/a/b":"{}","ssh/":"{}","home/x":"{}","ca/bad":"%%%","ssh/{long}":"{}","ssh/\ud800":"{}"}}"#,
b64("PRIVATE-KEY-BODY"),
b64("Host *\n"),
b64("-----BEGIN CERTIFICATE-----"),
@@ -117,6 +117,9 @@ fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() {
b64("x"),
b64("x"),
b64("x"),
b64("x"),
b64("x"),
long = "a".repeat(300),
);
let repos = r#"[{"url":"https://example.invalid/r.git","branch":null,"folder":"r"}]"#;
let out = bash(
@@ -150,8 +153,18 @@ fn aws_files_decode_to_the_host_dirs_mode_600_and_leave_the_environment() {
assert_eq!(names, ["config", "id_ed25519"]);
assert_eq!(fs::read_dir(&ca).unwrap().count(), 1);
assert!(!tmp.path().join("a").exists() && !tmp.path().join("x").exists());
for bad in ["ssh/..", "ssh/a/b", "\"ssh/\"", "home/x", "ca/bad"] {
assert!(log.contains(&format!("refusing")) && log.contains(bad.trim_matches('"')), "{bad}: {log}");
let long_key = format!("ssh/{}", "a".repeat(300));
for (key, why) in [
("'ssh/..'", "not ssh/<name> or ca/<name>"),
("'ssh/a/b'", "not ssh/<name> or ca/<name>"),
("'ssh/'", "not ssh/<name> or ca/<name>"),
("'home/x'", "not ssh/<name> or ca/<name>"),
("'ca/bad'", "value is not base64"),
(&format!("'{long_key}'"), "File name too long"),
("'ssh/\\ud800'", "UnicodeEncodeError"),
] {
let line = format!("entrypoint: refusing file entry {key}: {why}");
assert!(log.lines().any(|l| l == line), "missing `{line}` in:\n{log}");
}
// Gone from the environment, and from the agent's snapshot of it.
@@ -260,21 +273,96 @@ fn link_persistent_home_without_the_mount_changes_nothing() {
assert!(fs::symlink_metadata(home.join(".claude")).unwrap().is_dir());
}
/// `write_claude_json` with the Hybrid mount at `<tmp>/mnt`, in aws mode or not.
fn write_claude_json(tmp: &Path, aws: bool) -> Output {
let mnt = tmp.join("mnt");
let blocks = aws_blocks(&["aws_env", "write_claude_json"])
.replace("triplec_home_mnt=/mnt/triple-c-home", &format!("triplec_home_mnt='{}'", mnt.display()));
// chown to a user the host may not have is noise here, not the point.
let script = format!(
"chown() {{ :; }}\n{blocks}\ntriplec_aws={}\nCLAUDE_JSON=\"$1/.claude.json\"\nwrite_claude_json '{{\"a\":1}}'\n",
if aws { 1 } else { 0 }
);
bash(&script, &[&tmp.join("home")], &[], &[])
}
fn hybrid_dirs(tmp: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
let (home, mnt) = (tmp.join("home"), tmp.join("mnt"));
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&mnt).unwrap();
(home, mnt)
}
#[test]
fn write_claude_json_keeps_a_linked_claude_json_linked() {
let tmp = tempfile::tempdir().unwrap();
let (home, persist) = (tmp.path().join("home"), tmp.path().join("mnt"));
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&persist).unwrap();
fs::write(persist.join(".claude.json"), "{}").unwrap();
std::os::unix::fs::symlink(persist.join(".claude.json"), home.join(".claude.json")).unwrap();
let f = block(ENTRYPOINT, "write_claude_json");
// chown to a user the host may not have is noise here, not the point.
let script = format!("chown() {{ :; }}\n{f}\nCLAUDE_JSON=\"$1/.claude.json\"\nwrite_claude_json '{{\"a\":1}}'\n");
let out = bash(&script, &[&home], &[], &[]);
let (home, mnt) = hybrid_dirs(tmp.path());
fs::write(mnt.join(".claude.json"), "{}").unwrap();
std::os::unix::fs::symlink(mnt.join(".claude.json"), home.join(".claude.json")).unwrap();
let out = write_claude_json(tmp.path(), true);
assert!(out.status.success(), "{}", text(&out));
assert!(fs::symlink_metadata(home.join(".claude.json")).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(persist.join(".claude.json")).unwrap().trim(), "{\"a\":1}");
assert_eq!(fs::read_to_string(mnt.join(".claude.json")).unwrap().trim(), "{\"a\":1}");
assert_eq!(mode(&mnt.join(".claude.json")), 0o600);
assert!(!mnt.join(".claude.json.triple-c-tmp").exists());
}
#[test]
fn write_claude_json_refuses_a_link_triple_c_did_not_make() {
let tmp = tempfile::tempdir().unwrap();
let (home, mnt) = hybrid_dirs(tmp.path());
// A link pointing somewhere else entirely.
let elsewhere = tmp.path().join("elsewhere");
fs::write(&elsewhere, "untouched").unwrap();
std::os::unix::fs::symlink(&elsewhere, home.join(".claude.json")).unwrap();
let out = write_claude_json(tmp.path(), true);
assert!(!out.status.success());
assert!(text(&out).contains("did not make"), "{}", text(&out));
assert_eq!(fs::read_to_string(&elsewhere).unwrap(), "untouched");
// The right link, but its target swapped for a link of its own.
fs::remove_file(home.join(".claude.json")).unwrap();
std::os::unix::fs::symlink(mnt.join(".claude.json"), home.join(".claude.json")).unwrap();
std::os::unix::fs::symlink(&elsewhere, mnt.join(".claude.json")).unwrap();
let out = write_claude_json(tmp.path(), true);
assert!(!out.status.success());
assert_eq!(fs::read_to_string(&elsewhere).unwrap(), "untouched");
}
#[test]
fn write_claude_json_outside_aws_writes_a_plain_file_as_before() {
let tmp = tempfile::tempdir().unwrap();
let (home, _mnt) = hybrid_dirs(tmp.path());
let out = write_claude_json(tmp.path(), false);
assert!(out.status.success(), "{}", text(&out));
assert!(fs::symlink_metadata(home.join(".claude.json")).unwrap().is_file());
assert_eq!(fs::read_to_string(home.join(".claude.json")).unwrap().trim(), "{\"a\":1}");
}
#[test]
fn the_hybrid_ssh_dir_is_used_only_through_the_link_triple_c_made() {
let tmp = tempfile::tempdir().unwrap();
let (home, mnt) = hybrid_dirs(tmp.path());
let check = |home: &Path| {
let blocks = aws_blocks(&["aws_env", "aws_home"])
.replace("triplec_home_mnt=/mnt/triple-c-home", &format!("triplec_home_mnt='{}'", mnt.display()));
bash(&format!("{blocks}\naws_hybrid_ssh_dir \"$1\""), &[home], &[], &[])
};
fs::create_dir_all(mnt.join(".ssh")).unwrap();
std::os::unix::fs::symlink(mnt.join(".ssh"), home.join(".ssh")).unwrap();
let out = check(&home);
assert!(out.status.success(), "{}", text(&out));
assert_eq!(String::from_utf8_lossy(&out.stdout), mnt.join(".ssh").to_str().unwrap());
// Persisted .ssh replaced by a link (to /etc, say): refused.
fs::remove_dir(mnt.join(".ssh")).unwrap();
std::os::unix::fs::symlink(tmp.path(), mnt.join(".ssh")).unwrap();
assert!(!check(&home).status.success());
// ~/.ssh linked anywhere else: refused.
fs::remove_file(home.join(".ssh")).unwrap();
std::os::unix::fs::symlink(tmp.path(), home.join(".ssh")).unwrap();
assert!(!check(&home).status.success());
}
// --------------------------------------------------------------- clone_repos
@@ -307,11 +395,18 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() {
{{"url":"{r}","branch":"dev","folder":"dev-copy"}},
{{"url":"/nonexistent/nope.git","branch":null,"folder":"existing"}},
{{"url":"/nonexistent/nope.git","branch":null,"folder":"broken"}},
{{"url":"{r}","branch":null,"folder":"../escape"}}]"#,
{{"url":"{r}","branch":null,"folder":"../escape"}},
{{"url":"{r}","branch":null,"folder":"bad\ud800"}},
{{"url":"{r}","branch":null,"folder":".dotted"}}]"#,
r = remote.display()
);
let script = format!("{}\nclone_repos \"$JSON\" \"$1\"\necho \"rc=$?\"\n", aws_blocks(&["aws_env", "aws_repos"]));
let out = bash(&script, &[&ws], &[("JSON", &json)], &[]);
let status = tmp.path().join("status");
fs::create_dir_all(&status).unwrap();
let script = format!(
"{}\nclone_repos \"$JSON\" \"$1\" \"$2\"\necho \"rc=$?\"\n",
aws_blocks(&["aws_env", "aws_repos"])
);
let out = bash(&script, &[&ws, &status], &[("JSON", &json)], &[]);
let log = text(&out);
assert!(out.status.success(), "{log}");
assert!(log.contains("rc=0"), "{log}");
@@ -321,7 +416,42 @@ fn clone_repos_clones_skips_existing_and_reports_failures_without_failing() {
assert!(log.contains("Triple-C: clone of broken failed:"), "{log}");
assert!(!log.contains("clone of existing failed"), "{log}");
assert!(log.contains("../escape"), "a bad folder is refused by name: {log}");
assert!(log.contains("bad folder 'bad\\ud800'"), "{log}");
assert!(!tmp.path().join("escape").exists());
assert_eq!(fs::read_to_string(ws.join(".dotted/README")).unwrap(), "hello");
assert_eq!(
fs::read_to_string(status.join("repos")).unwrap(),
"main-copy\tcloned\ndev-copy\tcloned\nexisting\texists\nbroken\tfailed\n.dotted\tcloned\n"
);
}
/// A stand-in `ssh` that records how it was called and fails.
fn fake_ssh(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
let log = dir.join("ssh-calls");
let ssh = write_script(dir, "fake-ssh", &format!("#!/bin/sh\necho \"$*\" >> '{}'\nexit 255\n", log.display()));
(ssh, log)
}
#[test]
fn clone_repos_keeps_the_users_ssh_command_and_adds_batch_mode() {
let tmp = tempfile::tempdir().unwrap();
let (ssh, calls) = fake_ssh(tmp.path());
let home = tmp.path().join("home");
fs::create_dir_all(&home).unwrap();
fs::write(home.join(".gitconfig"), format!("[core]\n\tsshCommand = sh {} -o User=me\n", ssh.display())).unwrap();
let ws = tmp.path().join("ws");
fs::create_dir_all(&ws).unwrap();
let json = r#"[{"url":"ssh://git@example.invalid/r.git","branch":null,"folder":"r"}]"#;
let script = format!("{}\nclone_repos \"$JSON\" \"$1\"\n", aws_blocks(&["aws_env", "aws_repos"]));
let out = bash(
&script,
&[&ws],
&[("JSON", json), ("HOME", home.to_str().unwrap()), ("GIT_CONFIG_NOSYSTEM", "1")],
&["GIT_SSH_COMMAND", "GIT_SSH"],
);
assert!(text(&out).contains("clone of r failed"), "{}", text(&out));
let calls = fs::read_to_string(&calls).unwrap();
assert!(calls.contains("-o User=me -o BatchMode=yes"), "{calls}");
}
#[test]
@@ -345,7 +475,10 @@ fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
fn wip_push(ws: &Path, args: &[&str]) -> Output {
let tmp = ws.parent().unwrap();
let script = write_script(tmp, "triple-c-wip-push", WIP_PUSH);
Command::new(script)
// Through bash rather than exec'd: a script written a moment ago can be
// ETXTBSY to exec while a parallel test's fork still holds it open.
Command::new("bash")
.arg(script)
.args(args)
.env("TRIPLEC_WORKSPACE_ROOT", ws)
.env("GIT_CONFIG_NOSYSTEM", "1")
@@ -481,6 +614,65 @@ fn wip_push_fails_when_a_push_fails() {
assert!(text(&out).contains("proj"), "{}", text(&out));
}
#[test]
fn wip_push_covers_dot_folders() {
let tmp = tempfile::tempdir().unwrap();
let (ws, _repo, remote) = workspace_with_clone(tmp.path());
git(&ws, &["clone", "-q", remote.to_str().unwrap(), ".hidden"]);
fs::write(ws.join(".hidden/y"), "y").unwrap();
let out = wip_push(&ws, &["--dry-run"]);
assert!(String::from_utf8_lossy(&out.stdout).contains(r#""folder":".hidden""#), "{}", text(&out));
let out = wip_push(&ws, &[]);
assert!(out.status.success(), "{}", text(&out));
assert_eq!(wip_refs(&remote).len(), 1);
}
#[test]
fn wip_push_keeps_the_repos_ssh_command_and_adds_batch_mode() {
let tmp = tempfile::tempdir().unwrap();
let (ws, repo, _remote) = workspace_with_clone(tmp.path());
let (ssh, calls) = fake_ssh(tmp.path());
git(&repo, &["config", "core.sshCommand", &format!("sh {} -o User=me", ssh.display())]);
git(&repo, &["remote", "set-url", "origin", "ssh://git@example.invalid/r.git"]);
fs::write(repo.join("y"), "y").unwrap();
let out = wip_push(&ws, &[]);
assert!(!out.status.success(), "the fake ssh fails the push");
let calls = fs::read_to_string(&calls).unwrap();
assert!(calls.contains("-o User=me -o BatchMode=yes"), "{calls}");
}
#[test]
fn wip_push_uploads_lfs_objects_before_the_no_verify_push() {
let tmp = tempfile::tempdir().unwrap();
let (ws, repo, remote) = workspace_with_clone(tmp.path());
// A stand-in git-lfs: present, reports one LFS file, records the push.
let bin = tmp.path().join("bin");
fs::create_dir_all(&bin).unwrap();
let log = tmp.path().join("lfs-calls");
write_script(
&bin,
"git-lfs",
&format!(
"#!/bin/sh\ncase \"$1\" in\n version) echo fake;;\n ls-files) echo big.bin;;\n push) echo \"$*\" >> '{}';;\nesac\n",
log.display()
),
);
fs::write(repo.join("y"), "y").unwrap();
let script = write_script(tmp.path(), "triple-c-wip-push", WIP_PUSH);
let out = Command::new("bash")
.arg(script)
.env("TRIPLEC_WORKSPACE_ROOT", &ws)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", tmp.path())
.env("PATH", format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()))
.output()
.unwrap();
assert!(out.status.success(), "{}", text(&out));
let refs = wip_refs(&remote);
let pushed = git(&remote, &["rev-parse", &refs[0]]);
assert_eq!(fs::read_to_string(&log).unwrap().trim(), format!("push origin {pushed}"));
}
#[test]
fn wip_push_rejects_an_unknown_reason() {
let tmp = tempfile::tempdir().unwrap();
@@ -538,7 +730,8 @@ impl Watch {
fn cmd(&self, agent_url: &str, signal_pid: u32) -> Command {
let p = self.tmp.path();
let mut c = Command::new(p.join("triple-c-idle-watch"));
let mut c = Command::new("bash");
c.arg(p.join("triple-c-idle-watch"));
c.env_clear()
.env("PATH", std::env::var("PATH").unwrap())
.env("HOME", p.join("home"))
@@ -589,6 +782,20 @@ s.serve_forever()
(format!("http://127.0.0.1:{}", line.trim()), child)
}
#[test]
fn idle_watch_check_counts_an_unfinished_clone_as_busy() {
let w = Watch::new();
let (url, mut agent) = fake_agent(w.tmp.path(), r#"{"last_activity_unix":1,"active_execs":0,"active_tunnels":0}"#);
let clone = w.tmp.path().join("clone");
fs::create_dir_all(&clone).unwrap();
let busy = w.cmd(&url, 0).env("TRIPLEC_CLONE_STATUS_DIR", &clone).arg("--check").output().unwrap();
fs::write(clone.join("done"), "").unwrap();
let idle = w.cmd(&url, 0).env("TRIPLEC_CLONE_STATUS_DIR", &clone).arg("--check").output().unwrap();
agent.kill().ok();
assert_eq!(busy.status.code(), Some(1), "{}", text(&busy));
assert_eq!(idle.status.code(), Some(0), "{}", text(&idle));
}
#[test]
fn idle_watch_check_treats_an_unreachable_agent_as_busy() {
let w = Watch::new();
@@ -689,6 +896,7 @@ fn docker_mode_never_reaches_the_aws_blocks() {
"triple-c-idle-watch",
"clone_repos",
"link_persistent_home",
"aws_hybrid_ssh_dir",
"aws_before_stop",
"start_idle_watch",
];
@@ -706,7 +914,18 @@ fn docker_mode_never_reaches_the_aws_blocks() {
"unguarded call outside the aws blocks: {line}"
);
}
assert_eq!(seen, 4, "the four call sites: Hybrid link, clone, idle watch, stop");
assert_eq!(seen, 5, "the call sites: Hybrid link, Hybrid ~/.ssh, clone, idle watch, stop");
// Clones start in the background *after* the agent: the app gives a
// starting task's agent only seconds to answer.
let pos = |needle: &str| {
lines
.iter()
.position(|l| l.trim_start().starts_with(needle))
.unwrap_or_else(|| panic!("{needle}"))
};
let agent_call = lines.iter().position(|l| l.trim() == "start_agent").expect("start_agent call");
assert!(agent_call < pos("start_clone_repos"), "clones must start after the agent");
assert!(block(ENTRYPOINT, "aws_repos").contains(") &"), "start_clone_repos backgrounds the clone");
// And the snapshot-sensitive variables are gone before the agent's
// environment snapshot is taken.
assert!(ENTRYPOINT.find("unset TRIPLEC_FILES").unwrap() < ENTRYPOINT.find("# >>> agent_token").unwrap());
+21 -1
View File
@@ -245,7 +245,11 @@ pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub";
/// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile.
pub(crate) const LABEL_BASE: &str = "triple-c.base";
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
/// `TRIPLEC_` is the AWS workspace's own namespace (`TRIPLEC_HOST`,
/// `TRIPLEC_FILES`, `TRIPLEC_AGENT_TOKEN`, … — see `container/entrypoint.sh`):
/// a custom var there could switch a local container into AWS mode or forge
/// the inputs the entrypoint trusts.
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_", "TRIPLEC_"];
/// Exact env var names Triple-C manages itself. Not covered by
/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes.
@@ -4096,6 +4100,22 @@ mod tests {
assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0");
}
#[test]
fn the_aws_workspace_namespace_is_reserved_from_custom_env() {
for key in ["TRIPLEC_HOST", "TRIPLEC_FILES", "TRIPLEC_AGENT_TOKEN", "triplec_repos", "TRIPLEC_ANYTHING"] {
assert!(is_reserved_env_key(key), "{key}");
}
assert_eq!(
compute_env_fingerprint(&[EnvVar {
key: "TRIPLEC_HOST".to_string(),
value: "aws".to_string(),
}]),
"",
"a reserved key never reaches the container"
);
assert!(!is_reserved_env_key("TRIPLECX"));
}
#[test]
fn the_vpn_skill_flag_is_reserved_from_custom_env() {
// entrypoint.sh installs and removes the pia-vpn skill from this
+132 -51
View File
@@ -34,6 +34,8 @@
triplec_aws=0
triplec_run_as=claude
triplec_repos_json=
# Where Hybrid storage mounts the project's EFS access point.
triplec_home_mnt=/mnt/triple-c-home
# stdin: the TRIPLEC_FILES JSON; $1 the ssh dir, $2 the CA dir. Names are
# validated (non-empty, no `/`, not `.`/`..`, no NUL) and anything else is
@@ -54,27 +56,21 @@ except ValueError:
if not isinstance(files, dict):
say("TRIPLEC_FILES is not a JSON object; no files written")
sys.exit(1)
bad = 0
for key, value in files.items():
def write(key, value):
kind, sep, name = key.partition("/")
if kind not in dirs or not sep or name in ("", ".", "..") or "/" in name or "\0" in name:
say("refusing file entry %r: not ssh/<name> or ca/<name>" % key)
bad += 1
continue
return "not ssh/<name> or ca/<name>"
name.encode("utf-8") # a lone surrogate cannot name a file
try:
data = base64.b64decode(value, validate=True) if isinstance(value, str) else None
except (binascii.Error, ValueError):
data = None
if data is None:
say("refusing file entry %r: value is not base64" % key)
bad += 1
continue
return "value is not base64"
d = dirs[kind]
os.makedirs(d, mode=0o700, exist_ok=True)
if not stat.S_ISDIR(os.lstat(d).st_mode):
say("refusing file entry %r: %s is not a directory" % (key, d))
bad += 1
continue
return "%s is not a directory" % d
os.chmod(d, 0o700)
if root:
os.chown(d, 0, 0)
@@ -84,6 +80,16 @@ for key, value in files.items():
if root:
os.fchown(f.fileno(), 0, 0)
f.write(data)
return None
bad = 0
for key, value in files.items():
try:
why = write(key, value)
except (OSError, UnicodeError, ValueError) as e:
why = "%s" % (e.strerror if isinstance(e, OSError) and e.strerror else type(e).__name__)
if why:
say("refusing file entry %r: %s" % (key, why))
bad += 1
sys.exit(1 if bad else 0)
' "$1" "$2"
}
@@ -220,10 +226,14 @@ find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude:
# >>> aws_home
link_persistent_home() {
local home="$1" persist="$2" owner="$3" name link target
if [ ! -d "$persist" ]; then
if [ ! -d "$persist" ] || [ -L "$persist" ]; then
echo "entrypoint: warning — Hybrid storage, but $persist is not mounted; home state will not persist"
return 0
fi
# The access point already makes its root 1000:1000; said again here so
# claude (Claude Code's own atomic saves, write_claude_json) can always
# create files beside the persisted ones.
[ -z "$owner" ] || chown "$owner" "$persist" 2>/dev/null
for name in .claude .claude.json .ssh; do
link="$home/$name"
target="$persist/$name"
@@ -254,9 +264,21 @@ link_persistent_home() {
done
echo "entrypoint: Hybrid storage — ~/.claude, ~/.claude.json and ~/.ssh persist on $persist"
}
# The real directory behind a Hybrid ~/.ssh link, printed only when the link
# is exactly the one link_persistent_home makes and neither it nor the mount
# is itself a symlink. Root chowns and chmods under the result, and the
# persisted directory is claude-writable, so root must never be pointed
# anywhere else by it.
aws_hybrid_ssh_dir() {
local home="$1" want="$triplec_home_mnt/.ssh"
[ "$(readlink "$home/.ssh")" = "$want" ] || return 1
[ ! -L "$triplec_home_mnt" ] && [ ! -L "$want" ] && [ -d "$want" ] || return 1
printf '%s' "$want"
}
# <<< aws_home
if [ "$triplec_aws" = 1 ] && [ "${TRIPLEC_STORAGE:-}" = hybrid ]; then
link_persistent_home /home/claude /mnt/triple-c-home claude:claude
link_persistent_home /home/claude "$triplec_home_mnt" claude:claude
fi
# ── Corporate CA certificates ───────────────────────────────────────────────
@@ -509,20 +531,29 @@ sync_host_ssh() {
}
# <<< sync_host_ssh
if [ -d /tmp/.host-ssh ]; then
sync_host_ssh /tmp/.host-ssh /home/claude/.ssh
# Trailing slashes: on AWS Hybrid ~/.ssh is a symlink, and without them
# `chown -R` and `find` would stop at the link itself.
chown -R claude:claude /home/claude/.ssh/
chmod 700 /home/claude/.ssh
find /home/claude/.ssh/ -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \;
find /home/claude/.ssh/ -type f -name "*.triple-c-backup" -exec chmod 600 {} \;
find /home/claude/.ssh/ -type f -name "*.pub" -exec chmod 644 {} \;
if [ -f /home/claude/.ssh/known_hosts ]; then
chmod 644 /home/claude/.ssh/known_hosts
# On AWS Hybrid ~/.ssh is a link into the EFS mount; the root steps below then
# work on the real directory behind it, and only after aws_hybrid_ssh_dir has
# confirmed the link is the one Triple-C made. Any other link: nothing is
# merged. Docker: always /home/claude/.ssh, as before.
ssh_home_dir=/home/claude/.ssh
if [ "$triplec_aws" = 1 ] && [ -L "$ssh_home_dir" ]; then
ssh_home_dir=$(aws_hybrid_ssh_dir /home/claude) || {
echo "entrypoint: warning — ~/.ssh is a symlink Triple-C did not make; host SSH files not merged"
ssh_home_dir=""
}
fi
if [ -n "$ssh_home_dir" ] && [ -d /tmp/.host-ssh ]; then
sync_host_ssh /tmp/.host-ssh "$ssh_home_dir"
chown -R claude:claude "$ssh_home_dir"
chmod 700 "$ssh_home_dir"
find "$ssh_home_dir" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \;
find "$ssh_home_dir" -type f -name "*.triple-c-backup" -exec chmod 600 {} \;
find "$ssh_home_dir" -type f -name "*.pub" -exec chmod 644 {} \;
if [ -f "$ssh_home_dir/known_hosts" ]; then
chmod 644 "$ssh_home_dir/known_hosts"
fi
if [ -f /home/claude/.ssh/config ]; then
chmod 600 /home/claude/.ssh/config
if [ -f "$ssh_home_dir/config" ]; then
chmod 600 "$ssh_home_dir/config"
fi
fi
@@ -595,21 +626,30 @@ chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
# ── AWS: clone the project's repositories ───────────────────────────────────
# An AWS workspace has no project folder to mount; its repositories are cloned
# into /workspace/<folder> on start. Here, after the credential helper, git
# identity and ~/.ssh are in place, as `claude`, with prompts off and a
# per-repo time limit. A folder that is already a repository (Hybrid keeps
# nothing under /workspace today, but a restarted container would) is left
# alone. Failures are reported and never stop the start: a workspace with one
# repo missing is still a workspace.
# into /workspace/<folder> once the credential helper, git identity and ~/.ssh
# are in place — as `claude`, with prompts off and a per-repo time limit. A
# folder that is already a repository is left alone. Failures are reported and
# never stop the start: a workspace with one repo missing is still a workspace.
#
# In the background, started after the agent (start_clone_repos below): the
# app gives a starting task's agent only seconds to answer, so a clone in the
# way of the agent would fail every start with a large repository. Terminals
# work while the repos arrive. Progress, for the app to show later, is in
# /run/triple-c-clone (world-readable, root-written): `repos` gets one
# "<folder>\t<cloned|failed|exists>" line per repo, and `done` appears when
# all are finished. The idle watcher counts an unfinished clone as activity.
# >>> aws_repos
# clone_repos json root [status_dir]
clone_repos() {
local json="$1" root="$2" folder url branch dest existed err rc
local json="$1" root="$2" status_dir="${3:-}" folder url branch dest existed err rc
local limit="${triplec_clone_timeout:-600}"
[ -n "$json" ] || return 0
_clone_status() { [ -z "$status_dir" ] || printf '%s\t%s\n' "$1" "$2" >> "$status_dir/repos"; }
while IFS= read -r -d '' folder && IFS= read -r -d '' url && IFS= read -r -d '' branch; do
dest="$root/$folder"
if [ -e "$dest/.git" ]; then
echo "entrypoint: /workspace/$folder is already a repository; not cloning"
_clone_status "$folder" exists
continue
fi
existed=0
@@ -618,17 +658,24 @@ clone_repos() {
[ -n "$branch" ] && args+=(--branch "$branch")
args+=(-- "$url" "$dest")
echo "entrypoint: cloning $folder..."
err=$(_triplec_as_user env GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="ssh -o BatchMode=yes" \
timeout -k 10 "$limit" git "${args[@]}" 2>&1 </dev/null)
# BatchMode is appended to whatever ssh command the user's git config
# names (core.sshCommand), read as the user, rather than replacing it.
err=$(_triplec_as_user sh -c '
limit=$1; shift
c=${GIT_SSH_COMMAND:-$(git config --get core.sshCommand)}
export GIT_SSH_COMMAND="${c:-ssh} -o BatchMode=yes" GIT_TERMINAL_PROMPT=0
exec timeout -k 10 "$limit" git "$@"' _ "$limit" "${args[@]}" 2>&1 </dev/null)
rc=$?
if [ "$rc" -eq 0 ]; then
echo "entrypoint: cloned $folder"
_clone_status "$folder" cloned
continue
fi
[ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] && err="timed out after ${limit}s"
# Don't leave a half-made clone that the next start would skip.
[ "$existed" = 1 ] || rm -rf "$dest"
echo "Triple-C: clone of $folder failed: $(printf '%s' "$err" | tail -n 1)"
_clone_status "$folder" failed
done < <(printf '%s' "$json" | python3 -I -c '
import json, sys
def say(msg):
@@ -642,14 +689,20 @@ if not isinstance(repos, list):
say("TRIPLEC_REPOS is not a JSON array; nothing cloned")
sys.exit(0)
def text(v):
return isinstance(v, str) and "\0" not in v
if not isinstance(v, str) or "\0" in v or "\t" in v or "\n" in v:
return False
try:
v.encode("utf-8")
except UnicodeError:
return False
return True
for i, r in enumerate(repos):
if not isinstance(r, dict):
say("skipping repository %d: not an object" % i)
continue
url, branch, folder = r.get("url"), r.get("branch"), r.get("folder")
if not text(folder) or folder in ("", ".", "..") or "/" in folder:
say("skipping repository %d: bad folder %r" % (i, folder))
say("skipping repository %d: bad folder %a" % (i, folder))
continue
if not text(url) or not url:
say("skipping %s: bad url" % folder)
@@ -659,12 +712,23 @@ for i, r in enumerate(repos):
continue
sys.stdout.write("%s\0%s\0%s\0" % (folder, url, branch or ""))
')
unset -f _clone_status
return 0
}
# Runs clone_repos in the background, recording progress in $2.
start_clone_repos() {
local json="$1" status_dir="$2"
[ -n "$json" ] || return 0
rm -rf "$status_dir"
mkdir -m 755 "$status_dir" || status_dir=""
(
trap '' HUP
clone_repos "$json" /workspace "$status_dir"
[ -z "$status_dir" ] || : > "$status_dir/done"
) &
}
# <<< aws_repos
if [ "$triplec_aws" = 1 ]; then
clone_repos "$triplec_repos_json" /workspace
fi
# ── Claude instructions ──────────────────────────────────────────────────────
if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
@@ -826,27 +890,40 @@ fi
# skips the write that would have repaired it. `triple-c-task-runner` has done
# it this way all along.
#
# A linked ~/.claude.json (AWS Hybrid storage) is written where the link
# points: renaming over the link itself would swap it for a plain file and the
# state would quietly stop persisting.
# On AWS Hybrid storage ~/.claude.json is a link into the EFS mount, and
# renaming over the link would swap it for a plain file and the state would
# quietly stop persisting. So the write goes to the link's target — but only
# when the link is exactly the one link_persistent_home makes and its target
# is not itself a link, and it is done *as claude*: that directory is
# claude-writable, so root must not write, rename or chown anything in it.
# Docker mode never takes that branch.
# >>> write_claude_json
write_claude_json() {
_wcj_dest="$CLAUDE_JSON"
if [ -L "$_wcj_dest" ]; then
_wcj_dest=$(readlink -f "$CLAUDE_JSON")
[ -n "$_wcj_dest" ] || _wcj_dest="$CLAUDE_JSON"
if [ "${triplec_aws:-0}" = 1 ] && [ -L "$CLAUDE_JSON" ]; then
_wcj_dest="$triplec_home_mnt/.claude.json"
if [ "$(readlink "$CLAUDE_JSON")" != "$_wcj_dest" ] || [ -L "$triplec_home_mnt" ] || [ -L "$_wcj_dest" ]; then
echo "entrypoint: warning — $CLAUDE_JSON is a symlink Triple-C did not make; not writing through it"
return 1
fi
if printf '%s\n' "$1" | _triplec_as_user sh -c \
'umask 077; cat > "$1.triple-c-tmp" && mv -f "$1.triple-c-tmp" "$1"' _ "$_wcj_dest"; then
return 0
fi
_triplec_as_user rm -f "$_wcj_dest.triple-c-tmp"
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
return 1
fi
_wcj_tmp="${_wcj_dest}.triple-c-tmp"
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
mv -f "$_wcj_tmp" "$_wcj_dest" 2>/dev/null || rm -f "$_wcj_tmp"
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
else
rm -f "$_wcj_tmp"
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
return 1
fi
# By name, after the rename, so these land on the new inode.
chown claude:claude "$_wcj_dest"
chmod 600 "$_wcj_dest"
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
}
# <<< write_claude_json
@@ -1149,6 +1226,10 @@ start_idle_watch() {
if [ "$triplec_aws" = 1 ] && [ -n "${TRIPLEC_IDLE_STOP_MINUTES:-}" ]; then
start_idle_watch "$TRIPLEC_IDLE_STOP_MINUTES" "$$"
fi
# Repos are cloned in the background from here on — see clone_repos.
if [ "$triplec_aws" = 1 ]; then
start_clone_repos "$triplec_repos_json" /run/triple-c-clone
fi
echo "Triple-C container ready."
keepalive_pid=""
+14 -3
View File
@@ -9,7 +9,9 @@
# * the agent's /v1/activity (read with the agent's own token file) shows
# no authenticated activity for N minutes, no open exec, no open tunnel;
# * no `claude` process is running;
# * no scheduler job is running (`triple-c-scheduler status`, as claude).
# * no scheduler job is running (`triple-c-scheduler status`, as claude);
# * the start's background repository clone has finished (entrypoint.sh,
# start_clone_repos: /run/triple-c-clone exists without `done`).
#
# Anything it cannot read counts as NOT idle: a stop is the one thing here
# that cannot be undone, so every doubt keeps the workspace running.
@@ -39,6 +41,7 @@
# TRIPLEC_SCHEDULER_BIN default /usr/local/bin/triple-c-scheduler
# TRIPLEC_SCHEDULER_USER default claude; empty runs it as this user
# TRIPLEC_IDLE_PROC_NAME default claude
# TRIPLEC_CLONE_STATUS_DIR default /run/triple-c-clone
set -u
@@ -51,6 +54,7 @@ reason_file="${TRIPLEC_STOP_REASON_FILE:-/run/triple-c/stop-reason}"
scheduler="${TRIPLEC_SCHEDULER_BIN:-/usr/local/bin/triple-c-scheduler}"
scheduler_user="${TRIPLEC_SCHEDULER_USER-claude}"
proc_name="${TRIPLEC_IDLE_PROC_NAME:-claude}"
clone_dir="${TRIPLEC_CLONE_STATUS_DIR:-/run/triple-c-clone}"
# >>> is_idle
# is_idle now last_activity_unix active_execs active_tunnels claude_running jobs_running minutes
@@ -95,12 +99,19 @@ jobs_running() {
if [ "$out" = "Nothing running." ]; then echo 0; else echo 1; fi
}
# A clone still under way counts as a running job.
clone_running() {
[ -d "$clone_dir" ] && [ ! -e "$clone_dir/done" ]
}
check() {
local act last execs tunnels claude
local act last execs tunnels claude jobs
act=$(read_activity) || return 1
read -r last execs tunnels <<< "$act"
if pgrep -x "$proc_name" >/dev/null 2>&1; then claude=1; else claude=0; fi
is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$(jobs_running)" "$minutes"
jobs=$(jobs_running)
clone_running && jobs=1
is_idle "$(date +%s)" "${last:-}" "${execs:-}" "${tunnels:-}" "$claude" "$jobs" "$minutes"
}
case "$minutes" in
+21 -3
View File
@@ -46,9 +46,8 @@ while [ $# -gt 0 ]; do
done
export GIT_TERMINAL_PROMPT=0 GIT_OPTIONAL_LOCKS=0
# BatchMode: an unknown host key or a passphrase prompt fails at once instead
# of waiting for input nobody will give. Keeps any GIT_SSH_COMMAND the user set.
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes"
# Dot-folders too: clone_repos accepts a folder named `.x`.
shopt -s dotglob nullglob
stamp=$(date -u +%Y%m%d-%H%M%S)
failed=0
@@ -63,6 +62,8 @@ pick_remote() {
if [ "$(git remote | wc -l)" -eq 1 ]; then git remote; fi
}
user_ssh_command="${GIT_SSH_COMMAND:-}"
for dir in "$root"/*/; do
dir="${dir%/}"
[ -d "$dir" ] || continue
@@ -74,6 +75,12 @@ for dir in "$root"/*/; do
(
cd "$dir" || exit 1
# BatchMode: an unknown host key or a passphrase prompt fails at once
# instead of waiting for input nobody will give. Appended to the ssh
# command the user chose (GIT_SSH_COMMAND, else this repo's
# core.sshCommand), never instead of it.
ssh_cmd="${user_ssh_command:-$(git config --get core.sshCommand)}"
export GIT_SSH_COMMAND="${ssh_cmd:-ssh} -o BatchMode=yes"
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
branch=$(git symbolic-ref -q --short HEAD 2>/dev/null) || branch=HEAD
@@ -134,6 +141,17 @@ for dir in "$root"/*/; do
fi
ref="refs/heads/triple-c/wip/$stamp"
# --no-verify skips every pre-push hook, Git LFS's upload among them.
# So when git-lfs is here and the snapshot has LFS files, upload their
# objects first — best effort: the push still goes, with a warning.
if git lfs version >/dev/null 2>&1 \
&& [ -n "$(git lfs ls-files --name-only "$commit" 2>/dev/null | head -n 1)" ]; then
if lfs_err=$(git lfs push "$remote" "$commit" 2>&1); then
echo "triple-c-wip-push: $folder: uploaded LFS objects to $remote"
else
echo "triple-c-wip-push: $folder: warning — LFS upload failed, large files may be missing on the branch: $(printf '%s' "$lfs_err" | tail -n 1)" >&2
fi
fi
if err=$(git push --no-verify -q "$remote" "$commit:$ref" 2>&1); then
echo "triple-c-wip-push: $folder: pushed to $remote $ref"
else