Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
642 changes: 612 additions & 30 deletions src-tauri/src/commands/agent_setup.rs

Large diffs are not rendered by default.

424 changes: 374 additions & 50 deletions src-tauri/src/commands/doctor.rs

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions src/features/providers/api/agentSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ export interface AgentSetupOperation {
error: string | null;
}

export interface AgentSetupUpdateCommand {
// `'updateMain'` or `'updateBridge'`, paired with the readout's command.
fixType: Extract<FixType, "updateMain" | "updateBridge">;
command: string;
}
export type AgentSetupUpdateFixType = Extract<
FixType,
"updateMain" | "updateBridge"
>;

// The execution recipe captured at click time. The card derives this from the
// doctor report's actionable readouts, so the backend never has to re-derive
Expand All @@ -47,7 +46,11 @@ export interface AgentSetupPlan {
// The install recipe to seed the install loop with, or null for a pure
// update / auth.
installFixType: Extract<FixType, "command" | "bridge"> | null;
updateCommands: AgentSetupUpdateCommand[];
// Which per-readout updates to run after the install loop (`updateMain` /
// `updateBridge`). The card names only the readout slot; the backend resolves
// the exact source-aware command from the crate's trusted freshness readout,
// so no renderer-supplied shell command crosses the wire.
updateFixTypes: AgentSetupUpdateFixType[];
// Whether the backend probes PATH after the fix to confirm the agent landed.
// `hasBinary && !isBuiltIn`: a built-in or binary-less provider has nothing to
// resolve on disk, so the backend skips verification and takes a clean run as
Expand Down
2 changes: 1 addition & 1 deletion src/features/providers/stores/agentSetupStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe("useAgentSetupStore", () => {
await useAgentSetupStore.getState().init();
await useAgentSetupStore.getState().startSetup("claude-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});

Expand Down
25 changes: 13 additions & 12 deletions src/features/settings/ui/AgentProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { ArrowUpCircle } from "lucide-react";
import type {
AgentSetupAction,
AgentSetupUpdateCommand,
AgentSetupUpdateFixType,
} from "@/features/providers/api/agentSetup";
import { useAgentSetupStore } from "@/features/providers/stores/agentSetupStore";
import {
Expand Down Expand Up @@ -213,15 +213,16 @@ export function AgentProviderCard({
const installFixType: Extract<FixType, "command" | "bridge"> =
versionCheck?.fixType === "bridge" ? "bridge" : "command";

// Build the per-readout update commands the backend runs after the install
// loop. Readout *derivation* stays here (it already has the doctor report);
// only the resulting recipe crosses to Rust.
function buildUpdateCommands(): AgentSetupUpdateCommand[] {
// Build the per-readout update fix identities the backend runs after the
// install loop. Readout *derivation* stays here (it already has the doctor
// report) to decide *whether* an update is actionable; only the typed fix
// slot crosses to Rust, which re-resolves the exact command from the crate's
// trusted freshness readout.
function buildUpdateFixTypes(): AgentSetupUpdateFixType[] {
return actionableReadouts.flatMap((readout) =>
(readout.updateFixType === "updateMain" ||
readout.updateFixType === "updateBridge") &&
readout.updateCommand
? [{ fixType: readout.updateFixType, command: readout.updateCommand }]
readout.updateFixType === "updateMain" ||
readout.updateFixType === "updateBridge"
? [readout.updateFixType]
: [],
);
}
Expand Down Expand Up @@ -256,7 +257,7 @@ export function AgentProviderCard({
try {
await startSetup(provider.id, "install", {
installFixType,
updateCommands: buildUpdateCommands(),
updateFixTypes: buildUpdateFixTypes(),
verifyInstall,
...(bundledBridge ? { bundledBridge } : {}),
});
Expand Down Expand Up @@ -348,7 +349,7 @@ export function AgentProviderCard({
}
void startSetup(provider.id, "update", {
installFixType: null,
updateCommands: buildUpdateCommands(),
updateFixTypes: buildUpdateFixTypes(),
verifyInstall,
...(bundledBridge ? { bundledBridge } : {}),
});
Expand All @@ -362,7 +363,7 @@ export function AgentProviderCard({
}
void startSetup(provider.id, "auth", {
installFixType: null,
updateCommands: [],
updateFixTypes: [],
verifyInstall,
...(bundledBridge ? { bundledBridge } : {}),
});
Expand Down
8 changes: 1 addition & 7 deletions src/features/settings/ui/DoctorCheckRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function DoctorCheckRow({ check, onFixed }: DoctorCheckRowProps) {
const [activeFix, setActiveFix] = useState<{
fixType: FixType;
command: string;
commandOverride: string | null;
} | null>(null);

const Icon = STATUS_ICON[check.status];
Expand All @@ -65,7 +64,6 @@ export function DoctorCheckRow({ check, onFixed }: DoctorCheckRowProps) {
setActiveFix({
fixType: check.fixType,
command: check.fixCommand,
commandOverride: null,
});
setShowFixDialog(true);
}
Expand All @@ -75,11 +73,7 @@ export function DoctorCheckRow({ check, onFixed }: DoctorCheckRowProps) {
setFixing(true);
setFixError(null);
try {
await runDoctorFix(
check.id,
activeFix.fixType,
activeFix.commandOverride ?? undefined,
);
await runDoctorFix(check.id, activeFix.fixType);
setShowFixDialog(false);
onFixed?.();
} catch (e) {
Expand Down
43 changes: 11 additions & 32 deletions src/features/settings/ui/__tests__/AgentProviderCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "auth", {
installFixType: null,
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -463,7 +463,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -494,7 +494,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -618,7 +618,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -680,12 +680,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "update", {
installFixType: null,
updateCommands: [
{
fixType: "updateMain",
command: "npm install -g @anthropic-ai/claude-code@latest",
},
],
updateFixTypes: ["updateMain"],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -785,12 +780,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "update", {
installFixType: null,
updateCommands: [
{
fixType: "updateMain",
command: "npm install -g @anthropic-ai/claude-code@latest",
},
],
updateFixTypes: ["updateMain"],
verifyInstall: true,
});
});
Expand All @@ -809,7 +799,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "auth", {
installFixType: null,
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -921,9 +911,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("codex-acp", "install", {
installFixType: "bridge",
updateCommands: [
{ fixType: "updateMain", command: "brew upgrade codex" },
],
updateFixTypes: ["updateMain"],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -966,7 +954,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("codex-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
// The backend's post-install verification mirrors the readiness gate:
// a bundled-bridge provider must resolve its only binary under `path`,
Expand Down Expand Up @@ -1062,7 +1050,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("codex-acp", "install", {
installFixType: "command",
updateCommands: [],
updateFixTypes: [],
verifyInstall: true,
});
});
Expand Down Expand Up @@ -1175,16 +1163,7 @@ describe("AgentProviderCard", () => {
await waitFor(() => {
expect(startAgentSetup).toHaveBeenCalledWith("claude-acp", "update", {
installFixType: null,
updateCommands: [
{
fixType: "updateMain",
command: "curl -fsSL https://example.com/install.sh | bash",
},
{
fixType: "updateBridge",
command: "npm install -g claude-agent-acp@latest",
},
],
updateFixTypes: ["updateMain", "updateBridge"],
verifyInstall: true,
});
});
Expand Down
22 changes: 22 additions & 0 deletions src/shared/api/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ describe("doctor API", () => {
});
});

it("never forwards a renderer-supplied command to run_doctor_fix", async () => {
// Regression for finding 7: the wire contract carries only the typed
// (checkId, fixType) identity. Even for an update fix — whose command used
// to ride along as `commandOverride` — no command string may cross to the
// backend, so a compromised renderer has no shell escape hatch.
mockedInvoke.mockResolvedValue(undefined);

const { runDoctorFix } = await import("../doctor");
await runDoctorFix("ai-agent-claude", "updateMain");

const payload = mockedInvoke.mock.calls.at(-1)?.[1] as Record<
string,
unknown
>;
expect(payload).toEqual({
checkId: "ai-agent-claude",
fixType: "updateMain",
});
expect(payload).not.toHaveProperty("commandOverride");
expect(payload).not.toHaveProperty("command");
});

it("detects synthetic doctor timeout reports", async () => {
const { isDoctorTimeoutReport } = await import("../useDoctorReport");

Expand Down
3 changes: 1 addition & 2 deletions src/shared/api/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export async function runDoctorFresh(): Promise<DoctorReport> {
export async function runDoctorFix(
checkId: string,
fixType: FixType,
commandOverride?: string,
): Promise<void> {
return invoke("run_doctor_fix", { checkId, fixType, commandOverride });
return invoke("run_doctor_fix", { checkId, fixType });
}