From b1ac78613c2a79ac85f9ec32a2a88c280ef1a56d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 09:59:00 -0800 Subject: [PATCH] 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 --- app/src-tauri/src/aws/workspace.rs | 15 +- app/src-tauri/src/aws/workspace/tests.rs | 7 + app/src-tauri/src/docker/aws_mode_tests.rs | 257 +++++++++++++++++++-- app/src-tauri/src/docker/container.rs | 22 +- container/entrypoint.sh | 183 +++++++++++---- container/triple-c-idle-watch | 17 +- container/triple-c-wip-push | 24 +- 7 files changed, 447 insertions(+), 78 deletions(-) diff --git a/app/src-tauri/src/aws/workspace.rs b/app/src-tauri/src/aws/workspace.rs index 2da99b5..0261175 100644 --- a/app/src-tauri/src/aws/workspace.rs +++ b/app/src-tauri/src/aws/workspace.rs @@ -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) -> 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() diff --git a/app/src-tauri/src/aws/workspace/tests.rs b/app/src-tauri/src/aws/workspace/tests.rs index 94b9cc0..4b0bfc0 100644 --- a/app/src-tauri/src/aws/workspace/tests.rs +++ b/app/src-tauri/src/aws/workspace/tests.rs @@ -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"); } diff --git a/app/src-tauri/src/docker/aws_mode_tests.rs b/app/src-tauri/src/docker/aws_mode_tests.rs index 83e9601..5a79efa 100644 --- a/app/src-tauri/src/docker/aws_mode_tests.rs +++ b/app/src-tauri/src/docker/aws_mode_tests.rs @@ -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/ or ca/"), + ("'ssh/a/b'", "not ssh/ or ca/"), + ("'ssh/'", "not ssh/ or ca/"), + ("'home/x'", "not ssh/ or ca/"), + ("'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 `/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()); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 701026c..ffeb841 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -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 diff --git a/container/entrypoint.sh b/container/entrypoint.sh index cc71f91..91ad73c 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -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/ or ca/" % key) - bad += 1 - continue + return "not ssh/ or ca/" + 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/ 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/ 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 +# "\t" 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 &1 "$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="" diff --git a/container/triple-c-idle-watch b/container/triple-c-idle-watch index 589819c..c50c5df 100755 --- a/container/triple-c-idle-watch +++ b/container/triple-c-idle-watch @@ -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 diff --git a/container/triple-c-wip-push b/container/triple-c-wip-push index b83f5db..42489aa 100755 --- a/container/triple-c-wip-push +++ b/container/triple-c-wip-push @@ -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