diff --git a/.dockerignore b/.dockerignore index d682daf49..0ffef0451 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,10 +8,15 @@ playwright-report/ coverage/ .nyc_output/ -# Retired backend source/test roots must not enter the Rust-only source image. +# Retired Node backend roots must not enter the Rust-only source image. /server/ /test/server/ /test/unit/server/ +# /test/integration/server/ is NOT retired (live managed-provider parity +# suites) but stays OUT of the Cloud Run image deliberately: its tests build +# and spawn the real Rust server and need a Docker daemon (workload image), +# neither of which exists in the cloud container. They are covered by LOCAL +# lanes: the default vitest config (test:unit / base gate / pre-push). /test/integration/server/ # Version control and CI diff --git a/Cargo.lock b/Cargo.lock index 987056878..150ee391b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ name = "freshell-runtime-protocol" version = "0.1.0" dependencies = [ + "freshell-platform", "hmac", "serde", "serde_json", diff --git a/crates/freshell-runtime-protocol/Cargo.toml b/crates/freshell-runtime-protocol/Cargo.toml index 0d6952761..d4a8298ab 100644 --- a/crates/freshell-runtime-protocol/Cargo.toml +++ b/crates/freshell-runtime-protocol/Cargo.toml @@ -13,3 +13,8 @@ uuid = { version = "1", features = ["v4"] } sha2 = "0.10" hmac = "0.12" thiserror = "2" + +[dev-dependencies] +# Dev-only: the drift guard test asserts that the recipes built by +# freshell-platform are the ones this crate's approved digest list accepts. +freshell-platform = { path = "../freshell-platform" } diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 445c5019e..0cd33a132 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1977,6 +1977,23 @@ impl TerminalLaunchSpec { } } +/// Approved SHA-256 digests of the managed Claude `--settings` recipe +/// (`freshell_platform::cli_launch::claude_settings_json`). The recipe is +/// pinned by digest (not raw text) so the list stays a fixed-size constant. +/// Superseded recipe revisions stay listed so durable argv persisted under +/// them keeps validating. When `freshell-platform` changes the recipe, add +/// the new digest here — the drift-guard test +/// (`approved_claude_settings_digests_track_current_recipes`) fails with the +/// computed digest until you do. +const APPROVED_CLAUDE_SETTINGS_DIGESTS: [&str; 4] = [ + // Current recipes (since 0908a4624, Oct 4 2026: bell via hook response). + "41b86fd2aed9fa85ad0c3e99695de49fdadc36fcd6dc80d9fb53a2305b95cd71", // Unix + "b48c5d93ff2129438e2e10dc9eadc45849ec1b93c683a1ca08aebaa55d46a786", // Windows + // Superseded recipes (pre-0908a4624: bell via /dev/tty and CONOUT$). + "aa8900bab7bd9bcbb64634d60593a9217c1639b539cea23318122b3428d123c6", // Unix + "d2dcb96c036160b2469bcd732fdc8d767dd481f9c0c864ce47d58ed057652c4d", // Windows +]; + /// Durable argv accepts only provider launch selectors. Credential-bearing /// switches must be resolved through the child-only OneCLI path instead. pub fn validate_managed_terminal_args(mode: &str, args: &[String]) -> Result<(), RuntimeError> { @@ -2017,11 +2034,7 @@ pub fn validate_managed_terminal_args(mode: &str, args: &[String]) -> Result<(), let allowed = match (mode, flag) { ("claude", "--settings") => { let digest = format!("{:x}", sha2::Sha256::digest(value.as_bytes())); - matches!( - digest.as_str(), - "aa8900bab7bd9bcbb64634d60593a9217c1639b539cea23318122b3428d123c6" - | "d2dcb96c036160b2469bcd732fdc8d767dd481f9c0c864ce47d58ed057652c4d" - ) + APPROVED_CLAUDE_SETTINGS_DIGESTS.contains(&digest.as_str()) } ("claude", "--mcp-config") => matches!( value.as_str(), @@ -3046,6 +3059,34 @@ where mod tests { use super::*; + /// The managed Claude `--settings` payload is BUILT by freshell-platform + /// (`cli_launch::claude_settings_json`) but APPROVED by digest in this + /// crate. The two once drifted (0908a4624 rewrote the bell commands + /// without updating the approved digests) and every managed Claude launch + /// was rejected for 10 days. This guard pins the crates together: if the + /// recipe changes without the digest list, rust-gate fails HERE. + #[test] + fn approved_claude_settings_digests_track_current_recipes() { + let mut drift = Vec::new(); + for target in [ + freshell_platform::cli_launch::ProviderTarget::Unix, + freshell_platform::cli_launch::ProviderTarget::Windows, + ] { + let recipe = freshell_platform::cli_launch::claude_settings_json(target); + let digest = format!("{:x}", sha2::Sha256::digest(recipe.as_bytes())); + let args = vec!["--settings".to_string(), recipe]; + if validate_managed_terminal_args("claude", &args).is_err() { + drift.push(format!("{target:?}: {digest}")); + } + } + assert!( + drift.is_empty(), + "claude --settings recipe digests not in the approved list: \ + {drift:#?}; update APPROVED_CLAUDE_SETTINGS_DIGESTS in this \ + crate to match freshell-platform's current recipe" + ); + } + #[test] fn durable_terminal_argument_recipe_preserves_provider_options_and_legacy_modes() { for (provider, args) in [ diff --git a/test/unit/architecture/fresh-agent-only-runtime.test.ts b/test/unit/architecture/fresh-agent-only-runtime.test.ts index 83772f4bf..f6e2fd6b4 100644 --- a/test/unit/architecture/fresh-agent-only-runtime.test.ts +++ b/test/unit/architecture/fresh-agent-only-runtime.test.ts @@ -13,7 +13,12 @@ const retiredNodeBackendPaths = [ 'server', 'test/server', 'test/unit/server', - 'test/integration/server', + // NOTE: 'test/integration/server' was retired with the Node backend, then + // RE-ADDED (2026-09-28) as the live managed-provider parity suites — it is + // a production test root now, NOT retired, and must NOT return to this + // list. Its suites need a locally built Rust server binary + Docker, so + // they run in local lanes (default vitest config, base gate) and are + // deliberately excluded from the Cloud Run image (.dockerignore). 'test/integration/session-repair.test.ts', 'tsconfig.server.json', 'dist/server',