Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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<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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
|
||||
/// 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]
|
||||
|
||||
@@ -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({
|
||||
|
||||
<Field
|
||||
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) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => 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}
|
||||
|
||||
@@ -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<BedrockConfig>) =>
|
||||
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||
save({
|
||||
bedrock_config: withoutUntouchedSecrets(
|
||||
{ ...bedrock, ...patch },
|
||||
patch,
|
||||
BEDROCK_SECRET_KEYS,
|
||||
),
|
||||
});
|
||||
|
||||
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||
save({
|
||||
@@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
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) => (
|
||||
<input
|
||||
id={id}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => 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) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => 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) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(e) => 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) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(e) => 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) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={oaiApiKey}
|
||||
onChange={(e) => 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}
|
||||
|
||||
@@ -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 (
|
||||
<ConfigGroup
|
||||
title="Workspace"
|
||||
@@ -70,7 +86,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => 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}`}
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user