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

Merged
jknapp merged 62 commits from ship/core into main 2026-08-25 18:02:58 +00:00
11 changed files with 354 additions and 49 deletions
Showing only changes of commit 6a8972980d - Show all commits
@@ -461,7 +461,17 @@ async fn fresh_migration(
// pre-swap commit is the single largest snapshot Triple-C ever takes, so
// 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
+5 -1
View File
@@ -2371,6 +2371,10 @@ const SCRUB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus
/// [`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)",
+64
View File
@@ -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."
);
}
}
}
+9 -2
View File
@@ -151,6 +151,10 @@ fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
/// predecessor already learned: a plain release statement is skipped by an
/// 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 ?? [];
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSecretField, withoutUntouchedSecrets } from "./useSecretField";
describe("useSecretField", () => {
it("says nothing about a secret the user never touched", () => {
// The bug this exists for: the input always renders empty (secrets are
// never serialized to the frontend), so the obvious `value || null` blur
// handler sent `null` — which means *delete* — merely because the user
// focused the field and tabbed away. No warning, nothing to undo.
const { result } = renderHook(() => useSecretField("p1"));
expect(result.current.value).toBe("");
expect(result.current.edited).toBe(false);
expect(result.current.patch("git_token")).toEqual({});
// Absent, not null: `JSON.stringify` drops the key entirely, and Rust
// distinguishes an absent key ("leave it") from an explicit null ("clear").
expect("git_token" in result.current.patch("git_token")).toBe(false);
});
it("sends the value once the user types", () => {
const { result } = renderHook(() => useSecretField("p1"));
act(() => result.current.setValue("ghp_secret"));
expect(result.current.patch("git_token")).toEqual({ git_token: "ghp_secret" });
});
it("sends null only when the user cleared a field they had typed in", () => {
// This is the one case where deleting the stored secret is what was asked
// for, and it has to keep working — the previous behaviour skipped `None`
// entirely, so a blanked token was never actually revoked.
const { result } = renderHook(() => useSecretField("p1"));
act(() => result.current.setValue("typed"));
act(() => result.current.setValue(""));
expect(result.current.edited).toBe(true);
expect(result.current.patch("git_token")).toEqual({ git_token: null });
});
it("forgets a half-typed secret when the editor moves to another project", () => {
const { result, rerender } = renderHook(({ id }) => useSecretField(id), {
initialProps: { id: "p1" },
});
act(() => result.current.setValue("for-project-one"));
rerender({ id: "p2" });
expect(result.current.value).toBe("");
expect(result.current.edited).toBe(false);
expect(result.current.patch("api_key")).toEqual({});
});
});
describe("withoutUntouchedSecrets", () => {
it("drops a secret key the caller did not set", () => {
// `saveBedrock` spreads `{ ...bedrock, ...patch }`, and when `bedrock`
// falls back to DEFAULT_BEDROCK_CONFIG that literal spells every secret out
// as `null`. Without this filter, editing the AWS *region* would delete the
// stored credentials as a side effect.
const merged = {
aws_region: "eu-west-1",
aws_access_key_id: null,
aws_secret_access_key: null,
};
const out = withoutUntouchedSecrets(merged, { aws_region: "eu-west-1" }, [
"aws_access_key_id",
"aws_secret_access_key",
]);
expect(out).toEqual({ aws_region: "eu-west-1" });
expect("aws_access_key_id" in out).toBe(false);
});
it("keeps a secret key the caller set, including an explicit null", () => {
const merged = { aws_region: "eu-west-1", aws_access_key_id: null };
const out = withoutUntouchedSecrets(
merged,
{ aws_access_key_id: null },
["aws_access_key_id"],
);
expect(out).toEqual({ aws_region: "eu-west-1", aws_access_key_id: null });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* A password input whose stored value the frontend can never see.
*
* Secrets live in the OS keychain and are `#[serde(skip_serializing)]`, so a
* `Project` arriving from Rust has no key for them at all and the input always
* renders empty whether or not a credential is stored. That is fine on its
* own. What is not fine is the obvious blur handler:
*
* ```tsx
* onBlur={() => save({ git_token: gitToken || null })}
* ```
*
* An empty box sends `null`, and `null` now means **delete** (it used to mean
* "skip", which was its own bug a blanked token was never actually revoked).
* So merely focusing a secret field and tabbing away destroyed the stored
* credential, with nothing shown and nothing to undo it.
*
* The rule this encodes: **only a field the user actually typed in may speak
* about a secret.** `patch()` returns `undefined` until then, and `undefined`
* is dropped by `JSON.stringify`, so the key never reaches Rust which
* deliberately distinguishes an absent key ("leave it alone") from an explicit
* `null` ("clear it"). See `explicitly_cleared_secrets` in
* `commands/project_commands.rs`.
*/
export interface SecretField {
/** Current input value. Always starts empty for a stored secret. */
value: string;
/** Whether the user has typed in this field since it was last reset. */
edited: boolean;
/** `onChange` handler — marks the field edited. */
setValue: (next: string) => void;
/**
* What to put in the save patch, spread into it:
* `save({ ...token.patch("git_token") })`.
*
* Empty when untouched, so the key is absent and the stored secret stands.
*/
patch: <K extends string>(key: K) => Partial<Record<K, string | null>>;
}
export function useSecretField(projectId: string): SecretField {
const [value, setValueRaw] = useState("");
const [edited, setEdited] = useState(false);
// Reset when the editor moves to a different project, so a value typed for
// one project can never be saved onto another.
const lastProject = useRef(projectId);
useEffect(() => {
if (lastProject.current !== projectId) {
lastProject.current = projectId;
setValueRaw("");
setEdited(false);
}
}, [projectId]);
const setValue = useCallback((next: string) => {
setValueRaw(next);
setEdited(true);
}, []);
const patch = useCallback(
<K extends string>(key: K): Partial<Record<K, string | null>> =>
edited ? ({ [key]: value || null } as Partial<Record<K, string | null>>) : {},
[edited, value],
);
return { value, edited, setValue, patch };
}
/**
* Drop secret keys the caller did not explicitly set.
*
* The config editors save by spreading `save({ bedrock_config: { ...bedrock,
* ...patch } })`. That is safe while `bedrock` comes from Rust, because secrets
* are never serialized and the keys are simply absent. It stops being safe the
* moment the spread falls back to a `DEFAULT_*_CONFIG` literal, because those
* spell every secret out as `null` and `null` means delete. Editing the AWS
* region would then wipe the stored credentials as a side effect.
*
* So the merged object is filtered: a secret key survives only if it is in the
* caller's own patch, which is to say only if a `useSecretField` that the user
* typed into put it there.
*/
export function withoutUntouchedSecrets<T extends object>(
merged: T,
patch: Partial<T>,
secretKeys: readonly (keyof T)[],
): T {
const out = { ...merged };
for (const key of secretKeys) {
if (!(key in patch)) delete out[key];
}
return out;
}
+6
View File
@@ -643,6 +643,12 @@ export interface ClearTokenOutcome {
snapshots_scrubbed: string[];
/** 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. */