Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.
`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.
`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.
Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.
The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.
Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
220 lines
9.7 KiB
TypeScript
220 lines
9.7 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
|
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
|
|
|
function renderEditor(
|
|
settings: ClaudeCodeSettings | null,
|
|
scope: "global" | "project" = "global",
|
|
) {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(
|
|
<ClaudeCodeSettingsEditor
|
|
scope={scope}
|
|
settings={settings}
|
|
disabled={false}
|
|
onSave={onSave}
|
|
/>,
|
|
);
|
|
return onSave;
|
|
}
|
|
|
|
describe("ClaudeCodeSettingsEditor", () => {
|
|
it("shows the two default-on settings as on for a project that never touched them", () => {
|
|
// Claude Code's session recap and fullscreen auto-scroll are both on by
|
|
// default, and the fields behind them store the *disabled* sense. A toggle
|
|
// rendered straight from the field would tell every existing user their
|
|
// recap is off.
|
|
renderEditor(null);
|
|
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
|
|
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
|
|
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
|
|
});
|
|
|
|
it("stores the disabled sense when an inverted toggle is switched off", () => {
|
|
const onSave = renderEditor(null);
|
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ session_recap_disabled: true }),
|
|
);
|
|
});
|
|
|
|
it("collapses back to null once every setting is at its default again", () => {
|
|
// `null` is what tells the backend this project adds nothing over the
|
|
// global settings, so the round trip has to land exactly back on it.
|
|
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
|
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
|
expect(onSave).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("offers the classic renderer as a choice distinct from automatic", () => {
|
|
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
|
|
// different, and previously unreachable, instruction.
|
|
const onSave = renderEditor(null);
|
|
const tui = screen.getByLabelText("TUI mode");
|
|
expect(
|
|
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
|
).toEqual(["", "default", "fullscreen"]);
|
|
fireEvent.change(tui, { target: { value: "default" } });
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
|
|
});
|
|
|
|
it("offers every effort level Claude Code accepts", () => {
|
|
renderEditor(null);
|
|
expect(
|
|
Array.from(
|
|
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
|
).map((o) => o.getAttribute("value")),
|
|
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
|
});
|
|
|
|
describe("project scope", () => {
|
|
it("offers Global as a third state so a project can decline to have an opinion", () => {
|
|
renderEditor(null, "project");
|
|
const focus = screen.getByLabelText("Focus mode");
|
|
expect(
|
|
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
|
).toEqual(["global", "off", "on"]);
|
|
expect((focus as HTMLSelectElement).value).toBe("global");
|
|
});
|
|
|
|
it("stores a deliberate false so the project can turn a global On back off", () => {
|
|
// The reason the field widened from boolean to boolean|null. Under the
|
|
// old merge there was no project value that could produce this.
|
|
const onSave = renderEditor(null, "project");
|
|
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ focus_mode: false }),
|
|
);
|
|
});
|
|
|
|
it("does not collapse a deliberate off to null", () => {
|
|
// `null` means inherit. Collapsing here would silently hand the setting
|
|
// straight back to the global value the user just overrode.
|
|
const onSave = renderEditor(null, "project");
|
|
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
|
expect(onSave).not.toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("round-trips the inverted fields through the disabled sense", () => {
|
|
// Session recap stores `session_recap_disabled`, so choosing "off" has to
|
|
// store `true` and choosing "on" has to store `false`.
|
|
const onSave = renderEditor(null, "project");
|
|
const recap = screen.getByLabelText("Session recap");
|
|
|
|
fireEvent.change(recap, { target: { value: "off" } });
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ session_recap_disabled: true }),
|
|
);
|
|
|
|
fireEvent.change(recap, { target: { value: "on" } });
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ session_recap_disabled: false }),
|
|
);
|
|
});
|
|
|
|
it("shows a stored override rather than the inherited state", () => {
|
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
|
|
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
|
|
"off",
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Auto-scroll is the second inverted field and had no project-scope test at
|
|
* all — every assertion above rides on `session_recap_disabled`, so a
|
|
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
|
|
* one of the two and pass silently for the other. It is stored as
|
|
* `auto_scroll_disabled`, so every value here reads back the other way up.
|
|
*/
|
|
describe("auto-scroll", () => {
|
|
const AUTO = "Auto-scroll";
|
|
|
|
it("starts on Global, which is not the same as on", () => {
|
|
// Claude Code scrolls by default, so an inheriting project *behaves*
|
|
// as on — but it has taken no position, and rendering it as "On" would
|
|
// make a later global change look like it had no effect.
|
|
renderEditor(null, "project");
|
|
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
|
|
});
|
|
|
|
it("stores the disabled sense in both directions", () => {
|
|
const onSave = renderEditor(null, "project");
|
|
const auto = screen.getByLabelText(AUTO);
|
|
|
|
fireEvent.change(auto, { target: { value: "off" } });
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ auto_scroll_disabled: true }),
|
|
);
|
|
|
|
fireEvent.change(auto, { target: { value: "on" } });
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({ auto_scroll_disabled: false }),
|
|
);
|
|
});
|
|
|
|
it("hands the setting back to the global level when Global is chosen", () => {
|
|
// Back to no opinion, and with nothing else set that collapses the
|
|
// whole object to `null` — the value that means "adds nothing over the
|
|
// global settings".
|
|
const onSave = renderEditor(
|
|
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
|
|
"project",
|
|
);
|
|
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
|
|
expect(onSave).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("reads a stored override back the right way up", () => {
|
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
|
|
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The inverted fields store a *deviation*, so a stored `false` is the one
|
|
* value that means "the user deliberately re-enabled the default". Nothing
|
|
* asserted it: every existing test drives the `true` (turned off) direction
|
|
* or the `null` (untouched) one, and both scopes would still read correctly
|
|
* if the inversion were dropped from the `false` branch alone.
|
|
*/
|
|
describe.each([
|
|
["session_recap_disabled", "Session recap"] as const,
|
|
["auto_scroll_disabled", "Auto-scroll"] as const,
|
|
])("a stored false on %s", (key, label) => {
|
|
it("reads as On at project scope, not as Off", () => {
|
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
|
|
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
|
|
});
|
|
|
|
it("reads as on at global scope, where the control is a switch", () => {
|
|
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
|
|
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
|
* struct skips serialising a field it has no value for, which is what keeps
|
|
* an older binary able to parse `projects.json` after a downgrade. It is also
|
|
* the exact shape a project stored before the fields were widened is read
|
|
* back as — every one of its `false`s meant "unset" — so reading absent as
|
|
* "off" would show a switch the user never touched as a deliberate choice.
|
|
*/
|
|
it("reads an absent field as Global rather than as Off", () => {
|
|
renderEditor({} as ClaudeCodeSettings, "project");
|
|
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
|
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
|
});
|
|
|
|
it("still collapses to null when an absent-field object is edited back", () => {
|
|
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
|
// Off and straight back on: the round trip has to land on `null`, or an
|
|
// untouched global stops being indistinguishable from one never opened.
|
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
|
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
|
expect(onSave).toHaveBeenLastCalledWith(null);
|
|
});
|
|
});
|