Stop granting an unscoped host-file read, and make a refused credential scrub recoverable
`core:default` was an alias for nine core plugins' default sets, and one of them — `core:image:default` — carries `allow-from-path`, whose handler is a bare `std::fs::read(path)` with no scope mechanism at all. Nothing imports `@tauri-apps/api/image`, so the plugin is dropped rather than scoped; there is nothing to scope it with. The capability file now enumerates what `app/src` actually invokes, which is `core:event`'s listen/unlisten and nothing else from core — every emit in this app originates in Rust. `core:menu`, `core:tray`, `core:window`, `core:path`, `core:resources` and the three dead `dialog:` grants go with it. `core:webview:allow-internal-toggle-devtools` stays because Tauri's own injected debug script calls it; both it and the command behind it are `cfg(any(debug_assertions, feature = "devtools"))`, so it is absent from a release bundle. Verified empirically: an unknown identifier fails the build, so every identifier kept is real and the regenerated `gen/schemas/capabilities.json` carries the opener scope verbatim rather than silently dropping it. `opener:allow-open-url` cannot be host-narrowed — the terminal opens links Claude printed inside the container — so what it does and does not buy is recorded instead, including the verified fact that each scope entry's `app` defaults to `Application::Default`, which matches only `with == None` and therefore refuses `openUrl(url, "/bin/sh")`. `clear_claude_token` deleted the keychain entry first and swept the snapshot images second. The sweep runs once and skips a project another operation holds, the deleted entry made `has_claude_token` false, and Revoke rendered only while a token was stored — so a project that happened to be starting during a revoke kept a live ~1-year OAuth token in its snapshot's `Config.Env` permanently, with Reset (which destroys both volumes) as the only remaining remedy. The sweep now runs first, so a crash mid-revoke leaves the app still saying "authenticated" with the same button still able to finish; a busy project is reported as `snapshots_skipped` rather than folded in with images that genuinely cannot be rewritten; and the panel keeps a retry visible independent of token status, plus offers the sweep outright when nothing is stored, because a snapshot committed by an older build carries the token either way. The retry is the same command — it is idempotent, and the images are the durable record. Also: `openai-compatible-api-key` was written but never deleted, so it outlived its project. The key list is now the single definition and an unlisted key is refused outright, so the writer cannot get ahead of the deleter again. `store_or_clear_project_secret` lands here unused on purpose: the editors send a blanked field as `null` and `store_secrets_for_project` skips `None`, so clearing a secret through the UI is impossible today. Its one call site is in `commands/project_commands.rs`, which belongs to another change in this round. No `devCsp` was added. `tauri dev` loads the main document straight from Vite, and Tauri only attaches a CSP to documents it serves itself — the dev server is proxied through `tauri://` only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))`. A `devCsp` here would be inert config that reads as protection. The reasoning, and the one place that could set one, are recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -189,4 +189,115 @@ describe("SharedAuthSettings", () => {
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
|
||||
});
|
||||
|
||||
// ── A skipped scrub is not a success, and must stay retryable ────────────
|
||||
// `scrub_secrets_from_snapshots` refuses a project another operation holds
|
||||
// rather than racing its `:latest` tag. That leaves a live ~1-year token in
|
||||
// the image, so it can be neither folded into the success message nor
|
||||
// described as a permanent failure whose remedy is Reset.
|
||||
|
||||
it("reports a skipped snapshot as an incomplete revocation, not a success", async () => {
|
||||
const toast = await revoke({
|
||||
snapshots_skipped: [
|
||||
"triple-c-snapshot-p1:latest: This project's container is being started or recreated. Wait for it to finish before removing a credential from its snapshot.",
|
||||
],
|
||||
});
|
||||
expect(toast.kind).toBe("error");
|
||||
expect(toast.message).toMatch(/still in 1 snapshot image/i);
|
||||
expect(toast.detail).toMatch(/run the\s+cleanup again/i);
|
||||
// The wrong advice for a transient refusal.
|
||||
expect(toast.detail).not.toMatch(/Reset/i);
|
||||
});
|
||||
|
||||
it("keeps a retry available after the revoke has cleared the keychain", async () => {
|
||||
projects = [running()];
|
||||
// Stored when the panel mounts, gone after the revoke — which is exactly
|
||||
// the state that used to remove the only button able to finish the job.
|
||||
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValue({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
|
||||
const retry = await screen.findByTestId("shared-auth-retry");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("shared-auth-leftover")).toHaveTextContent(
|
||||
/still readable/i,
|
||||
);
|
||||
expect(retry).toBeEnabled();
|
||||
});
|
||||
|
||||
it("clears the warning when a retry finally finishes the job", async () => {
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValueOnce({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||
const retry = await screen.findByTestId("shared-auth-retry");
|
||||
|
||||
clearClaudeToken.mockResolvedValueOnce({
|
||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: [],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
fireEvent.click(retry);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(clearClaudeToken).toHaveBeenCalledTimes(2);
|
||||
const toast = useAppState.getState().toasts.at(-1)!;
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toMatch(/cleared from 1 snapshot image/i);
|
||||
});
|
||||
|
||||
it("offers a snapshot sweep even when no token is stored", async () => {
|
||||
// Snapshots committed by an older build carry the token whether or not
|
||||
// anything is in the keychain today, so the sweep cannot be gated on it.
|
||||
projects = [running()];
|
||||
hasClaudeToken.mockResolvedValue(false);
|
||||
clearClaudeToken.mockResolvedValue({
|
||||
snapshots_scrubbed: [],
|
||||
snapshots_failed: [],
|
||||
snapshots_skipped: [],
|
||||
snapshots_superseded: [],
|
||||
docker_unavailable: null,
|
||||
});
|
||||
render(<SharedAuthSettings />);
|
||||
|
||||
const sweep = await screen.findByTestId("shared-auth-sweep");
|
||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||
fireEvent.click(sweep);
|
||||
|
||||
await waitFor(() => expect(clearClaudeToken).toHaveBeenCalled());
|
||||
const toast = useAppState.getState().toasts.at(-1)!;
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(toast.message).toBe("No snapshot image is holding the token.");
|
||||
});
|
||||
|
||||
it("tolerates a backend that does not report skipped snapshots", async () => {
|
||||
// `snapshots_skipped` is newer than the rest of the payload; its absence
|
||||
// must read as "none", never as undefined reaching the UI.
|
||||
const toast = await revoke({ snapshots_scrubbed: ["triple-c-snapshot-p1:latest"] });
|
||||
expect(toast.kind).toBe("success");
|
||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user