diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 2674cf9..f6698f0 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -318,8 +318,19 @@ fn is_reserved_env_key(key: &str) -> bool { || RESERVED_ENV_EXACT.iter().any(|e| upper == *e) } -/// Compute a fingerprint string for the custom environment variables. -/// Sorted alphabetically so order changes do not cause spurious recreation. +/// Compute a fingerprint for the custom environment variables. +/// +/// Sorted alphabetically so order changes do not cause spurious recreation, and +/// **hashed**, because this value is written as the +/// `triple-c.custom-env-fingerprint` label. Labels are readable by anything on +/// the host through `docker inspect`, `docker commit` copies them onto the +/// project's snapshot image, and `container_needs_recreation` logs both sides on +/// a mismatch — so a plaintext `KEY=VALUE` join published every custom +/// variable's *value*, API tokens included, to all three places. Same treatment +/// as `triple-c.git-token-hash`. +/// +/// Empty stays empty rather than becoming the hash of the empty string: an empty +/// label is how every other `triple-c.*` key says "nothing configured". fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { @@ -330,7 +341,10 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { parts.push(format!("{}={}", key, env_var.value)); } parts.sort(); - parts.join(",") + if parts.is_empty() { + return String::new(); + } + sha256_hex(&parts.join(",")) } /// The shared Claude Code OAuth token to inject for this project, paired with @@ -2473,6 +2487,29 @@ mod tests { assert_eq!(fp, ""); } + #[test] + fn the_custom_env_fingerprint_never_carries_the_value() { + // It goes into `triple-c.custom-env-fingerprint`, which `docker inspect` + // hands to anything on the host, `docker commit` copies onto the + // project's snapshot image, and the recreation check logs on a mismatch. + let secret = "33da01c1b320644920c20d6b5e0a1c6b3c3451c2"; + let fp = compute_env_fingerprint(&[EnvVar { + key: "TEA_TOKEN".to_string(), + value: secret.to_string(), + }]); + assert!(!fp.contains(secret), "fingerprint leaked the value: {}", fp); + assert!(!fp.contains("TEA_TOKEN"), "fingerprint leaked the key: {}", fp); + assert_eq!(fp.len(), 64, "expected a sha256 hex digest, got {:?}", fp); + + // It still has to move when the value does, or a rotated token would + // never reach the container. + let rotated = compute_env_fingerprint(&[EnvVar { + key: "TEA_TOKEN".to_string(), + value: "rotated".to_string(), + }]); + assert_ne!(fp, rotated); + } + #[test] fn the_deprecated_small_fast_model_var_is_never_emitted() { let rendered: Vec = aliases(Some("m"), Some("h")) diff --git a/app/src/components/projects/EnvVarsEditor.tsx b/app/src/components/projects/EnvVarsEditor.tsx index 1266283..ce6dde5 100644 --- a/app/src/components/projects/EnvVarsEditor.tsx +++ b/app/src/components/projects/EnvVarsEditor.tsx @@ -43,26 +43,36 @@ export default function EnvVarsEditor({

)} + {/* The row's widths live on wrapper divs, not on the inputs. `inputClass` + carries `w-full`, and a width utility on the input itself does not beat + it — class-attribute order is not what resolves the conflict, stylesheet + order is. Sizing the key input directly left it asking for the whole row + and collapsed the value input, whose `flex-1` basis of 0 gave it only the + leftover space, to an unusable sliver. */} {vars.map((ev, i) => (
- updateVar(i, "key", e.target.value)} - onBlur={() => onSave(vars)} - placeholder="KEY" - aria-label={`Environment variable ${i + 1} name`} - disabled={disabled} - className={`w-2/5 ${monoInputClass}`} - /> - updateVar(i, "value", e.target.value)} - onBlur={() => onSave(vars)} - placeholder="value" - aria-label={`Environment variable ${i + 1} value`} - disabled={disabled} - className={`flex-1 ${monoInputClass}`} - /> +
+ updateVar(i, "key", e.target.value)} + onBlur={() => onSave(vars)} + placeholder="KEY" + aria-label={`Environment variable ${i + 1} name`} + disabled={disabled} + className={monoInputClass} + /> +
+
+ updateVar(i, "value", e.target.value)} + onBlur={() => onSave(vars)} + placeholder="value" + aria-label={`Environment variable ${i + 1} value`} + disabled={disabled} + className={monoInputClass} + /> +