+ {backgroundCreations.map((thread) =>
+ 对话已创建:{thread.title}
+
+
+
)}
+
}
{(workspacesQuery.isError || threadsQuery.isError) &&
项目或对话列表加载失败,已有内容会保留。
@@ -472,7 +495,6 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
}
export function V2WorkbenchEntry() {
- const connection = useConnectionStore();
- const client = useMemo(() => createV2Client(connection), [connection]);
+ const client = useV2Client();
return
;
}
diff --git a/web/src/v2/client-session.test.ts b/web/src/v2/client-session.test.ts
index 5e816c7d..4c811771 100644
--- a/web/src/v2/client-session.test.ts
+++ b/web/src/v2/client-session.test.ts
@@ -1,7 +1,25 @@
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { useEffect } from "react";
+import type { ClientCapabilities } from "../api/client";
import { useConnectionStore } from "../state/connection";
-import { createV2Client } from "./client-session";
+import { createV2Client, useV2Client } from "./client-session";
-afterEach(() => useConnectionStore.getState().disconnect());
+afterEach(() => {
+ cleanup();
+ useConnectionStore.getState().disconnect();
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+});
+
+const health = {
+ status: "ok" as const, api_version: "api.v1" as const,
+ app_version: "test", schema_version: 129,
+};
+const batchCapabilities = {
+ batchDeliveryControlEnabled: true, batchDeliveryHostValidationEnabled: true,
+ executionPermissionControlEnabled: true, operatorApprovalEnabled: true,
+ dangerFullAccessEnabled: true,
+};
it.each([true, false])("preserves advanced capability wiring while requiring a control token: %s", (control) => {
useConnectionStore.getState().connect("read-token-fixture", {
@@ -17,3 +35,118 @@ it.each([true, false])("preserves advanced capability wiring while requiring a c
expect(client.hasWorkspaceCheckpointControl).toBe(control);
expect(client.hasThreadControl).toBe(control);
});
+
+it.each
([
+ "batchDeliveryControlEnabled", "executionPermissionControlEnabled",
+ "operatorApprovalEnabled", "dangerFullAccessEnabled", "batchDeliveryHostValidationEnabled",
+])("keeps batch host validation closed without %s", (missing) => {
+ useConnectionStore.getState().connect("read", health, "control", {
+ ...batchCapabilities, [missing]: false,
+ });
+ const client = createV2Client(useConnectionStore.getState());
+ expect(client.hasBatchDeliveryControl).toBe(missing !== "batchDeliveryControlEnabled");
+ expect(client.hasBatchDeliveryHostValidation).toBe(false);
+});
+
+it("carries batch authority into V2 only with a control credential", () => {
+ useConnectionStore.getState().connect("read", health, "control", batchCapabilities);
+ const controlled = createV2Client(useConnectionStore.getState());
+ expect(controlled.hasBatchDeliveryControl).toBe(true);
+ expect(controlled.hasBatchDeliveryHostValidation).toBe(true);
+ useConnectionStore.getState().connect("read", health, "", batchCapabilities);
+ const readOnly = createV2Client(useConnectionStore.getState());
+ expect(readOnly.hasBatchDeliveryControl).toBe(false);
+ expect(readOnly.hasBatchDeliveryHostValidation).toBe(false);
+});
+
+it("keeps the client and its active effects across navigation, health refresh, and equivalent reconnects", () => {
+ const connect = useConnectionStore.getState().connect;
+ connect("read", health, "control", batchCapabilities);
+ const attach = vi.fn();
+ const detach = vi.fn();
+ const { result, rerender, unmount } = renderHook(() => {
+ const client = useV2Client();
+ useEffect(() => {
+ attach(client);
+ return () => detach(client);
+ }, [client]);
+ return client;
+ });
+ const initial = result.current;
+ act(() => {
+ useConnectionStore.getState().selectThread("thread-1");
+ useConnectionStore.getState().selectRun("run-1");
+ useConnectionStore.getState().selectSession("session-1");
+ useConnectionStore.getState().setResourceKind("thread");
+ useConnectionStore.getState().setHealth({ ...health, app_version: "updated" });
+ });
+ rerender();
+ act(() => connect("read", { ...health }, "control", { ...batchCapabilities }));
+ expect(result.current).toBe(initial);
+ expect(attach).toHaveBeenCalledTimes(1);
+ expect(detach).not.toHaveBeenCalled();
+ unmount();
+ expect(detach).toHaveBeenCalledExactlyOnceWith(initial);
+});
+
+it("replaces the client and request credentials after an authentication change", async () => {
+ const fetchMock = vi.fn().mockImplementation(async () => new Response(JSON.stringify({
+ version: "api.v1", request_id: "req-denied",
+ error: { code: "PERMISSION_DENIED", message: "fixture denial" },
+ }), { status: 403 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const connect = useConnectionStore.getState().connect;
+ connect("read-old", health, "control-old", batchCapabilities);
+ const { result } = renderHook(() => useV2Client());
+ const first = result.current;
+ act(() => connect("read-new", health, "control-old", batchCapabilities));
+ expect(result.current).not.toBe(first);
+ await expect(result.current.health()).rejects.toThrow("fixture denial");
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer read-new");
+
+ const second = result.current;
+ act(() => connect("read-new", health, "control-new", batchCapabilities));
+ expect(result.current).not.toBe(second);
+ await expect(result.current.cancelRunBatchDelivery("run-1", "plan-1", {
+ version: "batch_delivery_cancel.v1", confirm: true, reason: "operator cancellation",
+ }, "operation-key-fixture")).rejects.toThrow("fixture denial");
+ expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe("Bearer control-new");
+});
+
+it("replaces the client when capabilities change and immediately applies their new gates", () => {
+ const connect = useConnectionStore.getState().connect;
+ connect("read", health, "control", batchCapabilities);
+ const { result } = renderHook(() => useV2Client());
+ const enabled = result.current;
+ act(() => connect("read", health, "control", {
+ ...batchCapabilities, batchDeliveryHostValidationEnabled: false,
+ }));
+ expect(result.current).not.toBe(enabled);
+ expect(result.current.hasBatchDeliveryControl).toBe(true);
+ expect(result.current.hasBatchDeliveryHostValidation).toBe(false);
+ const withoutHostValidation = result.current;
+ act(() => connect("read", health, "control", {
+ ...batchCapabilities, batchDeliveryControlEnabled: false,
+ }));
+ expect(result.current).not.toBe(withoutHostValidation);
+ expect(result.current.hasBatchDeliveryControl).toBe(false);
+ expect(result.current.hasBatchDeliveryHostValidation).toBe(false);
+});
+
+it("preserves an explicit connection address, tracks its changes, and retains the default", () => {
+ vi.stubEnv("VITE_API_BASE_URL", "/api/v1");
+ const connect = useConnectionStore.getState().connect;
+ connect("read", health, "control");
+ const { result, unmount } = renderHook(() => useV2Client());
+ const implicit = result.current;
+ expect(implicit.baseURL).toBe("/api/v1");
+ act(() => connect("read", health, "control", {}, "/api/v1/"));
+ expect(result.current).not.toBe(implicit);
+ expect(result.current.baseURL).toBe("/api/v1");
+ unmount();
+
+ vi.stubEnv("VITE_API_BASE_URL", "https://other-origin.example/api/v1");
+ expect(createV2Client(useConnectionStore.getState()).baseURL).toBe("/api/v1");
+ connect("read", health, "control");
+ expect(() => createV2Client(useConnectionStore.getState())).toThrow("current browser origin");
+});
diff --git a/web/src/v2/client-session.ts b/web/src/v2/client-session.ts
index efdb460c..7d9718f5 100644
--- a/web/src/v2/client-session.ts
+++ b/web/src/v2/client-session.ts
@@ -1,9 +1,13 @@
+import { useMemo } from "react";
+import { useShallow } from "zustand/react/shallow";
import { APIClient, type ClientCapabilities } from "../api/client";
import { useConnectionStore } from "../state/connection";
type ConnectionSnapshot = ReturnType;
+type ClientConnection = Pick &
+ ClientCapabilities;
-function capabilities(state: ConnectionSnapshot): ClientCapabilities {
+function capabilities(state: ClientCapabilities): ClientCapabilities {
return {
runControlEnabled: state.runControlEnabled,
workspaceImportEnabled: state.workspaceImportEnabled,
@@ -49,12 +53,28 @@ function capabilities(state: ConnectionSnapshot): ClientCapabilities {
workspaceCheckpointControlEnabled: state.workspaceCheckpointControlEnabled,
gitAdvancedControlEnabled: state.gitAdvancedControlEnabled,
githubReviewControlEnabled: state.githubReviewControlEnabled,
+ batchDeliveryControlEnabled: state.batchDeliveryControlEnabled,
+ batchDeliveryHostValidationEnabled: state.batchDeliveryHostValidationEnabled,
dockerExecutionEnabled: state.dockerExecutionEnabled,
agentCodeToolsEnabled: state.agentCodeToolsEnabled,
codeIntelEnabled: state.codeIntelEnabled,
};
}
-export function createV2Client(state: ConnectionSnapshot): APIClient {
- return new APIClient(state.token, undefined, state.controlToken, capabilities(state));
+export function createV2Client(state: ClientConnection): APIClient {
+ return new APIClient(state.token, state.apiBaseURL, state.controlToken, capabilities(state));
+}
+
+function clientConnection(state: ConnectionSnapshot): ClientConnection {
+ return {
+ apiBaseURL: state.apiBaseURL,
+ token: state.token,
+ controlToken: state.controlToken,
+ ...capabilities(state),
+ };
+}
+
+export function useV2Client(): APIClient {
+ const connection = useConnectionStore(useShallow(clientConnection));
+ return useMemo(() => createV2Client(connection), [connection]);
}
diff --git a/web/src/v2/components/approval-cards.test.tsx b/web/src/v2/components/approval-cards.test.tsx
index 1ef0cb82..9ff2d801 100644
--- a/web/src/v2/components/approval-cards.test.tsx
+++ b/web/src/v2/components/approval-cards.test.tsx
@@ -2,9 +2,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { APIClient } from "../../api/client";
-import type { ApprovalQueueItemView } from "../../api/types";
+import type { ApprovalPreviewView, ApprovalQueueItemView } from "../../api/types";
import { V2ApprovalCards } from "./approval-cards";
+afterEach(() => vi.unstubAllGlobals());
+
function pending(overrides: Partial = {}): ApprovalQueueItemView {
return {
action_class: "public_https_fetch",
@@ -64,7 +66,93 @@ function renderCards(item: ApprovalQueueItemView, previewOverrides = {}, onRevie
return { decideApproval, approvalPreview, onReviewFile };
}
+function renderReadOnlyCards(item: ApprovalQueueItemView | null = pending(),
+ previewOverrides: Partial = {}, failure: "queue" | "preview" | null = null) {
+ const queue = { protocol_version: "approval_queue.v1", run_id: "run-1", items: item ? [item] : [],
+ truncated: false, process_execution_enabled: false, session_grant_created: false, capability_grant: false };
+ const preview = item && { protocol_version: "approval_queue.v1", run_id: "run-1", approval_id: item.id,
+ proposal_id: item.proposal_id, tool_name: item.tool_name, workspace_id: item.workspace_id,
+ effect: item.tool_name === "command_runtime" ? "command_process" : "fetch_public_https",
+ working_directory: "", fields: [{ name: "summary", value: "Read-only exact proposal [REDACTED]" }],
+ source_current: true, redacted: true, truncated: false, ...previewOverrides };
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+ const isPreview = String(url).endsWith("/preview");
+ if (init?.method !== "GET") throw new Error("Unexpected approval mutation");
+ const failed = failure === (isPreview ? "preview" : "queue");
+ return new Response(JSON.stringify(failed
+ ? { version: "api.v1", request_id: "readonly-failure", error: { code: "UNAVAILABLE", message: "Read unavailable" } }
+ : { version: "api.v1", request_id: "readonly-approval", data: isPreview ? preview : queue }),
+ { status: failed ? 503 : 200, headers: { "Content-Type": "application/json" } });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ const client = new APIClient("test-read", "/api/v1");
+ const decideApproval = vi.spyOn(client, "decideApproval");
+ render(
+
+ );
+ return { fetchMock, decideApproval, restoreReads: () => { failure = null; } };
+}
+
describe("V2ApprovalCards", () => {
+ it.each([
+ pending(),
+ pending({ tool_name: "command_runtime", action_class: "command_process",
+ allowed_actions: ["approve_once", "approve_for_run", "deny"], canonical_url: undefined, exact_target: undefined }),
+ pending({ status: "approved", allowed_actions: ["approve_for_thread"], version: 2 }),
+ ])("shows the exact proposal for a read-only $tool_name/$status connection without decision controls", async (item) => {
+ const { fetchMock, decideApproval } = renderReadOnlyCards(item);
+ expect(await screen.findByText("Read-only exact proposal [REDACTED]")).toBeVisible();
+ expect(screen.getByRole("region", { name: "待处理审批" })).toBeVisible();
+ expect(screen.getByText(/当前连接只有审批读取权限/)).toBeVisible();
+ expect(screen.getByText("敏感内容已脱敏;这里不会显示凭据值。")).toBeVisible();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument();
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
+ "/api/v1/runs/run-1/approvals", `/api/v1/runs/run-1/approvals/${item.id}/preview`,
+ ]);
+ expect(fetchMock.mock.calls.every(([, init]) => init?.method === "GET" &&
+ new Headers(init.headers).get("Authorization") === "Bearer test-read")).toBe(true);
+ expect(decideApproval).not.toHaveBeenCalled();
+ });
+
+ it("distinguishes a successful empty queue from an unread queue", async () => {
+ const { fetchMock } = renderReadOnlyCards(null);
+ expect(await screen.findByText("没有待处理审批。")).toBeVisible();
+ expect(screen.queryByRole("region", { name: "待处理审批" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a failed queue read and retries with the read token", async () => {
+ const { fetchMock, decideApproval, restoreReads } = renderReadOnlyCards(pending(), {}, "queue");
+ expect(await screen.findByRole("alert")).toHaveTextContent("无法读取待审批操作");
+ expect(screen.queryByText("没有待处理审批。")).not.toBeInTheDocument();
+ restoreReads();
+ await userEvent.click(screen.getByRole("button", { name: "重试审批队列" }));
+ expect(await screen.findByText("Read-only exact proposal [REDACTED]")).toBeVisible();
+ expect(fetchMock.mock.calls.filter(([url]) => url.endsWith("/approvals"))).toHaveLength(2);
+ expect(decideApproval).not.toHaveBeenCalled();
+ });
+
+ it("retains the queue and retries a failed exact preview read without submitting a decision", async () => {
+ const { decideApproval, restoreReads } = renderReadOnlyCards(pending(), {}, "preview");
+ expect(await screen.findByText("无法核对操作内容,暂不能批准。")).toBeVisible();
+ expect(screen.getByText("arxiv.org")).toBeVisible();
+ restoreReads();
+ await userEvent.click(screen.getByRole("button", { name: "重试操作预览" }));
+ expect(await screen.findByText("Read-only exact proposal [REDACTED]")).toBeVisible();
+ expect(decideApproval).not.toHaveBeenCalled();
+ });
+
+ it.each([false, true])("shows a stale read-only preview with truncated=%s without offering a decision", async (truncated) => {
+ const { decideApproval } = renderReadOnlyCards(pending(), { source_current: false, truncated });
+ expect(await screen.findByText(truncated ? "预览超过显示上限,不能据此批准。" : "操作已变化或不再等待批准。")).toBeVisible();
+ expect(screen.getByRole("button", { name: "刷新操作预览" })).toBeEnabled();
+ expect(screen.queryByRole("button", { name: /允许一次|本对话允许|拒绝/ })).not.toBeInTheDocument();
+ expect(decideApproval).not.toHaveBeenCalled();
+ });
+
it("reviews each command with explicit bounded Run limits and preserves retry intent", async () => {
const item = pending({tool_name:"command_runtime",action_class:"command_process",allowed_actions:["approve_once","approve_for_run","deny"],canonical_url:undefined,exact_target:undefined});
const {decideApproval}=renderCards(item,{effect:"command_process",fields:[{name:"review_scope",value:"local verification"}]});
diff --git a/web/src/v2/components/approval-cards.tsx b/web/src/v2/components/approval-cards.tsx
index 7be9bf9a..7813dc6e 100644
--- a/web/src/v2/components/approval-cards.tsx
+++ b/web/src/v2/components/approval-cards.tsx
@@ -32,7 +32,7 @@ export function V2ApprovalCards({ client, runID, threadID, onReviewFile }: {
const query = useQuery({
queryKey: v2QueryKeys.approvals(runID),
queryFn: ({ signal }) => client.approvalQueue(runID, signal),
- enabled: Boolean(runID) && client.hasApprovalControl,
+ enabled: Boolean(runID),
refetchInterval: 2_000,
});
const decided = (message: string) => {
@@ -43,11 +43,16 @@ export function V2ApprovalCards({ client, runID, threadID, onReviewFile }: {
};
return <>
{notice && {notice}
}
+ {query.isLoading && 正在读取待审批操作…
}
{query.isError &&
无法读取待审批操作,请重试后再作决定。
}
- {Boolean(query.data?.items.length) &&
+ {query.isSuccess && query.data.items.length === 0 && 没有待处理审批。
}
+ {Boolean(query.data?.items.length) &&
+ {!client.hasApprovalControl &&
+ 当前连接只有审批读取权限;批准、拒绝或继续恢复需要审批控制权限。
+
}
{query.data?.items.map((item) => )}
{query.data?.truncated && 待审批操作较多;处理后会继续显示其余操作。
}
@@ -113,7 +118,7 @@ function ApprovalCard({ client, item, runID, onDecided, onReviewFile }: {
},
onError: () => { void preview.refetch(); },
});
- const canApprove = preview.isSuccess && !preview.isFetching && preview.data.source_current &&
+ const canApprove = client.hasApprovalControl && preview.isSuccess && !preview.isFetching && preview.data.source_current &&
!preview.data.truncated && !mutation.isPending;
const dryRun = preview.data?.effect === "dry_run";
return
@@ -142,10 +147,10 @@ function ApprovalCard({ client, item, runID, onDecided, onReviewFile }: {
}
>}
{recovering && 上次决定已经保存。继续只会恢复同一决定,不会更改授权范围。
}
- {!recovering && item.allowed_actions.includes("deny") && setReason(event.target.value)} placeholder="拒绝原因(可选)" value={reason} />}
- {item.allowed_actions.includes("approve_for_run") && )}
{(eventStream.error || publicStream.error) && working &&