Stop an untouched secret field from deleting the stored credential
Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.
`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.
`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.
Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.
The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user