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:
1 parent
51f71212d2
commit
b1ac78613c
7 files changed
+447
-78
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user