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
18 changes: 18 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ false until that matrix is signed.

## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断

In Web and Desktop, a newly created task opens automatically only while its
original waiting page is still current. After navigating elsewhere, use the
creation notice to open it; its first message continues with the original
request identity. Resuming a paused task also keeps its pending result, retry
identity, and refresh bound to that task and Run, without changing another task's
draft or controls.

Web 和 Desktop 创建任务后,仅在仍停留于原等待页时自动打开。切换页面后,可通过
“打开已创建的对话”提示主动进入;首条消息仍按原请求继续交接。解除暂停的等待、
错误与重试归属原任务及 Run,不影响当前任务的草稿和操作状态。

Both connection paths preserve the enabled batch delivery and host-validation
capabilities together with their existing permission requirements. Read-only
connections can inspect pending approvals and the API's redacted previews;
approval, denial, and decision recovery still require approval control authority.
两种连接入口均保留已启用的批次交付能力及原有权限组合。只读连接可查看待审批条目
与接口允许展示的脱敏预览;批准、拒绝和恢复决定仍需审批控制权限。

```powershell
cyberagent run create "review this workspace" --workspace demo --profile review --surface code --phase plan
cyberagent run create "explain this code" --profile learn --max-turns 40 --max-tokens 20000 --timeout 20m
Expand Down
67 changes: 55 additions & 12 deletions web/src/components/connection-gate.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useConnectionStore } from "../state/connection";
import { createV2Client } from "../v2/client-session";
import { ConnectionGate } from "./connection-gate";

vi.mock("../lib/locale", () => ({
Expand All @@ -15,8 +16,12 @@ describe("ConnectionGate", () => {
delete window.go;
});

afterEach(() => {
afterEach(async () => {
cleanup();
const { useConnectionStore: currentStore } = await import("../state/connection");
currentStore.getState().disconnect();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

it("validates the token through health and does not render it as text", async () => {
Expand Down Expand Up @@ -48,6 +53,10 @@ describe("ConnectionGate", () => {
expect(useConnectionStore.getState().fileEditProposalEnabled).toBe(true);
expect(useConnectionStore.getState().workspaceImportEnabled).toBe(true);
expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(true);
expect(useConnectionStore.getState().apiBaseURL).toBe("/api/v1");
const client = createV2Client(useConnectionStore.getState());
expect(client.hasBatchDeliveryControl).toBe(true);
expect(client.hasBatchDeliveryHostValidation).toBe(true);
});

it("retains the advertised execution read route when connecting without a control token", async () => {
Expand All @@ -65,9 +74,37 @@ describe("ConnectionGate", () => {
expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(true);
expect(useConnectionStore.getState().runExecutionEnabled).toBe(false);
expect(useConnectionStore.getState().threadControlEnabled).toBe(false);
expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryControl).toBe(false);
expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryHostValidation).toBe(false);
});

it("auto-connects a closed-authority Desktop bootstrap without rendering its token", async () => {
it("rejects contradictory Web batch host capabilities before connecting", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
version: "api.v1", request_id: "req-health",
data: { status: "ok", api_version: "api.v1", app_version: "test", schema_version: 129 },
}), { status: 200 })).mockResolvedValueOnce(new Response(JSON.stringify({
version: "api.v1", request_id: "req-capabilities",
data: { ...runtimeCapabilities(), batch_delivery_control_enabled: false },
}), { status: 200 })));
const user = userEvent.setup();
render(<QueryClientProvider client={new QueryClient()}><ConnectionGate /></QueryClientProvider>);
await user.type(screen.getByLabelText("只读访问令牌"), "read-token-fixture");
await user.type(screen.getByLabelText(/控制访问令牌/), "control-token-fixture");
await user.click(screen.getByRole("button", { name: "连接" }));
expect(await screen.findByRole("alert")).toHaveTextContent("capability response is invalid");
expect(useConnectionStore.getState().token).toBe("");
expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false);
expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false);
});

it.each([false, true])("carries Desktop batch authority through bootstrap without exposing its token: %s", async (control) => {
vi.resetModules();
const { ConnectionGate } = await import("./connection-gate");
const { useConnectionStore } = await import("../state/connection");
const { createV2Client } = await import("../v2/client-session");
// Desktop's validated address must survive V2 client construction even if
// a Web build default points elsewhere.
vi.stubEnv("VITE_API_BASE_URL", "https://other-origin.example/api/v1");
const bootstrap = vi.fn().mockResolvedValue({
protocol_version: "desktop_connection_bootstrap.v1",
agent_code_tools_enabled: true,
Expand All @@ -77,14 +114,14 @@ describe("ConnectionGate", () => {
app_version: "v0.1.0",
ui_digest: "a".repeat(64),
read_token: "desktop-read-token-0123456789abcdef",
control_token: "",
control_token: control ? "desktop-control-token-0123456789abcdef" : "",
control_enabled: false,
execution_permission_control_enabled: false,
execution_permission_control_enabled: control,
browser_cdp_permission_control_enabled: false,
full_cdp_debug_enabled: false,
full_cdp_session_control_enabled: false,
operator_approval_enabled: false,
danger_full_access_enabled: false,
operator_approval_enabled: control,
danger_full_access_enabled: control,

workspace_sandbox_enabled: false,
command_runtime_enabled: false,
Expand Down Expand Up @@ -112,7 +149,7 @@ describe("ConnectionGate", () => {
run_wake_worker_enabled: false,
scheduled_job_control_enabled: false,
scheduled_job_worker_enabled: false,
read_only_default: true,
read_only_default: !control,
process_execution_enabled: false,
shell_execution_enabled: false,
docker_execution_enabled: false,
Expand All @@ -124,8 +161,8 @@ describe("ConnectionGate", () => {
workspace_checkpoint_control_enabled: false,
git_advanced_control_enabled: false,
github_review_control_enabled: false,
batch_delivery_control_enabled: false,
batch_delivery_host_validation_enabled: false,
batch_delivery_control_enabled: control,
batch_delivery_host_validation_enabled: control,
user_terminal_enabled: false,
agent_terminal_input_default: false,
workspace_open_enabled: false,
Expand All @@ -151,10 +188,16 @@ describe("ConnectionGate", () => {
render(<QueryClientProvider client={new QueryClient()}><ConnectionGate /></QueryClientProvider>);
await waitFor(() => expect(useConnectionStore.getState().token)
.toBe("desktop-read-token-0123456789abcdef"));
expect(useConnectionStore.getState().controlToken).toBe("");
expect(useConnectionStore.getState().controlToken)
.toBe(control ? "desktop-control-token-0123456789abcdef" : "");
expect(useConnectionStore.getState().workspaceImportEnabled).toBe(false);
expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(false);
const client = createV2Client(useConnectionStore.getState());
expect(client.baseURL).toBe("/api/v1");
expect(client.hasBatchDeliveryControl).toBe(control);
expect(client.hasBatchDeliveryHostValidation).toBe(control);
expect(screen.queryByText("desktop-read-token-0123456789abcdef")).not.toBeInTheDocument();
expect(screen.queryByText("desktop-control-token-0123456789abcdef")).not.toBeInTheDocument();
expect(bootstrap).toHaveBeenCalledTimes(1);
});
});
Expand Down Expand Up @@ -200,7 +243,7 @@ function runtimeCapabilities() {
git_advanced_control_enabled: true,
github_review_control_enabled: true,
batch_delivery_control_enabled: true,
batch_delivery_host_validation_enabled: false,
batch_delivery_host_validation_enabled: true,
shell_execution_enabled: true, docker_execution_enabled: false,
wake_worker: { protocol_version: "run_wake_worker_health.v1", enabled: false,
state: "disabled", active: false, poll_interval_ms: 0, concurrency: 1,
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/connection-gate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function ConnectionGate() {
dockerExecutionEnabled: bootstrap.docker_execution_enabled,
agentCodeToolsEnabled: bootstrap.agent_code_tools_enabled,
codeIntelEnabled: bootstrap.code_intel_enabled,
});
}, client.baseURL);
}).catch((caught: unknown) => {
if (active) {
setError(desktopErrorMessage(caught));
Expand Down Expand Up @@ -114,7 +114,8 @@ export function ConnectionGate() {
client.health(), client.runtimeCapabilities(),
]);
queryClient.clear();
connect(candidate, health, controlToken.trim(), clientCapabilitiesFromRuntime(capabilities));
connect(candidate, health, controlToken.trim(), clientCapabilitiesFromRuntime(capabilities),
client.baseURL);
setToken("");
setControlToken("");
} catch (caught) {
Expand Down
20 changes: 20 additions & 0 deletions web/src/lib/desktop-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,26 @@ describe("desktop native bridge", () => {
await expect(module.loadDesktopBootstrap()).resolves.toEqual(dockerEnabled);
});

it.each([
"control_token", "batch_delivery_control_enabled", "execution_permission_control_enabled",
"operator_approval_enabled", "danger_full_access_enabled",
])("rejects batch host validation without its %s gate", async (missing) => {
installBridge({ Bootstrap: vi.fn().mockResolvedValue({
...bootstrap,
control_token: "control-token-0123456789abcdefghijkl",
execution_permission_control_enabled: true,
operator_approval_enabled: true,
danger_full_access_enabled: true,
batch_delivery_control_enabled: true,
batch_delivery_host_validation_enabled: true,
read_only_default: false,
[missing]: missing === "control_token" ? "" : false,
}) });
const module = await import("./desktop-bridge");
await expect(module.loadDesktopBootstrap()).rejects.toThrow("bootstrap was rejected");
expect(module.desktopRuntimeActive()).toBe(false);
});

it("accepts Advanced Git only with permission, approval, and checkpoint gates", async () => {
const enabled = {
...bootstrap,
Expand Down
30 changes: 29 additions & 1 deletion web/src/state/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ describe("connection store", () => {
commandRuntimeProtocolAvailable: true,
commandRuntimeAdapterInstalled: true,
commandRuntimeAdapterReady: true,
});
}, "/api/v1");
useConnectionStore.getState().selectRun("run-1");

expect(useConnectionStore.getState().token).toBe("ephemeral-token");
expect(useConnectionStore.getState().controlToken).toBe("ephemeral-control-token");
expect(useConnectionStore.getState().apiBaseURL).toBe("/api/v1");
expect(useConnectionStore.getState().runControlEnabled).toBe(true);
expect(useConnectionStore.getState().runCreationEnabled).toBe(true);
expect(useConnectionStore.getState().sessionMessageEnabled).toBe(true);
Expand All @@ -48,6 +49,7 @@ describe("connection store", () => {
useConnectionStore.getState().disconnect();
expect(useConnectionStore.getState().token).toBe("");
expect(useConnectionStore.getState().controlToken).toBe("");
expect(useConnectionStore.getState().apiBaseURL).toBeUndefined();
expect(useConnectionStore.getState().runControlEnabled).toBe(false);
expect(useConnectionStore.getState().runCreationEnabled).toBe(false);
expect(useConnectionStore.getState().sessionMessageEnabled).toBe(false);
Expand Down Expand Up @@ -97,4 +99,30 @@ describe("connection store", () => {
connect("read", health, "control", { runExecutionEnabled: true });
expect(createV2Client(useConnectionStore.getState()).hasThreadExecutionRead).toBe(false);
});

it("requires explicit batch capabilities and clears them on reconnect and disconnect", () => {
const connect = useConnectionStore.getState().connect;
const capabilities = {
batchDeliveryControlEnabled: true, batchDeliveryHostValidationEnabled: true,
executionPermissionControlEnabled: true, operatorApprovalEnabled: true,
dangerFullAccessEnabled: true,
};
connect("read", health, "control", capabilities);
expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(true);
expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(true);
expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryHostValidation).toBe(true);
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);

connect("read", health, "control");
expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false);
expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false);
connect("read", health, "", capabilities);
expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false);
expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false);
connect("read", health, "control", capabilities);
useConnectionStore.getState().disconnect();
expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false);
expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false);
});
});
19 changes: 15 additions & 4 deletions web/src/state/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type ResourceKind = "thread" | "run" | "session";

interface ConnectionState {
health: HealthView | null;
apiBaseURL: string | undefined;
resourceKind: ResourceKind;
selectedRunID: string;
selectedSessionID: string;
Expand Down Expand Up @@ -53,11 +54,13 @@ interface ConnectionState {
gitAdvancedControlEnabled: boolean;
githubReviewControlEnabled: boolean;
workspaceCheckpointControlEnabled: boolean;
batchDeliveryControlEnabled: boolean;
batchDeliveryHostValidationEnabled: boolean;
dockerExecutionEnabled: boolean;
agentCodeToolsEnabled: boolean;
codeIntelEnabled: boolean;
connect: (token: string, health: HealthView, controlToken?: string,
capabilities?: ClientCapabilities) => void;
capabilities?: ClientCapabilities, apiBaseURL?: string) => void;
disconnect: () => void;
selectRun: (runID: string) => void;
selectSession: (sessionID: string) => void;
Expand All @@ -76,6 +79,7 @@ const initialSelection = {
export const useConnectionStore = create<ConnectionState>((set) => ({
...initialSelection,
health: null,
apiBaseURL: undefined,
token: "",
controlToken: "",
runControlEnabled: false,
Expand Down Expand Up @@ -122,12 +126,14 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
gitAdvancedControlEnabled: false,
githubReviewControlEnabled: false,
workspaceCheckpointControlEnabled: false,
batchDeliveryControlEnabled: false,
batchDeliveryHostValidationEnabled: false,
dockerExecutionEnabled: false,
agentCodeToolsEnabled: false,
codeIntelEnabled: false,
connect: (token, health, controlToken = "", capabilities = {}) => {
connect: (token, health, controlToken = "", capabilities = {}, apiBaseURL) => {
const present = controlToken.trim() !== "";
set({ token, health, controlToken,
set({ token, health, controlToken, apiBaseURL,
runControlEnabled: present && (capabilities.runControlEnabled ?? true),
workspaceImportEnabled: present && capabilities.workspaceImportEnabled === true,
executionPermissionControlEnabled: present &&
Expand Down Expand Up @@ -196,14 +202,17 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
(capabilities.githubReviewControlEnabled ?? false),
workspaceCheckpointControlEnabled: present &&
(capabilities.workspaceCheckpointControlEnabled ?? false),
batchDeliveryControlEnabled: present && capabilities.batchDeliveryControlEnabled === true,
batchDeliveryHostValidationEnabled: present &&
capabilities.batchDeliveryHostValidationEnabled === true,
dockerExecutionEnabled: present &&
(capabilities.dockerExecutionEnabled ?? false),
agentCodeToolsEnabled: present &&
(capabilities.agentCodeToolsEnabled ?? false),
codeIntelEnabled: capabilities.codeIntelEnabled ?? false,
});
},
disconnect: () => set({ token: "", controlToken: "", health: null,
disconnect: () => set({ token: "", controlToken: "", health: null, apiBaseURL: undefined,
runControlEnabled: false, runCreationEnabled: false,
workspaceImportEnabled: false,
standardCodePresetEnabled: false, sessionMessageEnabled: false,
Expand Down Expand Up @@ -233,6 +242,8 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
gitAdvancedControlEnabled: false,
githubReviewControlEnabled: false,
workspaceCheckpointControlEnabled: false,
batchDeliveryControlEnabled: false,
batchDeliveryHostValidationEnabled: false,
dockerExecutionEnabled: false,
agentCodeToolsEnabled: false,
codeIntelEnabled: false,
Expand Down
Loading
Loading