Resolve host paths before mounting them, and stop reading stored data as choice

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
This commit is contained in:
2026-08-23 17:02:03 -07:00
co-authored by Claude Opus 5
parent 168b61d632
commit 9472cb3c4c
8 changed files with 1027 additions and 54 deletions
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import WorkspaceSection from "./WorkspaceSection";
import type { Project } from "../../../../lib/types";
// The Browse button is the OS folder picker.
const open = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
llamacpp_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
ca_cert_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const save = vi.fn().mockResolvedValue(true);
function renderSection(over: Partial<Project> = {}, disabled = false) {
return render(
<WorkspaceSection
project={{ ...baseProject, ...over }}
save={save}
disabled={disabled}
/>,
);
}
/** Every folder list this component has sent to `update_project`. */
function savedLists() {
return save.mock.calls
.filter(([patch]) => "paths" in patch)
.map(([patch]) => patch.paths);
}
describe("WorkspaceSection — the blank row is never stored", () => {
beforeEach(() => vi.clearAllMocks());
/**
* The bug this file exists for. `create_container` mounts every stored row
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
* container with `field Source must not be empty` — the project can never be
* started or recreated again. Click "+ Add folder", blur a field, and it is
* bricked.
*/
it("drops the placeholder row when a real edit is saved", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
expect(save).toHaveBeenCalledTimes(1);
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when Browse fills a different row in", async () => {
open.mockResolvedValueOnce("/src/api-v2");
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
// The picker is awaited inside the handler, so the state update that
// follows it lands outside the click.
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
});
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when a row is removed", () => {
renderSection({
paths: [
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
],
});
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
});
it("never sends a row with an empty host path, whatever the route", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
for (const list of savedLists()) {
for (const row of list) {
expect(row.host_path).not.toBe("");
expect(row.mount_name).not.toBe("");
}
}
});
});
describe("WorkspaceSection — what a blur is allowed to save", () => {
beforeEach(() => vi.clearAllMocks());
/**
* Both inputs save on blur, so tabbing from the host path to the mount name
* fires a save with the name still empty — which `update_project` refuses,
* turning an ordinary keystroke into an error toast.
*/
it("holds a half-filled row back until it is complete", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const newHostPath = screen.getByLabelText("Folder 2 host path");
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
fireEvent.blur(newHostPath);
expect(save).not.toHaveBeenCalled();
const newMountName = screen.getByLabelText("Folder 2 mount name");
fireEvent.change(newMountName, { target: { value: "web" } });
fireEvent.blur(newMountName);
expect(savedLists()[0]).toEqual([
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
]);
});
/**
* Blurring out of an untouched field is not an edit. Saving anyway would
* round-trip the filtered list through `project` and take the empty row away
* while the user was still filling it in.
*/
it("saves nothing when the blur changed nothing", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
expect(save).not.toHaveBeenCalled();
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
});
it("still saves a rename, which does not go through the folder list", () => {
renderSection();
const name = screen.getByDisplayValue("api-server");
fireEvent.change(name, { target: { value: "api-v2" } });
fireEvent.blur(name);
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
});
});
@@ -10,6 +10,14 @@ interface Props {
disabled: boolean;
}
/** Whether two folder lists are the same rows in the same order. */
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
return (
a.length === b.length &&
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
);
}
export default function WorkspaceSection({ project, save, disabled }: Props) {
const [name, setName] = useState(project.name);
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
setPaths(project.paths ?? []);
}, [project]);
/**
* Persist a folder list, minus the rows that are only in it because the UI
* put them there.
*
* **The blank row must never reach the store.** "+ Add folder" inserts
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
* mounts every stored row unfiltered — a stored blank one becomes
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
* `field Source must not be empty`. The project then cannot be started or
* recreated at all, from a click and a blur. `AddProjectDialog` has always
* filtered this; this section computed the filtered list and then saved the
* unfiltered one.
*
* Every save goes through here for that reason — Browse and Remove write the
* list too, and either can be holding a blank row from an earlier click.
*/
const persist = (rows: ProjectPath[]) => {
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
return save({ paths: filled });
};
/**
* Save only when every row is fully filled in.
*
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
* 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.
*
* A blur that changed nothing saves nothing, which is what keeps the blank
* row on screen while it is being filled in: persisting the filtered list
* would round-trip through `project` and take the empty row away under the
* cursor.
*/
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 });
if (sameRows(filled, project.paths ?? [])) return;
return persist(paths);
};
return (
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
mount_name: updated[i].mount_name || basename,
};
setPaths(updated);
save({ paths: updated });
persist(updated);
}
}}
>
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
onClick={() => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
save({ paths: updated });
persist(updated);
}}
>
Remove