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
|
// 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),
|
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||||
...patch,
|
...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