diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs index 1b606db..a312578 100644 --- a/app/src-tauri/src/commands/migration_commands.rs +++ b/app/src-tauri/src/commands/migration_commands.rs @@ -461,7 +461,17 @@ async fn fresh_migration( // pre-swap commit is the single largest snapshot Triple-C ever takes, so // letting this one path commit unscrubbed is what the scrub exists to // prevent. Failure is swallowed inside; it must never block a migration. - docker::scrub_writable_layer(&container_id).await; + // + // The outcome is logged rather than discarded: this is the one scrub whose + // silence would be expensive, because the layer it declined to clean is + // about to be committed into a snapshot that outlives the migration. + log::info!( + "Pre-migration scrub of {}{}", + container_id, + docker::scrub_writable_layer(&container_id) + .await + .commit_log_suffix() + ); emit_progress(&app_handle, &project_id, "Stopping the container..."); let _ = state diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 679674d..1cdc019 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -2371,6 +2371,10 @@ const SCRUB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); /// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus /// [`ScrubOutcome::Failed`]: "there was nothing to exec into" is routine, while /// "the exec ran and broke" is the one case worth a warning in the log. +/// `#[must_use]` because the variants mean different things to a caller and +/// three of the four are easy to ignore by accident: a `Failed` swallowed at a +/// call site is exactly how a scrub that stopped working would stay quiet. +#[must_use = "a scrub that was skipped, timed out or failed reads the same as one that worked unless the outcome is inspected"] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrubOutcome { /// The scrub ran to completion and freed this many bytes — possibly zero, @@ -2397,7 +2401,7 @@ impl ScrubOutcome { /// nothing to exec into. A figure of zero reads as a scrub that ran and /// found nothing, which is a different and more alarming thing than a /// scrub that correctly had no work left. - fn commit_log_suffix(&self) -> String { + pub(crate) fn commit_log_suffix(&self) -> String { match self { Self::Reclaimed(bytes) => format!( " ({:.2} MB dropped by the pre-commit scrub)", diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index be94450..d257d6c 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -727,4 +727,68 @@ mod tests { lifecycle.settle_startup_tasks().await; assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1)); } + + /// The capability file is the app's entire IPC attack surface, and it is + /// data — nothing in `cargo test` reads it, so a widened grant lands with a + /// green suite. This is what noticing looks like. + /// + /// It exists because `core:default` was granted for months. That alias + /// pulls in `core:image:default` → `allow-from-path`, which is an + /// unconditional `std::fs::read` of any host path with no scope check, and + /// nothing in the frontend has ever imported `@tauri-apps/api/image`. + #[test] + fn the_capability_grants_are_the_ones_that_were_reviewed() { + let raw = include_str!("../capabilities/default.json"); + let parsed: serde_json::Value = + serde_json::from_str(raw).expect("capabilities/default.json must parse"); + let listed: Vec = parsed["permissions"] + .as_array() + .expect("a `permissions` array") + .iter() + .map(|p| match p { + // A scoped grant is an object; its identifier is what matters here. + serde_json::Value::Object(o) => o["identifier"] + .as_str() + .expect("a scoped grant needs an identifier") + .to_string(), + other => other.as_str().expect("a grant is a string or an object").to_string(), + }) + .collect(); + + let mut sorted = listed.clone(); + sorted.sort(); + let mut expected = vec![ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "dialog:allow-open", + "dialog:allow-save", + "opener:allow-open-url", + "drag:allow-start-drag", + ]; + expected.sort(); + assert_eq!( + sorted, expected, + "the capability set changed. That is allowed — but it is the IPC \ + surface a compromised webview can call, so update this list \ + deliberately rather than to make the test pass." + ); + + // Belt and braces: the `*:default` aliases are the specific trap here, + // because they expand to a set the file never spells out. `store:*` in + // particular was an arbitrary host-file read/write primitive. + for grant in &listed { + assert!( + !grant.ends_with(":default"), + "{} is an alias — it expands to permissions this file does not \ + name. Enumerate them instead.", + grant + ); + assert!( + !grant.starts_with("store:"), + "store:* is `PathBuf::push` against AppData, which an absolute \ + path discards: an arbitrary host-file read/write." + ); + } + } } diff --git a/app/src-tauri/src/project_lock.rs b/app/src-tauri/src/project_lock.rs index e07dc50..1deb0e1 100644 --- a/app/src-tauri/src/project_lock.rs +++ b/app/src-tauri/src/project_lock.rs @@ -151,6 +151,10 @@ fn holders() -> &'static Mutex> { /// predecessor already learned: a plain release statement is skipped by an /// early `?`, by a panic, and by the future simply being dropped. A guard is /// not. +/// Dropping this releases the claim, so a caller that discards it has taken no +/// lock at all — `let _ = try_acquire(...)` drops immediately and reads as +/// success. `#[must_use]` makes that a compile warning rather than a race. +#[must_use = "the claim is released as soon as this guard is dropped; bind it for the whole operation"] #[derive(Debug)] pub struct ProjectGuard { project_id: String, @@ -245,8 +249,11 @@ mod tests { assert!(err.contains("snapshot is being compacted"), "{}", err); assert!(err.contains("starting or recreating"), "{}", err); drop(first); - // And it has to be retakeable the moment the holder goes away. - try_acquire(&p, ProjectOp::Recreate).expect("released on drop"); + // And it has to be retakeable the moment the holder goes away. Bound + // rather than discarded: `#[must_use]` is what stops a real caller + // writing `try_acquire(...)` and believing it holds something. + let retaken = try_acquire(&p, ProjectOp::Recreate).expect("released on drop"); + drop(retaken); } #[test] diff --git a/app/src/components/projects/home/config/AccessSection.tsx b/app/src/components/projects/home/config/AccessSection.tsx index a499910..85c7f42 100644 --- a/app/src/components/projects/home/config/AccessSection.tsx +++ b/app/src/components/projects/home/config/AccessSection.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useSecretField } from "../../../../hooks/useSecretField"; import { open } from "@tauri-apps/plugin-dialog"; import type { Project } from "../../../../lib/types"; import Button from "../../../ui/Button"; @@ -24,14 +25,15 @@ export default function AccessSection({ const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); - const [gitToken, setGitToken] = useState(project.git_token ?? ""); + // Never seeded from `project` — the backend does not serialize secrets, so + // the box is always empty and only an edit may speak about the stored value. + const gitToken = useSecretField(project.id); useEffect(() => { setSshKeyPath(project.ssh_key_path ?? ""); setCaCertPath(project.ca_cert_path ?? ""); setGitName(project.git_user_name ?? ""); setGitEmail(project.git_user_email ?? ""); - setGitToken(project.git_token ?? ""); }, [project]); return ( @@ -101,15 +103,19 @@ export default function AccessSection({ {(id) => ( setGitToken(e.target.value)} - onBlur={() => save({ git_token: gitToken || null })} + value={gitToken.value} + onChange={(e) => gitToken.setValue(e.target.value)} + onBlur={() => save({ ...gitToken.patch("git_token") })} placeholder="ghp_…" disabled={disabled} className={inputClass} diff --git a/app/src/components/projects/home/config/ModelSection.tsx b/app/src/components/projects/home/config/ModelSection.tsx index 267e12e..e43e6a1 100644 --- a/app/src/components/projects/home/config/ModelSection.tsx +++ b/app/src/components/projects/home/config/ModelSection.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useSecretField, withoutUntouchedSecrets } from "../../../../hooks/useSecretField"; import type { Backend, BedrockAuthMethod, @@ -16,6 +17,17 @@ import Field, { } from "../../../ui/Field"; import Toggle from "../../../ui/Toggle"; +/** Bedrock fields held in the OS keychain, never serialized back to us. */ +const BEDROCK_SECRET_KEYS = [ + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_bearer_token", +] as const; + +/** The same, for the OpenAI-compatible backend. */ +const OPENAI_SECRET_KEYS = ["api_key"] as const; + export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = { auth_method: "static_credentials", aws_region: "us-east-1", @@ -65,11 +77,12 @@ export default function ModelSection({ project, save, disabled }: Props) { // Local text state — saved on blur, not on every keystroke. const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region); - const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? ""); - const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? ""); - const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? ""); + // Secrets are never seeded from `project` — see `useSecretField`. + const accessKeyId = useSecretField(project.id); + const secretKey = useSecretField(project.id); + const sessionToken = useSecretField(project.id); const [profile, setProfile] = useState(bedrock.aws_profile ?? ""); - const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? ""); + const bearerToken = useSecretField(project.id); const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? ""); const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? ""); @@ -97,9 +110,7 @@ export default function ModelSection({ project, save, disabled }: Props) { project.openai_compatible_config?.base_url ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url, ); - const [oaiApiKey, setOaiApiKey] = useState( - project.openai_compatible_config?.api_key ?? "", - ); + const oaiApiKey = useSecretField(project.id); const [oaiModelId, setOaiModelId] = useState( project.openai_compatible_config?.model_id ?? "", ); @@ -107,14 +118,13 @@ export default function ModelSection({ project, save, disabled }: Props) { project.openai_compatible_config?.haiku_model_id ?? "", ); + // Secret fields are deliberately absent here: `useSecretField` owns its own + // reset, and re-seeding one from `project` would write an empty string over + // whatever the user had half-typed on any unrelated project update. useEffect(() => { const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG; setBedrockRegion(bc.aws_region); - setAccessKeyId(bc.aws_access_key_id ?? ""); - setSecretKey(bc.aws_secret_access_key ?? ""); - setSessionToken(bc.aws_session_token ?? ""); setProfile(bc.aws_profile ?? ""); - setBearerToken(bc.aws_bearer_token ?? ""); setBedrockModelId(bc.model_id ?? ""); setServiceTier(bc.service_tier ?? ""); setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url); @@ -129,13 +139,18 @@ export default function ModelSection({ project, save, disabled }: Props) { project.openai_compatible_config?.base_url ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url, ); - setOaiApiKey(project.openai_compatible_config?.api_key ?? ""); setOaiModelId(project.openai_compatible_config?.model_id ?? ""); setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? ""); }, [project]); const saveBedrock = (patch: Partial) => - save({ bedrock_config: { ...bedrock, ...patch } }); + save({ + bedrock_config: withoutUntouchedSecrets( + { ...bedrock, ...patch }, + patch, + BEDROCK_SECRET_KEYS, + ), + }); const saveOllama = (patch: Partial) => save({ @@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) { const saveOpenAi = (patch: Partial) => save({ - openai_compatible_config: { - ...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG), - ...patch, - }, + openai_compatible_config: withoutUntouchedSecrets( + { + ...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG), + ...patch, + }, + patch, + OPENAI_SECRET_KEYS, + ), }); // Defaults to on: projects created before the field existed, and any data @@ -261,9 +280,9 @@ export default function ModelSection({ project, save, disabled }: Props) { {(id) => ( setAccessKeyId(e.target.value)} - onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })} + value={accessKeyId.value} + onChange={(e) => accessKeyId.setValue(e.target.value)} + onBlur={() => saveBedrock(accessKeyId.patch("aws_access_key_id"))} placeholder="AKIA…" disabled={disabled} className={monoInputClass} @@ -278,10 +297,10 @@ export default function ModelSection({ project, save, disabled }: Props) { setSecretKey(e.target.value)} + value={secretKey.value} + onChange={(e) => secretKey.setValue(e.target.value)} onBlur={() => - saveBedrock({ aws_secret_access_key: secretKey || null }) + saveBedrock(secretKey.patch("aws_secret_access_key")) } disabled={disabled} className={monoInputClass} @@ -296,10 +315,10 @@ export default function ModelSection({ project, save, disabled }: Props) { setSessionToken(e.target.value)} + value={sessionToken.value} + onChange={(e) => sessionToken.setValue(e.target.value)} onBlur={() => - saveBedrock({ aws_session_token: sessionToken || null }) + saveBedrock(sessionToken.patch("aws_session_token")) } disabled={disabled} className={monoInputClass} @@ -337,9 +356,9 @@ export default function ModelSection({ project, save, disabled }: Props) { setBearerToken(e.target.value)} - onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })} + value={bearerToken.value} + onChange={(e) => bearerToken.setValue(e.target.value)} + onBlur={() => saveBedrock(bearerToken.patch("aws_bearer_token"))} disabled={disabled} className={monoInputClass} /> @@ -507,9 +526,9 @@ export default function ModelSection({ project, save, disabled }: Props) { setOaiApiKey(e.target.value)} - onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })} + value={oaiApiKey.value} + onChange={(e) => oaiApiKey.setValue(e.target.value)} + onBlur={() => saveOpenAi(oaiApiKey.patch("api_key"))} placeholder="sk-…" disabled={disabled} className={monoInputClass} diff --git a/app/src/components/projects/home/config/WorkspaceSection.tsx b/app/src/components/projects/home/config/WorkspaceSection.tsx index 4c37819..18019ea 100644 --- a/app/src/components/projects/home/config/WorkspaceSection.tsx +++ b/app/src/components/projects/home/config/WorkspaceSection.tsx @@ -19,6 +19,22 @@ export default function WorkspaceSection({ project, save, disabled }: Props) { setPaths(project.paths ?? []); }, [project]); + /** + * Save only when every row is fully filled in. + * + * Both inputs save on blur, so tabbing from the host path to the mount name + * fires a save with the name still empty. `update_project` now validates — + * a half-filled row is refused — so the unconditional save turned an ordinary + * keystroke into an error toast. A blank row is *not* incomplete: the + * "+ Add folder" button adds one deliberately, and it is dropped on save. + */ + const saveIfComplete = () => { + const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim()); + const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim()); + if (halfFilled) return; + return save({ paths }); + }; + return ( save({ paths })} + onBlur={() => saveIfComplete()} placeholder="/path/to/folder" disabled={disabled} className={`flex-1 min-w-0 ${inputClass}`} @@ -107,7 +123,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) { updated[i] = { ...updated[i], mount_name: e.target.value }; setPaths(updated); }} - onBlur={() => save({ paths })} + onBlur={() => saveIfComplete()} placeholder="name" disabled={disabled} className={`w-40 ${monoInputClass}`} diff --git a/app/src/components/settings/SharedAuthSettings.tsx b/app/src/components/settings/SharedAuthSettings.tsx index b49d9ee..2551073 100644 --- a/app/src/components/settings/SharedAuthSettings.tsx +++ b/app/src/components/settings/SharedAuthSettings.tsx @@ -46,12 +46,12 @@ const STATUS_DISPLAY: Record< * snapshot was never attempted, so the remedy is to run the sweep again, not * to Reset the project and lose both its volumes. * - * `snapshots_skipped` is read defensively: it is newer than `ClearTokenOutcome` - * in `lib/types.ts`, which another change in this round owns. Until that lands - * the field arrives over IPC but is not in the declared type, and an older - * backend would not send it at all. + * `snapshots_skipped` is declared on `ClearTokenOutcome`, but it is still read + * through `list()` rather than indexed directly: a backend older than this + * change does not send the field at all, and a missing skip list must read as + * "nothing was skipped" rather than crashing the panel that reports it. */ -type RevokeOutcome = ClearTokenOutcome & { snapshots_skipped?: string[] }; +type RevokeOutcome = ClearTokenOutcome; const list = (values: string[] | undefined): string[] => values ?? []; diff --git a/app/src/hooks/useSecretField.test.ts b/app/src/hooks/useSecretField.test.ts new file mode 100644 index 0000000..afe3d76 --- /dev/null +++ b/app/src/hooks/useSecretField.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useSecretField, withoutUntouchedSecrets } from "./useSecretField"; + +describe("useSecretField", () => { + it("says nothing about a secret the user never touched", () => { + // The bug this exists for: the input always renders empty (secrets are + // never serialized to the frontend), so the obvious `value || null` blur + // handler sent `null` — which means *delete* — merely because the user + // focused the field and tabbed away. No warning, nothing to undo. + const { result } = renderHook(() => useSecretField("p1")); + expect(result.current.value).toBe(""); + expect(result.current.edited).toBe(false); + expect(result.current.patch("git_token")).toEqual({}); + // Absent, not null: `JSON.stringify` drops the key entirely, and Rust + // distinguishes an absent key ("leave it") from an explicit null ("clear"). + expect("git_token" in result.current.patch("git_token")).toBe(false); + }); + + it("sends the value once the user types", () => { + const { result } = renderHook(() => useSecretField("p1")); + act(() => result.current.setValue("ghp_secret")); + expect(result.current.patch("git_token")).toEqual({ git_token: "ghp_secret" }); + }); + + it("sends null only when the user cleared a field they had typed in", () => { + // This is the one case where deleting the stored secret is what was asked + // for, and it has to keep working — the previous behaviour skipped `None` + // entirely, so a blanked token was never actually revoked. + const { result } = renderHook(() => useSecretField("p1")); + act(() => result.current.setValue("typed")); + act(() => result.current.setValue("")); + expect(result.current.edited).toBe(true); + expect(result.current.patch("git_token")).toEqual({ git_token: null }); + }); + + it("forgets a half-typed secret when the editor moves to another project", () => { + const { result, rerender } = renderHook(({ id }) => useSecretField(id), { + initialProps: { id: "p1" }, + }); + act(() => result.current.setValue("for-project-one")); + rerender({ id: "p2" }); + expect(result.current.value).toBe(""); + expect(result.current.edited).toBe(false); + expect(result.current.patch("api_key")).toEqual({}); + }); +}); + +describe("withoutUntouchedSecrets", () => { + it("drops a secret key the caller did not set", () => { + // `saveBedrock` spreads `{ ...bedrock, ...patch }`, and when `bedrock` + // falls back to DEFAULT_BEDROCK_CONFIG that literal spells every secret out + // as `null`. Without this filter, editing the AWS *region* would delete the + // stored credentials as a side effect. + const merged = { + aws_region: "eu-west-1", + aws_access_key_id: null, + aws_secret_access_key: null, + }; + const out = withoutUntouchedSecrets(merged, { aws_region: "eu-west-1" }, [ + "aws_access_key_id", + "aws_secret_access_key", + ]); + expect(out).toEqual({ aws_region: "eu-west-1" }); + expect("aws_access_key_id" in out).toBe(false); + }); + + it("keeps a secret key the caller set, including an explicit null", () => { + const merged = { aws_region: "eu-west-1", aws_access_key_id: null }; + const out = withoutUntouchedSecrets( + merged, + { aws_access_key_id: null }, + ["aws_access_key_id"], + ); + expect(out).toEqual({ aws_region: "eu-west-1", aws_access_key_id: null }); + }); +}); diff --git a/app/src/hooks/useSecretField.ts b/app/src/hooks/useSecretField.ts new file mode 100644 index 0000000..c84ea32 --- /dev/null +++ b/app/src/hooks/useSecretField.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * A password input whose stored value the frontend can never see. + * + * Secrets live in the OS keychain and are `#[serde(skip_serializing)]`, so a + * `Project` arriving from Rust has no key for them at all and the input always + * renders empty — whether or not a credential is stored. That is fine on its + * own. What is not fine is the obvious blur handler: + * + * ```tsx + * onBlur={() => save({ git_token: gitToken || null })} + * ``` + * + * An empty box sends `null`, and `null` now means **delete** (it used to mean + * "skip", which was its own bug — a blanked token was never actually revoked). + * So merely focusing a secret field and tabbing away destroyed the stored + * credential, with nothing shown and nothing to undo it. + * + * The rule this encodes: **only a field the user actually typed in may speak + * about a secret.** `patch()` returns `undefined` until then, and `undefined` + * is dropped by `JSON.stringify`, so the key never reaches Rust — which + * deliberately distinguishes an absent key ("leave it alone") from an explicit + * `null` ("clear it"). See `explicitly_cleared_secrets` in + * `commands/project_commands.rs`. + */ +export interface SecretField { + /** Current input value. Always starts empty for a stored secret. */ + value: string; + /** Whether the user has typed in this field since it was last reset. */ + edited: boolean; + /** `onChange` handler — marks the field edited. */ + setValue: (next: string) => void; + /** + * What to put in the save patch, spread into it: + * `save({ ...token.patch("git_token") })`. + * + * Empty when untouched, so the key is absent and the stored secret stands. + */ + patch: (key: K) => Partial>; +} + +export function useSecretField(projectId: string): SecretField { + const [value, setValueRaw] = useState(""); + const [edited, setEdited] = useState(false); + // Reset when the editor moves to a different project, so a value typed for + // one project can never be saved onto another. + const lastProject = useRef(projectId); + + useEffect(() => { + if (lastProject.current !== projectId) { + lastProject.current = projectId; + setValueRaw(""); + setEdited(false); + } + }, [projectId]); + + const setValue = useCallback((next: string) => { + setValueRaw(next); + setEdited(true); + }, []); + + const patch = useCallback( + (key: K): Partial> => + edited ? ({ [key]: value || null } as Partial>) : {}, + [edited, value], + ); + + return { value, edited, setValue, patch }; +} + +/** + * Drop secret keys the caller did not explicitly set. + * + * The config editors save by spreading — `save({ bedrock_config: { ...bedrock, + * ...patch } })`. That is safe while `bedrock` comes from Rust, because secrets + * are never serialized and the keys are simply absent. It stops being safe the + * moment the spread falls back to a `DEFAULT_*_CONFIG` literal, because those + * spell every secret out as `null` — and `null` means delete. Editing the AWS + * region would then wipe the stored credentials as a side effect. + * + * So the merged object is filtered: a secret key survives only if it is in the + * caller's own patch, which is to say only if a `useSecretField` that the user + * typed into put it there. + */ +export function withoutUntouchedSecrets( + merged: T, + patch: Partial, + secretKeys: readonly (keyof T)[], +): T { + const out = { ...merged }; + for (const key of secretKeys) { + if (!(key in patch)) delete out[key]; + } + return out; +} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 691dadf..027032a 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -643,6 +643,12 @@ export interface ClearTokenOutcome { snapshots_scrubbed: string[]; /** Images still holding it, each with the reason. Non-empty = incomplete. */ snapshots_failed: string[]; + /** Images left alone because the project was busy under the per-project lock + * (a compaction, a migration, a recreate). **Not a failure and not a + * success** — the token is still baked into these, and `clearClaudeToken` + * is itself the retry. Kept separate from `snapshots_failed` so a skipped + * credential removal can never be reported as a completed one. */ + snapshots_skipped: string[]; /** Rewritten, but the pre-rewrite image object could not be deleted because a * container still runs off it. Clears itself when that container is * recreated — worth mentioning, not worth alarming about. */