fix(missions): copy an allow-list, not the whole 1.7GB seed dir
Checking before deploying caught a mistake in the previous commit. The seed dir on gw-04 is 1.7 GB and the first version copied all of it per mission — tens of seconds each, and ~17 GB across ten concurrent missions. 1.5 GB of that is .rustup: a Rust toolchain that installed itself into the data dir back when HOME=/zeroclaw-data and the image had no toolchain. The image now ships Rust at /usr/local/cargo, which is what the container's PATH actually resolves — verified live. The data-dir copy is dead weight and is not even reachable. SEEDED_PATHS now copies only what carries per-mission identity or secrets: .zeroclaw (config.toml with the door token, sessions.db, devices.db), clawmates-mcp.json, .claude + .claude.json, .kimi-code, glm-home, agents. Roughly 46 MB instead of 1.7 GB — about 37x smaller. Caches and toolchains are deliberately excluded: .rustup, .npm, .cargo, .cache, .local. They hold no secrets and a mission reads the image's. Absent paths are tolerated: a fresh deployment has no .kimi-code until Kimi is first used, and that must not fail container creation. The test asserts both directions — the token-bearing paths ARE copied and the caches are NOT — because either mistake is silent: copying everything just makes missions slow, and copying nothing quietly restores the credential sharing. 409 tests, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
15e7608e4a
commit
0d9498ec6e
@@ -161,6 +161,51 @@ const MISSIONS_HOST_ROOT: &str = "/var/lib/clawmates-missions";
|
|||||||
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
|
const DEFAULT_SEED_DIR: &str = "/root/clawmates-runtime/data";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// What a mission gets its own copy of.
|
||||||
|
///
|
||||||
|
/// An allow-list, not the whole directory. The seed dir is **1.7 GB** on
|
||||||
|
/// gw-04 and 1.5 GB of that is a vestigial `.rustup` — a Rust toolchain that
|
||||||
|
/// installed itself into the data dir back when `HOME=/zeroclaw-data` and the
|
||||||
|
/// image had no toolchain. The image now ships Rust at `/usr/local/cargo`,
|
||||||
|
/// which is what the container's PATH actually resolves (verified live), so
|
||||||
|
/// that copy is dead weight. Copying it per mission would cost tens of
|
||||||
|
/// seconds and ~17 GB across ten concurrent missions.
|
||||||
|
///
|
||||||
|
/// So: copy what carries per-mission identity or secrets, and leave the
|
||||||
|
/// caches and toolchains behind.
|
||||||
|
const SEEDED_PATHS: &[&str] = &[
|
||||||
|
// The whole point: config.toml carries the §15 door bearer token, and
|
||||||
|
// data/ holds sessions.db + devices.db. ~26 MB.
|
||||||
|
".zeroclaw",
|
||||||
|
// Door MCP config — also a bearer token.
|
||||||
|
"clawmates-mcp.json",
|
||||||
|
// Claude Code's own state and credentials (~16 MB). Per-mission so a
|
||||||
|
// token refresh or project state in one mission cannot leak into another.
|
||||||
|
".claude",
|
||||||
|
".claude.json",
|
||||||
|
// Per-CLI state for the alternate backends; small.
|
||||||
|
".kimi-code",
|
||||||
|
"glm-home",
|
||||||
|
// The seeded agent library.
|
||||||
|
"agents",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Deliberately NOT copied — caches and toolchains, no secrets, expensive:
|
||||||
|
/// `.rustup` (1.5 GB, vestigial), `.npm` (85 MB), `.cargo`, `.cache`,
|
||||||
|
/// `.local`. A mission that needs them reads the image's copies.
|
||||||
|
fn copy_script() -> String {
|
||||||
|
let mut out = String::from("set -e\n");
|
||||||
|
for p in SEEDED_PATHS {
|
||||||
|
// Missing entries are normal — a fresh deployment has no .kimi-code
|
||||||
|
// until Kimi is first used — so absence must not fail the copy.
|
||||||
|
out.push_str(&format!(
|
||||||
|
"if [ -e '/seed/{p}' ]; then cp -a '/seed/{p}' /dst/; fi\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Give this mission its own copy of the runtime seed data.
|
/// Give this mission its own copy of the runtime seed data.
|
||||||
///
|
///
|
||||||
/// Every per-mission container used to bind-mount the SAME host seed dir as
|
/// Every per-mission container used to bind-mount the SAME host seed dir as
|
||||||
@@ -192,13 +237,7 @@ async fn seed_runtime_data(
|
|||||||
let config = ContainerCreateBody {
|
let config = ContainerCreateBody {
|
||||||
image: Some(image.to_string()),
|
image: Some(image.to_string()),
|
||||||
entrypoint: Some(vec!["/bin/sh".to_string()]),
|
entrypoint: Some(vec!["/bin/sh".to_string()]),
|
||||||
// `/seed/.` copies the directory CONTENTS, including dotfiles such as
|
cmd: Some(vec!["-c".to_string(), copy_script()]),
|
||||||
// `.zeroclaw/`. `/seed/*` would silently skip them and produce a
|
|
||||||
// runtime with no config at all.
|
|
||||||
cmd: Some(vec![
|
|
||||||
"-c".to_string(),
|
|
||||||
"cp -a /seed/. /dst/ 2>/dev/null || cp -a /seed/. /dst/".to_string(),
|
|
||||||
]),
|
|
||||||
host_config: Some(HostConfig {
|
host_config: Some(HostConfig {
|
||||||
mounts: Some(vec![
|
mounts: Some(vec![
|
||||||
Mount {
|
Mount {
|
||||||
@@ -1132,4 +1171,40 @@ allowed_tools = ["file_read", "file_edit"]
|
|||||||
"runtime data must not live inside the shared seed dir either"
|
"runtime data must not live inside the shared seed dir either"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The copy must be an allow-list, and must include the secret-bearing
|
||||||
|
/// paths while excluding the expensive ones.
|
||||||
|
///
|
||||||
|
/// Measured on gw-04: the seed dir is 1.7 GB, of which 1.5 GB is a
|
||||||
|
/// vestigial `.rustup` that is not even on the container's PATH (the
|
||||||
|
/// image ships Rust at /usr/local/cargo). Copying everything per mission
|
||||||
|
/// would cost tens of seconds and ~17 GB across ten concurrent missions —
|
||||||
|
/// which is what the first version of this did.
|
||||||
|
#[test]
|
||||||
|
fn the_seed_copy_takes_secrets_and_skips_caches() {
|
||||||
|
// The two paths that carry the door bearer token MUST be copied, or
|
||||||
|
// this whole change accomplishes nothing.
|
||||||
|
assert!(SEEDED_PATHS.contains(&".zeroclaw"));
|
||||||
|
assert!(SEEDED_PATHS.contains(&"clawmates-mcp.json"));
|
||||||
|
// Claude Code's credentials and state.
|
||||||
|
assert!(SEEDED_PATHS.contains(&".claude"));
|
||||||
|
|
||||||
|
// The expensive, secret-free ones must NOT be.
|
||||||
|
for cache in [".rustup", ".npm", ".cargo", ".cache"] {
|
||||||
|
assert!(
|
||||||
|
!SEEDED_PATHS.contains(&cache),
|
||||||
|
"{cache} is a cache and must not be copied per mission"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let script = copy_script();
|
||||||
|
// A missing entry is normal on a fresh deployment (no .kimi-code
|
||||||
|
// until Kimi is first used) and must not fail the copy.
|
||||||
|
assert!(script.contains("if [ -e "), "absent paths must be tolerated");
|
||||||
|
assert!(script.contains("/seed/.zeroclaw"));
|
||||||
|
assert!(!script.contains("/seed/.rustup"));
|
||||||
|
for p in SEEDED_PATHS {
|
||||||
|
assert!(script.contains(p), "{p} missing from the copy script");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user