Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30

Merged
jknapp merged 62 commits from ship/core into main 2026-08-25 18:02:58 +00:00
11 changed files with 354 additions and 49 deletions
Showing only changes of commit 6a8972980d - Show all commits
@@ -461,7 +461,17 @@ async fn fresh_migration(
// pre-swap commit is the single largest snapshot Triple-C ever takes, so // 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 // letting this one path commit unscrubbed is what the scrub exists to
// prevent. Failure is swallowed inside; it must never block a migration. // 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..."); emit_progress(&app_handle, &project_id, "Stopping the container...");
let _ = state let _ = state
+5 -1
View File
@@ -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 /// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus
/// [`ScrubOutcome::Failed`]: "there was nothing to exec into" is routine, while /// [`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. /// "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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrubOutcome { pub enum ScrubOutcome {
/// The scrub ran to completion and freed this many bytes — possibly zero, /// 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 /// 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 /// found nothing, which is a different and more alarming thing than a
/// scrub that correctly had no work left. /// scrub that correctly had no work left.
fn commit_log_suffix(&self) -> String { pub(crate) fn commit_log_suffix(&self) -> String {
match self { match self {
Self::Reclaimed(bytes) => format!( Self::Reclaimed(bytes) => format!(
" ({:.2} MB dropped by the pre-commit scrub)", " ({:.2} MB dropped by the pre-commit scrub)",
+64
View File
@@ -727,4 +727,68 @@ mod tests {
lifecycle.settle_startup_tasks().await; lifecycle.settle_startup_tasks().await;
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1)); 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<String> = 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."
);
}
}
} }
+9 -2
View File
@@ -151,6 +151,10 @@ fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
/// predecessor already learned: a plain release statement is skipped by an /// 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 /// early `?`, by a panic, and by the future simply being dropped. A guard is
/// not. /// 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)] #[derive(Debug)]
pub struct ProjectGuard { pub struct ProjectGuard {
project_id: String, project_id: String,
@@ -245,8 +249,11 @@ mod tests {
assert!(err.contains("snapshot is being compacted"), "{}", err); assert!(err.contains("snapshot is being compacted"), "{}", err);
assert!(err.contains("starting or recreating"), "{}", err); assert!(err.contains("starting or recreating"), "{}", err);
drop(first); drop(first);
// And it has to be retakeable the moment the holder goes away. // And it has to be retakeable the moment the holder goes away. Bound
try_acquire(&p, ProjectOp::Recreate).expect("released on drop"); // 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] #[test]
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSecretField } from "../../../../hooks/useSecretField";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { Project } from "../../../../lib/types"; import type { Project } from "../../../../lib/types";
import Button from "../../../ui/Button"; import Button from "../../../ui/Button";
@@ -24,14 +25,15 @@ export default function AccessSection({
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? ""); const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); 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(() => { useEffect(() => {
setSshKeyPath(project.ssh_key_path ?? ""); setSshKeyPath(project.ssh_key_path ?? "");
setCaCertPath(project.ca_cert_path ?? ""); setCaCertPath(project.ca_cert_path ?? "");
setGitName(project.git_user_name ?? ""); setGitName(project.git_user_name ?? "");
setGitEmail(project.git_user_email ?? ""); setGitEmail(project.git_user_email ?? "");
setGitToken(project.git_token ?? "");
}, [project]); }, [project]);
return ( return (
@@ -101,15 +103,19 @@ export default function AccessSection({
<Field <Field
label="Git HTTPS token" label="Git HTTPS token"
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container." hint={
gitToken.edited
? "Saved when you click away. Clearing the box removes the stored token."
: "A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container. A stored token is not shown; leave this empty to keep it."
}
> >
{(id) => ( {(id) => (
<input <input
id={id} id={id}
type="password" type="password"
value={gitToken} value={gitToken.value}
onChange={(e) => setGitToken(e.target.value)} onChange={(e) => gitToken.setValue(e.target.value)}
onBlur={() => save({ git_token: gitToken || null })} onBlur={() => save({ ...gitToken.patch("git_token") })}
placeholder="ghp_…" placeholder="ghp_…"
disabled={disabled} disabled={disabled}
className={inputClass} className={inputClass}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSecretField, withoutUntouchedSecrets } from "../../../../hooks/useSecretField";
import type { import type {
Backend, Backend,
BedrockAuthMethod, BedrockAuthMethod,
@@ -16,6 +17,17 @@ import Field, {
} from "../../../ui/Field"; } from "../../../ui/Field";
import Toggle from "../../../ui/Toggle"; 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 = { export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
auth_method: "static_credentials", auth_method: "static_credentials",
aws_region: "us-east-1", 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. // Local text state — saved on blur, not on every keystroke.
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region); const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? ""); // Secrets are never seeded from `project` — see `useSecretField`.
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? ""); const accessKeyId = useSecretField(project.id);
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? ""); const secretKey = useSecretField(project.id);
const sessionToken = useSecretField(project.id);
const [profile, setProfile] = useState(bedrock.aws_profile ?? ""); 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 [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? ""); 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 ?? project.openai_compatible_config?.base_url ??
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url, DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
); );
const [oaiApiKey, setOaiApiKey] = useState( const oaiApiKey = useSecretField(project.id);
project.openai_compatible_config?.api_key ?? "",
);
const [oaiModelId, setOaiModelId] = useState( const [oaiModelId, setOaiModelId] = useState(
project.openai_compatible_config?.model_id ?? "", 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 ?? "", 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(() => { useEffect(() => {
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG; const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
setBedrockRegion(bc.aws_region); 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 ?? ""); setProfile(bc.aws_profile ?? "");
setBearerToken(bc.aws_bearer_token ?? "");
setBedrockModelId(bc.model_id ?? ""); setBedrockModelId(bc.model_id ?? "");
setServiceTier(bc.service_tier ?? ""); setServiceTier(bc.service_tier ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url); 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 ?? project.openai_compatible_config?.base_url ??
DEFAULT_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 ?? ""); setOaiModelId(project.openai_compatible_config?.model_id ?? "");
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? ""); setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
}, [project]); }, [project]);
const saveBedrock = (patch: Partial<BedrockConfig>) => const saveBedrock = (patch: Partial<BedrockConfig>) =>
save({ bedrock_config: { ...bedrock, ...patch } }); save({
bedrock_config: withoutUntouchedSecrets(
{ ...bedrock, ...patch },
patch,
BEDROCK_SECRET_KEYS,
),
});
const saveOllama = (patch: Partial<OllamaConfig>) => const saveOllama = (patch: Partial<OllamaConfig>) =>
save({ save({
@@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) => const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
save({ save({
openai_compatible_config: { openai_compatible_config: withoutUntouchedSecrets(
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG), {
...patch, ...(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 // 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) => ( {(id) => (
<input <input
id={id} id={id}
value={accessKeyId} value={accessKeyId.value}
onChange={(e) => setAccessKeyId(e.target.value)} onChange={(e) => accessKeyId.setValue(e.target.value)}
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })} onBlur={() => saveBedrock(accessKeyId.patch("aws_access_key_id"))}
placeholder="AKIA…" placeholder="AKIA…"
disabled={disabled} disabled={disabled}
className={monoInputClass} className={monoInputClass}
@@ -278,10 +297,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
<input <input
id={id} id={id}
type="password" type="password"
value={secretKey} value={secretKey.value}
onChange={(e) => setSecretKey(e.target.value)} onChange={(e) => secretKey.setValue(e.target.value)}
onBlur={() => onBlur={() =>
saveBedrock({ aws_secret_access_key: secretKey || null }) saveBedrock(secretKey.patch("aws_secret_access_key"))
} }
disabled={disabled} disabled={disabled}
className={monoInputClass} className={monoInputClass}
@@ -296,10 +315,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
<input <input
id={id} id={id}
type="password" type="password"
value={sessionToken} value={sessionToken.value}
onChange={(e) => setSessionToken(e.target.value)} onChange={(e) => sessionToken.setValue(e.target.value)}
onBlur={() => onBlur={() =>
saveBedrock({ aws_session_token: sessionToken || null }) saveBedrock(sessionToken.patch("aws_session_token"))
} }
disabled={disabled} disabled={disabled}
className={monoInputClass} className={monoInputClass}
@@ -337,9 +356,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
<input <input
id={id} id={id}
type="password" type="password"
value={bearerToken} value={bearerToken.value}
onChange={(e) => setBearerToken(e.target.value)} onChange={(e) => bearerToken.setValue(e.target.value)}
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })} onBlur={() => saveBedrock(bearerToken.patch("aws_bearer_token"))}
disabled={disabled} disabled={disabled}
className={monoInputClass} className={monoInputClass}
/> />
@@ -507,9 +526,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
<input <input
id={id} id={id}
type="password" type="password"
value={oaiApiKey} value={oaiApiKey.value}
onChange={(e) => setOaiApiKey(e.target.value)} onChange={(e) => oaiApiKey.setValue(e.target.value)}
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })} onBlur={() => saveOpenAi(oaiApiKey.patch("api_key"))}
placeholder="sk-…" placeholder="sk-…"
disabled={disabled} disabled={disabled}
className={monoInputClass} className={monoInputClass}
@@ -19,6 +19,22 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
setPaths(project.paths ?? []); setPaths(project.paths ?? []);
}, [project]); }, [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 ( return (
<ConfigGroup <ConfigGroup
title="Workspace" title="Workspace"
@@ -70,7 +86,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
updated[i] = { ...updated[i], host_path: e.target.value }; updated[i] = { ...updated[i], host_path: e.target.value };
setPaths(updated); setPaths(updated);
}} }}
onBlur={() => save({ paths })} onBlur={() => saveIfComplete()}
placeholder="/path/to/folder" placeholder="/path/to/folder"
disabled={disabled} disabled={disabled}
className={`flex-1 min-w-0 ${inputClass}`} 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 }; updated[i] = { ...updated[i], mount_name: e.target.value };
setPaths(updated); setPaths(updated);
}} }}
onBlur={() => save({ paths })} onBlur={() => saveIfComplete()}
placeholder="name" placeholder="name"
disabled={disabled} disabled={disabled}
className={`w-40 ${monoInputClass}`} className={`w-40 ${monoInputClass}`}
@@ -46,12 +46,12 @@ const STATUS_DISPLAY: Record<
* snapshot was never attempted, so the remedy is to run the sweep again, not * snapshot was never attempted, so the remedy is to run the sweep again, not
* to Reset the project and lose both its volumes. * to Reset the project and lose both its volumes.
* *
* `snapshots_skipped` is read defensively: it is newer than `ClearTokenOutcome` * `snapshots_skipped` is declared on `ClearTokenOutcome`, but it is still read
* in `lib/types.ts`, which another change in this round owns. Until that lands * through `list()` rather than indexed directly: a backend older than this
* the field arrives over IPC but is not in the declared type, and an older * change does not send the field at all, and a missing skip list must read as
* backend would not send it at all. * "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 ?? []; const list = (values: string[] | undefined): string[] => values ?? [];
+77
View File
@@ -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 });
});
});
+96
View File
@@ -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: <K extends string>(key: K) => Partial<Record<K, string | null>>;
}
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(
<K extends string>(key: K): Partial<Record<K, string | null>> =>
edited ? ({ [key]: value || null } as Partial<Record<K, string | null>>) : {},
[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<T extends object>(
merged: T,
patch: Partial<T>,
secretKeys: readonly (keyof T)[],
): T {
const out = { ...merged };
for (const key of secretKeys) {
if (!(key in patch)) delete out[key];
}
return out;
}
+6
View File
@@ -643,6 +643,12 @@ export interface ClearTokenOutcome {
snapshots_scrubbed: string[]; snapshots_scrubbed: string[];
/** Images still holding it, each with the reason. Non-empty = incomplete. */ /** Images still holding it, each with the reason. Non-empty = incomplete. */
snapshots_failed: string[]; 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 /** Rewritten, but the pre-rewrite image object could not be deleted because a
* container still runs off it. Clears itself when that container is * container still runs off it. Clears itself when that container is
* recreated — worth mentioning, not worth alarming about. */ * recreated — worth mentioning, not worth alarming about. */