From 3a8bb0e2dcc3d2e16431ab3b1794c684facad746 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:17:42 +0800 Subject: [PATCH 1/3] fix(web): align approval readiness and task capability entries --- docs/FRONTEND_CAPABILITY_ALIGNMENT_287.md | 61 ++++++ docs/PROJECT_MEMORY.md | 7 + docs/usage.md | 15 ++ internal/httpapi/capability_readiness_test.go | 99 +++++++++ web/src/api/client.test.ts | 103 ++++++--- web/src/api/client.ts | 8 +- web/src/components/run-projections.test.tsx | 30 +++ web/src/components/run-projections.tsx | 12 +- web/src/components/ui-evidence-panel.test.tsx | 76 ++++++- web/src/components/ui-evidence-panel.tsx | 44 +++- web/src/test/capability-readiness.ts | 12 +- .../readiness-paused-plan-current.json | 200 ++++++++++++++++++ .../components/browser-cdp-control.test.tsx | 8 +- web/src/v2/components/browser-cdp-control.tsx | 4 +- .../v2/components/execution-settings.test.tsx | 4 +- web/src/v2/components/execution-settings.tsx | 6 +- .../high-risk-activation-dialog.test.tsx | 4 +- .../high-risk-activation-dialog.tsx | 10 +- .../run-network-authority-control.test.tsx | 47 +++- .../run-network-authority-control.tsx | 19 +- .../components/runtime-capability-control.tsx | 2 +- .../v2/components/task-review-tools.test.tsx | 190 +++++++++++++++++ web/src/v2/components/task-review-tools.tsx | 68 ++++++ web/src/v2/components/task-review.css | 9 + web/src/v2/components/task-review.test.tsx | 2 +- web/src/v2/components/task-review.tsx | 35 ++- 26 files changed, 976 insertions(+), 99 deletions(-) create mode 100644 docs/FRONTEND_CAPABILITY_ALIGNMENT_287.md create mode 100644 web/src/test/fixtures/readiness-paused-plan-current.json create mode 100644 web/src/v2/components/task-review-tools.test.tsx create mode 100644 web/src/v2/components/task-review-tools.tsx diff --git a/docs/FRONTEND_CAPABILITY_ALIGNMENT_287.md b/docs/FRONTEND_CAPABILITY_ALIGNMENT_287.md new file mode 100644 index 00000000..549dd211 --- /dev/null +++ b/docs/FRONTEND_CAPABILITY_ALIGNMENT_287.md @@ -0,0 +1,61 @@ +# Frontend permission and capability alignment (#287) + +## Problem and implementation + +The backend readiness projection returns Ask / Auto / Full, while the frontend +validator required five retired writable choices. A valid HTTP 200 response was +therefore rejected before execution settings and review checks could render. +The validator and current fixtures now use the canonical three-choice sequence; +missing, duplicate, unknown, reordered and legacy readiness groups are rejected. +Historical Run/Thread permission snapshots retain their existing read support. + +`TestRunCapabilityReadinessHTTPCurrentFixtureMatchesGoProjection` creates an +isolated SQLite Run and obtains the response through the real Go HTTP handler. +Only generated request and Run identities are normalized. Every public field is +compared with the committed current fixture during normal Go tests. APIClient +tests parse that same fixture, and optionally the freshly exported response via +`TRAVERSE_TEST_READINESS_OUTPUT`. The original dated five-choice capture remains +unchanged as a negative current-contract specimen. + +Execution settings, Debug activation and CDP descriptions distinguish approval +preference from runtime capability. Direct URL fetch status uses the stored +network Scope, including a historical `public_https` target when actually present; +Full or old Debug values do not manufacture a network grant. + +Task review now has a first-level **更多交付工具** entry for batch delivery, +advanced Git, GitHub review and UI evidence. Each existing panel loads on demand +with the selected Run identity. Exact Git/GitHub review results survive a visit to +that Run's approval or delivery checks; changing Run clears retained review. +A missing selected historical Run fails closed instead of selecting the current +Run. No execution endpoint or startup default changed. + +UI evidence distinguishes missing control credentials from disabled UI evidence, +Run execution and browser-CDP capabilities. An unavailable history reader keeps +history unknown. Batch validation explains its independent startup requirements. +These instructions expose existing configuration paths; they do not provide new +runtime activation APIs or bypass backend checks. + +## Verification and limits + +- Full frontend suite: 175 files / 1,676 tests passed before the final UI-reader + 404 cases and CDP wording follow-up. The affected final slice passed 23 tests, + including six new UI-reader error/history cases. TypeScript, production build + and generated API checks passed; the existing large-chunk build warning remains. +- The Go HTTP/application readiness tests passed. A fresh Go export was also + consumed by the real TypeScript APIClient. Fixed declared Local adapter facts + make the fixture portable; this is not evidence of a real OS sandbox launch. +- The complete HTTP API package passed locally, along with its vet check and the + release/documentation contract packages. +- Read-only cross-checks covered exact network scope, live Full requirements, + Run/Thread isolation and retention of precise approval targets. +- Chromium used a separate loopback Go API, fresh application home and local + fixture workspace. The acceptance Run was seeded through `RunService` without + a model call. Its actual three-choice HTTP response rendered execution settings; + the four tool entries and disabled UI-evidence reader state were inspected at + desktop width. The tool selector was also checked at a 390-pixel viewport. + +No external model, GitHub review write, repository mutation, host validation, +managed browser startup or native Desktop restart was performed during the +browser check. It verifies entry points and current response consumption, not a +complete delivery or native-runtime acceptance run. Local screenshots and logs +remain in ignored `output/playwright/` and `build/permission-alignment/`. diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index 4808f506..8eb1e0f0 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -1,5 +1,12 @@ # Prayu Project Memory +> 2026-10-07 permission/capability alignment (#287): the frontend consumes the +> current Ask / Auto / Full readiness contract, with a Go HTTP fixture checked by +> both Go and the real APIClient. Task review exposes existing Run-bound delivery +> tools and explains independent startup gates. Historical permission readers and +> all Go authority checks remain in place. See +> [implementation and verification scope](FRONTEND_CAPABILITY_ALIGNMENT_287.md). + > 2026-10-07 extension onboarding checkpoint (#276): shared Web/Desktop settings > stage manual MCP descriptors, import pinned Plugin ZIPs and stage/review LSP > configuration through Go-owned services. Backend capability flags, source/scope, diff --git a/docs/usage.md b/docs/usage.md index a3d00115..b9e322c6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -235,6 +235,21 @@ Schema v86 separates execution interaction intent from general runtime authority Current execution preferences are `ask|auto|full`. Ask and Auto use the common per-operation authorizer and exact approvals when required; Full requires explicit confirmation and live activation in the current process. The Standard Code preset selects Ask. Commands use the same `command-runtime.v2` protocol with an independently ready Local or explicit Docker sandbox adapter, or an explicitly installed host adapter. A missing sandbox never falls back to host execution. `--enable-workspace-sandbox` opens the Local gate only after its real AppContainer/WFP/Job/ACL readiness proof; Docker remains independently enabled. Host Ask/Auto calls require exact durable approval, while host Full requires live activation. Saved permission rows cannot restore either authority. A permission revision change fences old calls and Jobs. See [Command Runtime adapter split](architecture/command-runtime-adapter-split.md) and [retirement decision](adr/0165-retire-legacy-command-execution.md). +The current Web/Desktop permission selector and readiness projection use only +Ask / Auto / Full; the five legacy values remain historical read formats. The +execution environment, Debug interaction/runtime, exact URL fetch scope and +browser-CDP permissions are separate controls. A Debug restart does not restore +Full activation or enable independent batch-validation/UI-evidence capabilities. + +In a task, open **审阅改动 → 更多交付工具** to select batch delivery, advanced Git, +GitHub review or UI evidence. Select the execution record before opening a tool; +its reads, review and approval flow remain bound to that Run. Switching records +clears the retained tool review. Missing startup capabilities show their existing +configuration requirements and keep execution disabled. UI evidence history is +still read independently of write capability; an unavailable reader is shown as +unknown history, never as an empty evidence ledger. See +[the #287 acceptance record](FRONTEND_CAPABILITY_ALIGNMENT_287.md). + `run capability-readiness` and `GET /api/v1/runs/{run_id}/capability-readiness` return the same Go-owned `run_capability_readiness.v1` projection used by Desktop. Each Permission, Profile, Interaction, browser-CDP, and Standard Code option reports diff --git a/internal/httpapi/capability_readiness_test.go b/internal/httpapi/capability_readiness_test.go index 448b44af..d419f6d8 100644 --- a/internal/httpapi/capability_readiness_test.go +++ b/internal/httpapi/capability_readiness_test.go @@ -1,11 +1,18 @@ package httpapi import ( + "encoding/json" "net/http" + "os" + "path/filepath" + "reflect" "strings" "testing" "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/commandruntimeadapter" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/store" ) func TestRunCapabilityReadinessHTTPProjectsSameStableGoFacts(t *testing.T) { @@ -48,6 +55,98 @@ func TestRunCapabilityReadinessHTTPProjectsSameStableGoFacts(t *testing.T) { http.StatusBadRequest, "INVALID_ARGUMENT") } +func TestRunCapabilityReadinessHTTPCurrentFixtureMatchesGoProjection(t *testing.T) { + state, err := store.Open(filepath.Join(t.TempDir(), "readiness-contract.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = state.Close() }) + runs := application.NewRunService(state) + _, run, err := runs.Create(t.Context(), application.CreateRunRequest{ + Goal: "read paused Plan readiness", Profile: "code", Surface: "code", + Phase: "plan", Budget: domain.Budget{MaxTurns: 2}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := application.NewRunExecutionProfileService(state).Change(t.Context(), + application.ChangeRunExecutionProfileRequest{RunID: run.ID, Profile: "local", + OperationKey: "readiness-contract-local-profile", RequestedBy: "test_operator", + Reason: "project selected installed adapter without execution"}); err != nil { + t.Fatal(err) + } + if _, err := runs.Start(t.Context(), run.ID); err != nil { + t.Fatal(err) + } + if _, err := runs.Pause(t.Context(), run.ID); err != nil { + t.Fatal(err) + } + capabilities := domain.ExecutionPermissionRuntimeCapabilities{ + OperatorApprovalEnabled: true, WorkspaceSandboxEnabled: true, + } + adapters := []commandruntimeadapter.Identity{commandruntimeadapter.SandboxedWorkspace( + application.CommandRuntimeLocalSandboxBackend, "readiness-fixture-local.v1", strings.Repeat("a", 64))} + runtime := application.CapabilityReadinessRuntime{ + RunControlEnabled: true, ExecutionPermissionControlEnabled: true, + ExecutionPermissionCapabilities: capabilities, + LocalSandboxInstalled: true, LocalSandboxProven: true, LocalBackendReady: true, + CommandRuntimeAdapters: adapters, + } + api, err := New(state, Config{ + AccessToken: testAccessToken, ControlToken: testControlToken, + RunControlEnabled: true, ExecutionPermissionControlEnabled: true, + ExecutionPermissionCapabilities: capabilities, CommandRuntimeAdapters: adapters, + CapabilityReadinessRuntime: &runtime, + }) + if err != nil { + t.Fatal(err) + } + response := performSessionMessageRequest(t, api, http.MethodGet, + "/api/v1/runs/"+run.ID+"/capability-readiness", testAccessToken, "", "", nil) + var view RunCapabilityReadinessView + decodeDataStatus(t, response, http.StatusOK, &view) + if len(view.Permissions) != 3 || view.Permissions[0].Value != "ask" || + view.Permissions[1].Value != "auto" || view.Permissions[2].Value != "full" || + !view.Permissions[0].Selected || view.CapabilityGrant || + !view.CommandRuntime.AdapterInstalled || !view.CommandRuntime.AdapterReady || + view.CommandRuntime.CurrentRunGranted || view.CommandRuntime.AdapterKind != "sandboxed_workspace" || + view.CommandRuntime.Backend != application.CommandRuntimeLocalSandboxBackend { + t.Fatalf("current readiness contract changed: %#v", view) + } + + // Capture the real HTTP envelope, normalizing only generated request/Run IDs. + // Compare every public field in ordinary Go runs so a manually maintained TS + // specimen cannot hide a backend contract change. The optional export follows + // the existing TRAVERSE_TEST_*_OUTPUT cross-language acceptance convention. + var captured map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &captured); err != nil { + t.Fatal(err) + } + captured["request_id"] = "req-readiness-current" + captured["data"].(map[string]any)["run_id"] = "run-readiness-current" + data, err := json.MarshalIndent(captured, "", " ") + if err != nil { + t.Fatal(err) + } + if output := os.Getenv("TRAVERSE_TEST_READINESS_OUTPUT"); output != "" { + if err := os.WriteFile(output, append(data, '\n'), 0600); err != nil { + t.Fatal(err) + } + } + fixturePath := filepath.Join("..", "..", "web", "src", "test", "fixtures", "readiness-paused-plan-current.json") + fixture, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatal(err) + } + var expected map[string]any + if err := json.Unmarshal(fixture, &expected); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(captured, expected) { + t.Fatalf("current readiness fixture drifted from the Go HTTP response; regenerate with TRAVERSE_TEST_READINESS_OUTPUT=%s\n%s", fixturePath, data) + } +} + func readinessHTTPOption(t *testing.T, options []CapabilityReadinessOptionView, value string, ) CapabilityReadinessOptionView { diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 92ae568f..88a34c27 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,8 +1,10 @@ +import { readFileSync } from "node:fs"; import { APIClient, clientCapabilitiesFromRuntime } from "./client"; import type { ProviderDefinitionView, RunEventStreamView, RunLifecycleControlView, ScheduledJobCreateRequestView, UIEvidenceArtifactMetadata } from "./types"; import { standardCodeDeliveryFixture } from "../test/standard-code-delivery"; import pausedPlanReadiness from "../test/fixtures/readiness-paused-plan.json"; +import currentPausedPlanReadiness from "../test/fixtures/readiness-paused-plan-current.json"; const healthEnvelope = { version: "api.v1", @@ -68,33 +70,15 @@ function runtimeCapabilitiesData(overrides: Record = {}) { }; } -function capabilityReadinessOption(value: string, selected = false) { - return { value, selected, selectable: true, runtime_available: true, - blocked_by: [] as string[], remediation: [] as string[], restart_required: false }; +function capabilityReadinessEnvelope(): typeof currentPausedPlanReadiness { + // Go checks the committed specimen against its real HTTP response. A focused + // cross-language run can also parse the exact envelope exported by that test. + const path = process.env.TRAVERSE_TEST_READINESS_OUTPUT; + return path ? JSON.parse(readFileSync(path, "utf8")) : currentPausedPlanReadiness; } function capabilityReadinessData() { - const preset = capabilityReadinessOption("standard_code"); - preset.selectable = false; - preset.runtime_available = false; - preset.blocked_by = ["capability_not_implemented"]; - preset.remediation = ["upgrade_application"]; - return { - protocol_version: "run_capability_readiness.v1", run_id: "run-1", - permissions: [capabilityReadinessOption("conservative", true), - capabilityReadinessOption("workspace_access"), capabilityReadinessOption("approval"), - capabilityReadinessOption("full_access"), capabilityReadinessOption("debug")], - profiles: [capabilityReadinessOption("preview", true), - capabilityReadinessOption("docker"), capabilityReadinessOption("local")], - interactions: [capabilityReadinessOption("preview", true), - capabilityReadinessOption("controlled"), capabilityReadinessOption("debug"), - capabilityReadinessOption("cyber")], - browser_cdp_permissions: [capabilityReadinessOption("restricted", true), - capabilityReadinessOption("full_debug")], - command_runtime: { protocol_available: true, adapter_installed: true, - adapter_ready: true, current_run_granted: false }, - presets: [preset], capability_grant: false, - }; + return { ...structuredClone(capabilityReadinessEnvelope().data), run_id: "run-1" }; } function standardCodeTrustData(overrides: Record = {}) { @@ -479,6 +463,27 @@ describe("APIClient", () => { } }); + it.each([ + ["", {}, "missing_control_credential"], + [" ", { uiEvidenceControlEnabled: true }, "missing_control_credential"], + ["control-secret", {}, "ui_evidence_disabled"], + ["control-secret", { uiEvidenceControlEnabled: false, runExecutionEnabled: false }, "ui_evidence_disabled"], + ["control-secret", { uiEvidenceControlEnabled: true, runExecutionEnabled: false }, "run_execution_disabled"], + ["control-secret", { uiEvidenceControlEnabled: true, runExecutionEnabled: true, + browserCDPPermissionControlEnabled: false }, "browser_cdp_control_disabled"], + ["control-secret", { uiEvidenceControlEnabled: true, runExecutionEnabled: true, + browserCDPPermissionControlEnabled: true }, null], + ["control-secret", { uiEvidenceControlEnabled: true, runExecutionEnabled: true, + browserCDPPermissionControlEnabled: true, runControlEnabled: false }, null], + ] as const)("projects the UI evidence availability reason for control %j and capabilities %j", (control, capabilities, reason) => { + const client = new APIClient("read-secret", "/api/v1", control, capabilities); + expect(client.uiEvidenceUnavailableReason).toBe(reason); + expect(client.hasUIEvidence).toBe(reason === null); + if ("runControlEnabled" in capabilities && capabilities.runControlEnabled === false) { + expect(client.hasControl).toBe(false); + } + }); + it("keeps the bearer out of the URL and sends it only in Authorization", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(healthEnvelope), { status: 200, @@ -775,14 +780,47 @@ describe("APIClient", () => { expect(init.headers).toMatchObject({ Authorization: "Bearer read-secret" }); }); - it("accepts the captured paused Plan response with a selected LPAC adapter and no active Run grant", async () => { - // Captured from Phase H's real HTTP service after two workspace reads and - // plan_delivery_propose paused the same Run; no idealized readiness builder. - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(pausedPlanReadiness), { + it("accepts the current Go paused Plan response with three permissions and an installed LPAC adapter without a Run grant", async () => { + const envelope = capabilityReadinessEnvelope(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(envelope), { status: 200, headers: { "Content-Type": "application/json" }, }))); const client = new APIClient("read-secret"); - await expect(client.runCapabilityReadiness(pausedPlanReadiness.data.run_id)).resolves.toEqual(pausedPlanReadiness.data); + const result = await client.runCapabilityReadiness(envelope.data.run_id); + expect(result).toEqual(envelope.data); + expect(result.permissions.map((option) => option.value)).toEqual(["ask", "auto", "full"]); + expect(result.command_runtime).toMatchObject({ adapter_installed: true, adapter_ready: true, + adapter_kind: "sandboxed_workspace", backend: "local_windows_lpac", current_run_granted: false }); + expect(result.capability_grant).toBe(false); + }); + + it("rejects an archived five-mode readiness response as the current selectable contract", async () => { + // Preserve the original Phase H HTTP capture as historical evidence. Current + // readiness offers only writable choices; historical permission reads are + // exercised separately by approval-mode-integration.test.tsx. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(pausedPlanReadiness), { + status: 200, headers: { "Content-Type": "application/json" }, + }))); + await expect(new APIClient("read-secret").runCapabilityReadiness(pausedPlanReadiness.data.run_id)) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it.each([ + ["missing Auto", (data: ReturnType) => { data.permissions.splice(1, 1); }], + ["duplicate Ask", (data: ReturnType) => { data.permissions[1] = { ...data.permissions[0]! }; }], + ["unknown mode", (data: ReturnType) => { data.permissions[1]!.value = "unknown_permission"; }], + ["retired mode", (data: ReturnType) => { data.permissions[1]!.value = "workspace_access"; }], + ["extra mode", (data: ReturnType) => { data.permissions.push({ ...data.permissions[2]! }); }], + ["reordered modes", (data: ReturnType) => { data.permissions.reverse(); }], + ["multiple selected modes", (data: ReturnType) => { data.permissions[1]!.selected = true; }], + ])("rejects a noncanonical current readiness permission group with %s", async (_label, mutate) => { + const data = capabilityReadinessData(); + mutate(data); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-readiness-permission-drift", data, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await expect(new APIClient("read-secret").runCapabilityReadiness(data.run_id)) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); }); it("rejects incomplete or malformed adapter identities even without an active Run grant", async () => { @@ -790,8 +828,9 @@ describe("APIClient", () => { vi.stubGlobal("fetch", fetchMock); for (const patch of [{ adapter_kind: undefined }, { backend: undefined }, { adapter_kind: "unknown_adapter" }, { adapter_kind: ["sandboxed_workspace"] }, { backend: "b".repeat(257) }, { adapter_kind: "" }, { backend: "" }]) { - const data = { ...pausedPlanReadiness.data, command_runtime: { ...pausedPlanReadiness.data.command_runtime, ...patch } }; - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ...pausedPlanReadiness, data }), { + const envelope = capabilityReadinessEnvelope(); + const data = { ...envelope.data, command_runtime: { ...envelope.data.command_runtime, ...patch } }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ...envelope, data }), { status: 200, headers: { "Content-Type": "application/json" }, })); await expect(new APIClient("read-secret").runCapabilityReadiness(data.run_id)) @@ -879,6 +918,8 @@ describe("APIClient", () => { }], ["granted adapter without identity", (data: ReturnType) => { data.command_runtime.current_run_granted = true; + delete (data.command_runtime as Partial).adapter_kind; + delete (data.command_runtime as Partial).backend; }], ["private extension", (data: ReturnType) => { (data.permissions[0] as Record).root_path = "C:\\private"; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e2931f7a..d845a1ae 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3290,7 +3290,7 @@ function parseRunCapabilityReadiness(value: unknown, "INVALID_RESPONSE", 502); } const groups: Array<[unknown, readonly string[], boolean]> = [ - [value.permissions, ["conservative", "workspace_access", "approval", "full_access", "debug"], true], + [value.permissions, ["ask", "auto", "full"], true], [value.profiles, ["preview", "docker", "local"], true], [value.interactions, ["preview", "controlled", "debug", "cyber"], true], [value.browser_cdp_permissions, ["restricted", "full_debug"], true], @@ -6416,6 +6416,8 @@ export class APIClient { readonly hasBatchDeliveryHostValidation: boolean; readonly hasExtensionControl: boolean; readonly hasUIEvidence: boolean; + readonly uiEvidenceUnavailableReason: "missing_control_credential" | "ui_evidence_disabled" | + "run_execution_disabled" | "browser_cdp_control_disabled" | null; constructor( private readonly token: string, @@ -6497,6 +6499,10 @@ export class APIClient { (capabilities.extensionControlEnabled ?? true); this.hasUIEvidence = controlPresent && (capabilities.uiEvidenceControlEnabled ?? false) && this.hasRunExecution && this.hasBrowserCDPPermissionControl; + this.uiEvidenceUnavailableReason = !controlPresent ? "missing_control_credential" + : !(capabilities.uiEvidenceControlEnabled ?? false) ? "ui_evidence_disabled" + : !this.hasRunExecution ? "run_execution_disabled" + : !this.hasBrowserCDPPermissionControl ? "browser_cdp_control_disabled" : null; } async health(signal?: AbortSignal): Promise { diff --git a/web/src/components/run-projections.test.tsx b/web/src/components/run-projections.test.tsx index 2cad3a1b..e0b3acad 100644 --- a/web/src/components/run-projections.test.tsx +++ b/web/src/components/run-projections.test.tsx @@ -10,6 +10,36 @@ function provider(children: React.ReactNode) { } describe("Run projection panels", () => { + it.each([false, true])("keeps batch host validation separate from batch control when enabled=%s", async (enabled) => { + const client = { hasBatchDeliveryControl: true, hasBatchDeliveryHostValidation: enabled, + getRunBatchDeliveries: vi.fn().mockResolvedValue({ items: [] }), + } as unknown as APIClient; + const { container } = render(provider()); + await screen.findByText("No batch-delivery.v1 plans"); + expect(container.textContent).toContain("selected Run"); + expect(container.textContent).toContain("current Full process activation"); + if (enabled) { + expect(container.textContent).toContain("is not an OS sandbox"); + } else { + expect(container.textContent).toContain("--enable-batch-validation-execution"); + expect(container.textContent).toContain("--enable-permission-control"); + expect(container.textContent).toContain("--enable-danger-full-access"); + expect(container.textContent).toContain("control credential"); + expect(container.textContent).toContain("operator approval"); + } + expect(screen.queryByRole("button", { name: "Merge in DAG order" })).not.toBeInTheDocument(); + }); + + it("explains the independent Desktop batch control switch while preserving read access", async () => { + const getRunBatchDeliveries = vi.fn().mockResolvedValue({ items: [] }); + const client = { hasBatchDeliveryControl: false, hasBatchDeliveryHostValidation: false, + getRunBatchDeliveries } as unknown as APIClient; + render(provider()); + expect(await screen.findByText(/--enable-batch-delivery-control/)).toBeInTheDocument(); + expect(getRunBatchDeliveries).toHaveBeenCalledWith("run-1", expect.any(AbortSignal)); + expect(screen.queryByRole("button", { name: "Accept" })).not.toBeInTheDocument(); + }); + it("renders bounded external Skill metadata without a mutation control", () => { const projection: ExternalSkillProjectionView = { protocol_version: "external_skill_projection.v1", diff --git a/web/src/components/run-projections.tsx b/web/src/components/run-projections.tsx index 3738a453..e84e2b76 100644 --- a/web/src/components/run-projections.tsx +++ b/web/src/components/run-projections.tsx @@ -421,10 +421,14 @@ export function BatchDeliveriesPanel({ client, runID }: ProjectionProps) { {t(`${formatNumber(query.data.items.length)} 个计划`, `${formatNumber(query.data.items.length)} plans`)}

{client.hasBatchDeliveryHostValidation - ? t("宿主 Go/npm 验证已由操作者以 full-access 显式启用;它不是 OS 沙箱。", - "Host Go/npm validation was explicitly enabled with full access; it is not an OS sandbox.") - : t("仅允许不执行仓库代码的 Git diff 检查;Go/npm 验证失败关闭。", - "Only non-executing Git diff checks are available; Go/npm validation fails closed.")}

+ ? t("宿主 Go/npm 验证的启动能力已启用;实际检查仍要求所选 Run 正在运行、当前 Full 进程激活及后端权限检查。此验证会在宿主执行仓库代码,不是 OS 沙箱。", + "Host Go/npm validation startup capability is enabled. Each check still requires the selected Run to be running, current Full process activation, and backend authorization. It executes repository code on the host and is not an OS sandbox.") + : t("宿主 Go/npm 验证未满足独立启用条件;当前仅允许不执行仓库代码的 Git diff 检查。操作者须显式配置 --enable-batch-validation-execution、--enable-permission-control 与 --enable-danger-full-access,并具备批量交付控制、控制凭证和操作员审批能力。检查开始时所选 Run 仍须正在运行且具有当前 Full 进程激活。", + "Host Go/npm validation requires independent enablement; only non-executing Git diff checks are available. The operator must explicitly configure --enable-batch-validation-execution, --enable-permission-control, and --enable-danger-full-access, with batch delivery control, a control credential, and operator approval capability. At each check start, the selected Run must still be running with current Full process activation.")}

+ {!client.hasBatchDeliveryControl &&

{t( + "批量交付验收、返工、合并与恢复控制不可用。Desktop 须独立启用 --enable-batch-delivery-control 并连接控制凭证;Go/npm 验证还需上述单独配置。", + "Batch acceptance, rework, merge, and recovery controls are unavailable. Desktop requires the independent --enable-batch-delivery-control flag and a control credential; Go/npm validation additionally requires the separate configuration above.", + )}

} {query.data.items.length === 0 ? {t("暂无 batch-delivery.v1 计划", "No batch-delivery.v1 plans")} :
diff --git a/web/src/components/ui-evidence-panel.test.tsx b/web/src/components/ui-evidence-panel.test.tsx index 8ad8389a..5f850fe7 100644 --- a/web/src/components/ui-evidence-panel.test.tsx +++ b/web/src/components/ui-evidence-panel.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { APIClient } from "../api/client"; +import { APIRequestError, type APIClient } from "../api/client"; import type { UIEvidenceAttempt } from "../api/types"; import { UIEvidencePanel } from "./ui-evidence-panel"; @@ -68,7 +68,7 @@ describe("UIEvidencePanel", () => { it("keeps not_run neutral and reserves the success treatment for passed", async () => { const notRun = attempt("not_run", "attempt-not-run"); const passed = attempt("passed", "attempt-passed"); - const client = { hasUIEvidence: false, + const client = { hasUIEvidence: false, uiEvidenceUnavailableReason: "ui_evidence_disabled", uiEvidence: vi.fn().mockResolvedValue([notRun, passed]), uiEvidenceBundle: vi.fn().mockResolvedValue({ attempt: notRun, steps: [], artifacts: [] }), } as unknown as APIClient; @@ -83,12 +83,57 @@ describe("UIEvidencePanel", () => { } expect(screen.getByText("通过")).toHaveClass("status-passed"); expect(screen.getByText(/页面内容与下载产物均不可信/)).toBeInTheDocument(); + expect(client.uiEvidence).toHaveBeenCalledWith("run-1", expect.any(AbortSignal)); + expect(client.uiEvidenceBundle).toHaveBeenCalledWith("attempt-not-run", expect.any(AbortSignal)); + expect(screen.queryByText(/历史状态未知/)).not.toBeInTheDocument(); + }); + + it("explains an unavailable backend history endpoint without treating 404 as an empty history", async () => { + const uiEvidence = vi.fn().mockRejectedValue(new APIRequestError( + "HTTP API endpoint was not found", "NOT_FOUND", 404)); + const client = { hasUIEvidence: false, uiEvidenceUnavailableReason: "ui_evidence_disabled", + uiEvidence } as unknown as APIClient; + const user = userEvent.setup(); + renderPanel(client); + expect(await screen.findByText(/当前后端无法提供所选执行的 UI 取证读取/)).toHaveTextContent("历史状态未知"); + expect(screen.getByText(/当前后端无法提供/)).toHaveTextContent("Windows Desktop"); + expect(screen.queryByText("HTTP API endpoint was not found")).not.toBeInTheDocument(); + expect(screen.queryByText("尚未创建 UI 验证 Attempt")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "刷新 UI 证据" })); + await waitFor(() => expect(uiEvidence).toHaveBeenCalledTimes(2)); + expect(uiEvidence).toHaveBeenCalledWith("run-1", expect.any(AbortSignal)); + }); + + it.each([ + [false, new APIRequestError("UI evidence service failed", "INTERNAL", 500)], + [false, new APIRequestError("UI evidence authentication failed", "UNAUTHORIZED", 401)], + [false, new APIRequestError("Unexpected error code", "OTHER_ERROR", 404)], + [true, new APIRequestError("Enabled endpoint missing", "NOT_FOUND", 404)], + ])("keeps other history errors visible with hasUIEvidence=%s", async (enabled, error) => { + renderPanel({ hasUIEvidence: enabled, uiEvidenceUnavailableReason: enabled ? null : "ui_evidence_disabled", + uiEvidence: vi.fn().mockRejectedValue(error) } as unknown as APIClient); + expect(await screen.findByRole("alert")).toHaveTextContent(error.message); + expect(screen.queryByText(/历史状态未知/)).not.toBeInTheDocument(); + expect(screen.queryByText("尚未创建 UI 验证 Attempt")).not.toBeInTheDocument(); + }); + + it("does not keep declaring empty history when a later refresh cannot read the endpoint", async () => { + const uiEvidence = vi.fn().mockResolvedValueOnce([]).mockRejectedValueOnce(new APIRequestError( + "HTTP API endpoint was not found", "NOT_FOUND", 404)); + const client = { hasUIEvidence: false, uiEvidenceUnavailableReason: "ui_evidence_disabled", + uiEvidence } as unknown as APIClient; + const user = userEvent.setup(); + renderPanel(client); + await screen.findByText("尚未创建 UI 验证 Attempt"); + await user.click(screen.getByRole("button", { name: "刷新 UI 证据" })); + await screen.findByText(/历史状态未知/); + expect(screen.queryByText("尚未创建 UI 验证 Attempt")).not.toBeInTheDocument(); }); it("requires exact-manifest review before starting", async () => { const created = attempt("not_run", "attempt-created"); const startUIEvidence = vi.fn().mockResolvedValue(created); - const client = { hasUIEvidence: true, startUIEvidence, + const client = { hasUIEvidence: true, uiEvidenceUnavailableReason: null, startUIEvidence, uiEvidence: vi.fn().mockResolvedValue([]), uiEvidenceBundle: vi.fn().mockResolvedValue({ attempt: created, steps: [], artifacts: [] }), } as unknown as APIClient; @@ -111,4 +156,29 @@ describe("UIEvidencePanel", () => { fail_on_request_error: true, fail_on_http_status: true }, })); }); + + it.each([ + ["missing_control_credential", "当前连接缺少控制凭证"], + ["ui_evidence_disabled", "当前进程未启用 UI 取证能力"], + ["run_execution_disabled", "当前进程未启用 Run 执行能力"], + ["browser_cdp_control_disabled", "当前进程未启用浏览器 CDP 控制能力"], + [undefined, "当前连接未满足 UI 取证的独立控制条件"], + ])("explains unavailable UI evidence accurately for %s and keeps launch disabled", async (reason, message) => { + const startUIEvidence = vi.fn(); + const client = { hasUIEvidence: false, uiEvidenceUnavailableReason: reason, + uiEvidence: vi.fn().mockResolvedValue([]), startUIEvidence, + } as unknown as APIClient; + const user = userEvent.setup(); + const { container } = renderPanel(client); + expect(screen.getByText(new RegExp(message, "u"))).toBeInTheDocument(); + expect(screen.getByText(/现有执行入口是 Windows Desktop/)).toBeInTheDocument(); + expect(container.textContent).toContain("--enable-ui-evidence"); + expect(container.textContent).toContain("--enable-run-execution"); + expect(container.textContent).toContain("--enable-browser-cdp-control"); + expect(container.textContent).not.toContain("当前连接为只读"); + await user.click(screen.getByText("审阅并启动精确清单")); + expect(screen.getByRole("button", { name: "载入本仓库模板" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "启动真实浏览器验证" })).toBeDisabled(); + expect(startUIEvidence).not.toHaveBeenCalled(); + }); }); diff --git a/web/src/components/ui-evidence-panel.tsx b/web/src/components/ui-evidence-panel.tsx index 09283504..9e0cd0b8 100644 --- a/web/src/components/ui-evidence-panel.tsx +++ b/web/src/components/ui-evidence-panel.tsx @@ -10,7 +10,7 @@ import { RefreshCw, ShieldAlert, } from "lucide-react"; -import type { APIClient } from "../api/client"; +import { APIRequestError, type APIClient } from "../api/client"; import type { UIEvidenceArtifactMetadata, UIEvidenceAttempt, @@ -149,7 +149,7 @@ export function UIEvidencePanel({ client, runID }: { const submit = (event: FormEvent) => { event.preventDefault(); setLocalError(""); - if (!reviewed || requestJSON.trim() === "") return; + if (!client.hasUIEvidence || start.isPending || !reviewed || requestJSON.trim() === "") return; try { JSON.parse(requestJSON); } catch (caught) { @@ -177,6 +177,9 @@ export function UIEvidencePanel({ client, runID }: { const current = bundle.data?.attempt; const mutationError = start.error || cancel.error; + const historyUnavailable = !client.hasUIEvidence && attempts.isError && + attempts.error instanceof APIRequestError && attempts.error.status === 404 && + attempts.error.code === "NOT_FOUND"; return
+ {!client.hasUIEvidence &&
+

{client.uiEvidenceUnavailableReason === "missing_control_credential" + ? t("当前连接缺少控制凭证;历史证据读取不要求控制凭证,启动和取消不可用。", + "This connection has no control credential. Reading historical evidence does not require one; start and cancel are unavailable.") + : client.uiEvidenceUnavailableReason === "ui_evidence_disabled" + ? t("当前进程未启用 UI 取证能力;需要独立的 --enable-ui-evidence 启动配置。", + "UI evidence is disabled in this process; it requires the independent --enable-ui-evidence startup flag.") + : client.uiEvidenceUnavailableReason === "run_execution_disabled" + ? t("当前进程未启用 Run 执行能力;UI 取证还需要 --enable-run-execution。", + "Run execution is disabled in this process; UI evidence also requires --enable-run-execution.") + : client.uiEvidenceUnavailableReason === "browser_cdp_control_disabled" + ? t("当前进程未启用浏览器 CDP 控制能力;UI 取证还需要 --enable-browser-cdp-control。", + "Browser CDP control is disabled in this process; UI evidence also requires --enable-browser-cdp-control.") + : t("当前连接未满足 UI 取证的独立控制条件,启动和取消不可用。", + "The independent UI evidence control requirements are not met; start and cancel are unavailable.")}

+

{t("现有执行入口是 Windows Desktop。连接须具备控制凭证,操作者须在桌面启动参数中显式配置以下独立开关:", + "Execution is available through Windows Desktop. The connection needs a control credential, and the operator must explicitly configure these independent Desktop startup flags:")} + {" "}--enable-permission-control --enable-danger-full-access --enable-run-execution --enable-browser-cdp-control --enable-ui-evidence + {t("。独立 CLI 仅支持读取和导出历史证据,不能启动或取消浏览器。", + ". The standalone CLI only reads and exports historical evidence; it cannot start or cancel a browser.")} +

+
} {attempts.isLoading && } - {attempts.isError && } - {attempts.data?.length === 0 && {t("尚未创建 UI 验证 Attempt", "No UI evidence attempt has been created")}} + {attempts.isError && (historyUnavailable + ?

{t( + "当前后端无法提供所选执行的 UI 取证读取。请核对执行记录,或在支持 UI 取证的 Windows Desktop 中读取。历史状态未知,不能据此判断没有记录。", + "This backend cannot provide UI evidence reads for the selected Run. Check the Run, or read it through a Windows Desktop that supports UI evidence. Historical state is unknown; this does not establish that no records exist.", + )}

+ : )} + {attempts.isSuccess && attempts.data.length === 0 && {t("尚未创建 UI 验证 Attempt", "No UI evidence attempt has been created")}} {attempts.data && attempts.data.length > 0 &&
@@ -225,6 +255,8 @@ export function UIEvidencePanel({ client, runID }: { "模板面向本仓库的 Vite UI。提交前必须逐字段核对 Workspace 相对命令、loopback 端口、fixture、交互步骤、遮罩与失败策略。原始输入仅用于当前请求,不会写入证据清单。", "The template targets this repository's Vite UI. Before submission, review every Workspace-relative command, loopback port, fixture, interaction, mask, and failure rule. Raw typed input is used only for the current request and is not persisted in the evidence manifest.", )}

+

{t("所选 Run 还必须是 Code / Local / Deliver,具有当前 Full 进程激活、有效根执行租约及 restricted 浏览器 CDP 权限。启动开关本身不会授予这些条件。", + "The selected Run must also be Code / Local / Deliver with current Full process activation, an active root execution lease, and restricted browser CDP permission. Startup flags do not grant these conditions.")}

- {!client.hasUIEvidence &&

{t( - "当前连接为只读;启动和取消需要 UI evidence、Run execution、命令运行时与受限 CDP 控制能力。", - "This connection is read-only. Start and cancel require UI evidence, Run execution, command-runtime, and restricted-CDP control capabilities.", - )}

} {(localError || mutationError) &&
{localError || humanError(mutationError)}
} diff --git a/web/src/test/capability-readiness.ts b/web/src/test/capability-readiness.ts index 1a8b47fa..9a8e1dd1 100644 --- a/web/src/test/capability-readiness.ts +++ b/web/src/test/capability-readiness.ts @@ -23,15 +23,9 @@ export function capabilityReadinessFixture( run_id: runID, capability_grant: false, permissions: [ - option("conservative", true), - option("workspace_access", false, false, false, { - blocked_by: ["startup_gate_closed", "sandbox_unproven"], - remediation: ["restart_with_startup_gate", "verify_sandbox"], - restart_required: true, - }), - option("approval", false), - option("full_access", false), - option("debug", false, false, false, { + option("ask", true), + option("auto", false), + option("full", false, false, false, { blocked_by: ["startup_gate_closed"], remediation: ["restart_with_startup_gate"], restart_required: true, }), diff --git a/web/src/test/fixtures/readiness-paused-plan-current.json b/web/src/test/fixtures/readiness-paused-plan-current.json new file mode 100644 index 00000000..fe3979f6 --- /dev/null +++ b/web/src/test/fixtures/readiness-paused-plan-current.json @@ -0,0 +1,200 @@ +{ + "data": { + "browser_cdp_permissions": [ + { + "blocked_by": [ + "startup_gate_closed" + ], + "remediation": [ + "restart_with_startup_gate" + ], + "restart_required": true, + "runtime_available": false, + "selectable": false, + "selected": true, + "value": "restricted" + }, + { + "blocked_by": [ + "startup_gate_closed", + "permission_mismatch" + ], + "remediation": [ + "restart_with_startup_gate", + "select_required_permission" + ], + "restart_required": true, + "runtime_available": false, + "selectable": false, + "selected": false, + "value": "full_debug" + } + ], + "capability_grant": false, + "command_runtime": { + "adapter_installed": true, + "adapter_kind": "sandboxed_workspace", + "adapter_ready": true, + "backend": "local_windows_lpac", + "current_run_granted": false, + "protocol_available": true + }, + "interactions": [ + { + "blocked_by": [], + "remediation": [], + "restart_required": false, + "runtime_available": true, + "selectable": true, + "selected": true, + "value": "preview" + }, + { + "blocked_by": [ + "workspace_untrusted" + ], + "remediation": [ + "trust_workspace" + ], + "restart_required": false, + "runtime_available": false, + "selectable": true, + "selected": false, + "value": "controlled" + }, + { + "blocked_by": [ + "startup_gate_closed", + "permission_mismatch", + "workspace_untrusted" + ], + "remediation": [ + "restart_with_startup_gate", + "select_required_permission", + "trust_workspace" + ], + "restart_required": true, + "runtime_available": false, + "selectable": true, + "selected": false, + "value": "debug" + }, + { + "blocked_by": [ + "startup_gate_closed", + "surface_mismatch", + "profile_mismatch", + "workspace_untrusted", + "docker_unavailable" + ], + "remediation": [ + "restart_with_startup_gate", + "select_required_surface", + "select_required_profile", + "trust_workspace", + "install_or_start_docker" + ], + "restart_required": true, + "runtime_available": false, + "selectable": false, + "selected": false, + "value": "cyber" + } + ], + "permissions": [ + { + "blocked_by": [], + "remediation": [], + "restart_required": false, + "runtime_available": true, + "selectable": true, + "selected": true, + "value": "ask" + }, + { + "blocked_by": [], + "remediation": [], + "restart_required": false, + "runtime_available": true, + "selectable": true, + "selected": false, + "value": "auto" + }, + { + "blocked_by": [ + "startup_gate_closed" + ], + "remediation": [ + "restart_with_startup_gate" + ], + "restart_required": true, + "runtime_available": false, + "selectable": false, + "selected": false, + "value": "full" + } + ], + "presets": [ + { + "blocked_by": [ + "capability_not_implemented", + "workspace_untrusted" + ], + "remediation": [ + "upgrade_application", + "trust_workspace" + ], + "restart_required": false, + "runtime_available": false, + "selectable": false, + "selected": false, + "value": "standard_code" + } + ], + "profiles": [ + { + "blocked_by": [], + "remediation": [], + "restart_required": false, + "runtime_available": true, + "selectable": true, + "selected": false, + "value": "preview" + }, + { + "blocked_by": [ + "startup_gate_closed", + "workspace_untrusted", + "docker_unavailable" + ], + "remediation": [ + "restart_with_startup_gate", + "trust_workspace", + "install_or_start_docker" + ], + "restart_required": true, + "runtime_available": false, + "selectable": true, + "selected": false, + "value": "docker" + }, + { + "blocked_by": [ + "workspace_untrusted" + ], + "remediation": [ + "trust_workspace" + ], + "restart_required": false, + "runtime_available": false, + "selectable": true, + "selected": true, + "value": "local" + } + ], + "protocol_version": "run_capability_readiness.v1", + "run_id": "run-readiness-current" + }, + "request_id": "req-readiness-current", + "version": "api.v1" +} diff --git a/web/src/v2/components/browser-cdp-control.test.tsx b/web/src/v2/components/browser-cdp-control.test.tsx index e8279962..cb951b20 100644 --- a/web/src/v2/components/browser-cdp-control.test.tsx +++ b/web/src/v2/components/browser-cdp-control.test.tsx @@ -34,7 +34,7 @@ function permission(mode: RunBrowserCDPPermissionView["mode"]): RunBrowserCDPPer } function renderControl({ initial = permission("restricted"), runID = "run-1", - mode = "full_access", executionRuntimeAvailable = true }: { + mode = "full", executionRuntimeAvailable = true }: { initial?: RunBrowserCDPPermissionView; runID?: string; mode?: ThreadExecutionPermissionView["mode"] | null; @@ -116,12 +116,12 @@ describe("V2BrowserCDPControl", () => { }); it("cannot be enabled below Full Access", async () => { - renderControl({ mode: "approval" }); + renderControl({ mode: "ask" }); const toggle = await screen.findByRole("switch", { name: "完整 CDP 控制" }); expect(toggle).toBeDisabled(); expect(toggle).toHaveAttribute("aria-checked", "false"); - expect(screen.getByText(/高风险 CDP 仅完全访问或调试模式可开启/u)).toBeInTheDocument(); + expect(screen.getByText(/高风险 CDP 需要在当前任务确认并激活完全访问/u)).toBeInTheDocument(); expect(screen.getByText(/受限导航、DOM 与截图不受影响/u)).toBeInTheDocument(); }); @@ -152,7 +152,7 @@ describe("V2BrowserCDPControl", () => { const user = userEvent.setup(); const unavailable = renderControl({ executionRuntimeAvailable: false }); const disabledToggle = await screen.findByRole("switch", { name: "完整 CDP 控制" }); - await screen.findByText("先重新确认并激活当前任务的完全访问或调试权限。"); + await screen.findByText("先重新确认并激活当前任务的完全访问权限。"); expect(disabledToggle).toBeDisabled(); expect(unavailable.postControl).not.toHaveBeenCalled(); diff --git a/web/src/v2/components/browser-cdp-control.tsx b/web/src/v2/components/browser-cdp-control.tsx index cbd92a9e..9954a98c 100644 --- a/web/src/v2/components/browser-cdp-control.tsx +++ b/web/src/v2/components/browser-cdp-control.tsx @@ -37,7 +37,7 @@ function statusCopy({ runID, mode, permission, loading, failed, controlEnabled, }): string { if (!runID) return mode === null ? "先打开一个任务。" : "当前任务还没有可配置的执行。"; if (!allowsFullCDP(mode)) { - return "高风险 CDP 仅完全访问或调试模式可开启;受限导航、DOM 与截图不受影响。"; + return "高风险 CDP 需要在当前任务确认并激活完全访问;受限导航、DOM 与截图不受影响。"; } if (!controlEnabled || !fullCDPEnabled) return "当前运行时未提供完整 CDP 控制。"; if (loading) return "正在读取当前执行的浏览器权限…"; @@ -50,7 +50,7 @@ function statusCopy({ runID, mode, permission, loading, failed, controlEnabled, : "已选择,但当前运行时尚未授权。"; } if (!executionRuntimeAvailable) { - return "先重新确认并激活当前任务的完全访问或调试权限。"; + return "先重新确认并激活当前任务的完全访问权限。"; } return "当前关闭;内置隔离浏览器仍可导航、读取 DOM 和截图,重新开启需确认高风险控制。"; } diff --git a/web/src/v2/components/execution-settings.test.tsx b/web/src/v2/components/execution-settings.test.tsx index 8a15bae7..15e9acc6 100644 --- a/web/src/v2/components/execution-settings.test.tsx +++ b/web/src/v2/components/execution-settings.test.tsx @@ -15,7 +15,7 @@ function runDetail(id = "run-1"): RunDetailView { run: { id, status: "paused", standard_code_preset_configured: false }, mission: { id: "mission-1", workspace_id: "workspace-1" }, mode: { surface: "code", phase: "deliver" }, - execution_permission: { mode: "approval" }, + execution_permission: { mode: "ask" }, execution_profile: { profile: "preview", backend: "noop", risk_tier: "minimal", approval_policy: "none", required_gate: "none", revision: 1 }, execution_interaction: { mode: "preview", workspace_trust: "untrusted", command_form: "none", @@ -65,7 +65,7 @@ describe("V2ExecutionSettings", () => { const controls = setup(); render(controls.ui("thread-1")); expect(await screen.findByText("项目:指定验收目录")).toBeInTheDocument(); - expect(screen.getByText(/逐次审批命令在宿主机执行/u)).toBeInTheDocument(); + expect(screen.getByText(/请求批准、帮我批准、完全访问是审批偏好/u)).toBeInTheDocument(); expect(controls.postControl).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: /本地工作区/u })); await waitFor(() => expect(controls.postControl).toHaveBeenCalledWith( diff --git a/web/src/v2/components/execution-settings.tsx b/web/src/v2/components/execution-settings.tsx index d90ce6ad..712dcccf 100644 --- a/web/src/v2/components/execution-settings.tsx +++ b/web/src/v2/components/execution-settings.tsx @@ -47,9 +47,9 @@ export function V2ExecutionSettings({ client, threadID, workspaces }: { 项目:{project?.name ?? "当前对话的项目"}

{detail.data.run.standard_code_preset_configured ? "当前任务已配置隔离工作区,文件与沙箱命令使用该工作区;这里切换环境不会把改动应用回原项目。" - : "普通任务的文件工具使用已接入的项目目录。本地执行配合逐次审批,可创建工程和运行检查;每条宿主命令仍需在对话中批准,并显示实际工作目录。"}

-

先选择执行环境,再确认信任项目并启用 Code。运行中不可更改时,返回对话停止后再设置;选择工作区访问不会自动准备隔离工作区。

- {detail.data.execution_permission.mode === "approval" &&

逐次审批命令在宿主机执行,可以访问主机网络。批准前请核对命令、目录和用途。

} + : "普通任务的文件工具使用已接入的项目目录。本地执行可用于创建工程和运行检查;命令按当前审批偏好和运行环境处理,并显示实际工作目录。"}

+

先选择执行环境,再确认信任项目并启用 Code。运行中不可更改时,返回对话停止后再设置。请求批准、帮我批准、完全访问是审批偏好,切换它们不会自动准备隔离工作区或启用调试运行时。

+

需要批准时,请核对具体操作、目录和用途。网络范围与浏览器能力由各自的设置和后端检查决定。

diff --git a/web/src/v2/components/high-risk-activation-dialog.test.tsx b/web/src/v2/components/high-risk-activation-dialog.test.tsx index 9a9d1f70..7cff799d 100644 --- a/web/src/v2/components/high-risk-activation-dialog.test.tsx +++ b/web/src/v2/components/high-risk-activation-dialog.test.tsx @@ -53,10 +53,10 @@ describe("V2HighRiskActivationDialog", () => { const { container } = render(); - expect(screen.getByText("完全访问的全部能力")).toBeInTheDocument(); + expect(screen.getByText("独立的任务权限")).toBeInTheDocument(); expect(screen.getByText("持久终端和后台进程")).toBeInTheDocument(); expect(screen.getByText("终端输入与调试控制")).toBeInTheDocument(); - expect(screen.getByText(/完整 CDP 默认开启,可在权限页关闭/u)).toBeInTheDocument(); + expect(screen.getByText(/完全访问仍需逐个任务明确确认/u)).toBeInTheDocument(); expect(screen.getByText(/初始化持久终端、后台进程和限时终端输入运行时/u)) .toBeInTheDocument(); expect(screen.getByText(/重启不会改写任何任务的已保存权限/u)).toBeInTheDocument(); diff --git a/web/src/v2/components/high-risk-activation-dialog.tsx b/web/src/v2/components/high-risk-activation-dialog.tsx index 613d6f18..8a7d3659 100644 --- a/web/src/v2/components/high-risk-activation-dialog.tsx +++ b/web/src/v2/components/high-risk-activation-dialog.tsx @@ -57,11 +57,11 @@ const profileCopy: Record = { }, debug: { title: "要开启调试模式吗?", - introduction: "调试模式继承完全访问的全部能力,并允许 Universal Code 维持终端和后台进程。仅应在你信任当前工作区及其内容时使用。包括:", + introduction: "调试运行时为持久终端和后台进程提供支持。使用这些能力还需在当前任务选择本地执行、Debug 交互,并确认完全访问权限。仅应在你信任当前工作区及其内容时使用。包括:", capabilities: [ { - title: "完全访问的全部能力", - description: "访问任意文件和网络、运行未沙箱化命令;完整 CDP 默认开启,可在权限页关闭", + title: "独立的任务权限", + description: "完全访问仍需逐个任务明确确认;网络范围、浏览器与其他集成保留各自的检查", icon: ShieldOff, }, { @@ -76,8 +76,8 @@ const profileCopy: Record = { }, ], risk: "终端和后台进程可能在界面操作结束后继续运行,凭证、环境变量和调试数据也可能暴露。", - boundary: "确认后,系统会再次显示原生警告并重启同一个应用,以初始化持久终端、后台进程和限时终端输入运行时。重启不会改写任何任务的已保存权限;任务仍需逐个选择调试模式。", - details: "这次重启只用于建立长生命周期调试运行时,不是管理员提权。完全访问无需重启,在当前执行暂停并静止后即可开启;降低权限会立即阻断新调用。", + boundary: "确认后,系统会再次显示原生警告并重启同一个应用,以初始化持久终端、后台进程和限时终端输入运行时。重启不会改写任何任务的已保存权限,也不会恢复完全访问的进程激活;请在当前任务重新确认完全访问,并选择本地执行与 Debug 交互。", + details: "这次重启只用于建立长生命周期调试运行时,不是管理员提权。完全访问无需重启,在当前执行暂停并静止后即可开启;降低权限会立即阻断新调用。限时终端输入仍需单独授权,批量验证和 UI 取证仍取决于各自的能力配置。", confirmLabel: "启用并重启", }, }; diff --git a/web/src/v2/components/run-network-authority-control.test.tsx b/web/src/v2/components/run-network-authority-control.test.tsx index 6694d2d4..c1073115 100644 --- a/web/src/v2/components/run-network-authority-control.test.tsx +++ b/web/src/v2/components/run-network-authority-control.test.tsx @@ -2,20 +2,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../../api/client"; -import type { ProviderSearchReadinessView } from "../../api/types"; +import type { ProviderSearchReadinessView, RunDetailView } from "../../api/types"; import { V2RunNetworkAuthorityControl } from "./run-network-authority-control"; function renderControl(status: "created" | "paused" | "running" = "paused", variant: "menu" | "settings" = "settings", readiness: Partial = {}, - permissionMode: "conservative" | "workspace_access" | "approval" | "full_access" | "debug" = - "conservative", onOpenModelSettings = vi.fn()) { + permissionMode: RunDetailView["execution_permission"]["mode"] = "ask", + onOpenModelSettings = vi.fn(), + networkScope = { network_mode: "allowlist", allowed_targets: ["search.example.org"] }) { const mode = { protocol_version: "run_mode.v1", policy_version: "mode_policy.v1", revision: 3, capability_grant: false, phase: "deliver", profile: "code", surface: "code", reason: "test", requested_by: "test", created_at: "2026-08-31T00:00:00Z", - scope: { network_mode: "allowlist", allowed_targets: ["search.example.org"] }, + scope: networkScope, }; const get = vi.fn().mockResolvedValue({ run: { status }, mode, execution_permission: { mode: permissionMode } }); @@ -70,9 +71,28 @@ describe("V2RunNetworkAuthorityControl", () => { expect(within(popover).getByText(/只追加明确的公网 HTTPS 主机/u)).toBeInTheDocument(); }); - it("shows safe public HTTPS instead of an empty exact allowlist in Full Access", async () => { + it.each(["ask", "auto", "full", "full_access", "debug"] as const)( + "keeps the exact URL scope visible for %s without inferring a broader grant", async (permissionMode) => { const user = userEvent.setup(); - renderControl("running", "menu", {}, "full_access"); + const controls = renderControl("paused", "menu", {}, permissionMode); + + const trigger = screen.getByRole("button", { name: "网页访问状态" }); + await waitFor(() => expect(trigger).toHaveTextContent("搜索配置就绪")); + await user.click(trigger); + + const popover = screen.getByRole("dialog", { name: "当前执行网页访问" }); + expect(within(popover).getByText("search.example.org")).toBeInTheDocument(); + expect(within(popover).getByText("1 个主机")).toBeInTheDocument(); + expect(within(popover).queryByText("公网 HTTPS")).not.toBeInTheDocument(); + expect(within(popover).getByRole("textbox", { name: "追加允许的 HTTPS 主机" })).toBeEnabled(); + expect(controls.expandRunNetworkAuthority).not.toHaveBeenCalled(); + }); + + it("reads a saved broad network scope without treating it as a permission preference", async () => { + const user = userEvent.setup(); + renderControl("running", "menu", {}, "ask", vi.fn(), { + network_mode: "allowlist", allowed_targets: ["public_https"], + }); const trigger = screen.getByRole("button", { name: "网页访问状态" }); await waitFor(() => expect(trigger).toHaveTextContent("搜索配置就绪")); @@ -80,11 +100,22 @@ describe("V2RunNetworkAuthorityControl", () => { const popover = screen.getByRole("dialog", { name: "当前执行网页访问" }); expect(within(popover).getByText("公网 HTTPS")).toBeInTheDocument(); - expect(within(popover).getByText(/允许匿名访问任意公网 HTTPS/u)).toBeInTheDocument(); + expect(within(popover).getByText(/当前执行保存的网络范围允许匿名访问公网 HTTPS/u)).toBeInTheDocument(); expect(within(popover).queryByRole("textbox", { name: "追加允许的 HTTPS 主机" })) .not.toBeInTheDocument(); }); + it("does not claim broad access or a disconnected provider when the URL scope is disabled", async () => { + const controls = renderControl("paused", "settings", {}, "full", vi.fn(), { + network_mode: "disabled", allowed_targets: [], + }); + expect(await screen.findByText("未预先授权主机")).toBeInTheDocument(); + expect(screen.getByText("供应商搜索 · 搜索配置就绪")).toBeInTheDocument(); + expect(screen.queryByText("公网 HTTPS")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "追加允许的 HTTPS 主机" })).toBeEnabled(); + expect(controls.expandRunNetworkAuthority).not.toHaveBeenCalled(); + }); + it("explains and prefills the exact missing Provider target", async () => { const user = userEvent.setup(); renderControl("paused", "menu", { @@ -105,7 +136,7 @@ describe("V2RunNetworkAuthorityControl", () => { const user = userEvent.setup(); const controls = renderControl("running", "menu", { search_policy: "web", required_target: "html.duckduckgo.com", - }, "full_access"); + }, "full"); const trigger = screen.getByRole("button", { name: "网页访问状态" }); await waitFor(() => expect(trigger).toHaveTextContent("搜索配置就绪")); await user.click(trigger); diff --git a/web/src/v2/components/run-network-authority-control.tsx b/web/src/v2/components/run-network-authority-control.tsx index 4b094683..b5257aa4 100644 --- a/web/src/v2/components/run-network-authority-control.tsx +++ b/web/src/v2/components/run-network-authority-control.tsx @@ -120,9 +120,11 @@ export function V2RunNetworkAuthorityControl({ client, threadID = "", runID, // pre-flight "待验证" state after a successful probe or a failed request. refetchInterval: 5_000, }); - const current = query.data?.mode.scope.allowed_targets ?? []; - const permissionMode = query.data?.execution_permission.mode; - const publicHTTPS = permissionMode === "full_access" || permissionMode === "debug"; + const networkScope = query.data?.mode.scope; + const current = networkScope?.network_mode === "allowlist" ? networkScope.allowed_targets ?? [] : []; + // Historical broad scopes are read as stored facts. An approval preference, + // including activated Full, does not itself grant this fetch scope. + const publicHTTPS = current.includes("public_https"); const rawRequested = useMemo(() => parseExactNetworkTargets(draft), [draft]); const invalid = rawRequested.filter((target) => !exactNetworkTargetLooksValid(target)); const requested = useMemo(() => invalid.length === 0 @@ -173,7 +175,7 @@ export function V2RunNetworkAuthorityControl({ client, threadID = "", runID, : query.isLoading ? "正在读取当前执行的网页访问范围…" : query.isError ? "无法读取网页访问范围。" : publicHTTPS - ? "当前权限允许匿名访问任意公网 HTTPS;私网、loopback、元数据地址、DNS 重绑定和非 HTTPS 请求仍会被拒绝。" + ? "当前执行保存的网络范围允许匿名访问公网 HTTPS;私网、loopback、元数据地址、DNS 重绑定和非 HTTPS 请求仍会被拒绝。" : mutable ? "只追加明确的公网 HTTPS 主机;现有授权不可在这里静默扩大或删除。" : `当前执行为 ${query.data?.run.status ?? "unknown"};需在 created/paused 静止边界追加。`; const readiness = readinessQuery.data; @@ -183,8 +185,8 @@ export function V2RunNetworkAuthorityControl({ client, threadID = "", runID, const body = <>
直接 URL 抓取

{status}

- {publicHTTPS ? "公网 HTTPS" - : current.length === 0 ? "无网络" : `${current.length} 个主机`} + {query.isError ? "范围未知" : query.isPending ? "读取中" + : publicHTTPS ? "公网 HTTPS" : current.length === 0 ? "未预先授权主机" : `${current.length} 个主机`}
{threadID &&
{readiness?.search_policy === "provider_native" ? "供应商搜索" : "网页搜索"} · {readinessQuery.isError @@ -195,6 +197,7 @@ export function V2RunNetworkAuthorityControl({ client, threadID = "", runID, {!readinessQuery.isError && {remediationLabel(readiness)}}
} {readiness && } + {query.isSuccess &&

这里显示当前执行预先授权的 URL 抓取范围。范围外的请求仍需通过后端的单独授权检查;审批偏好与供应商搜索不会自动扩大此范围。

} {!publicHTTPS && current.length > 0 &&
{current.map((target) => {target})}
} @@ -233,8 +236,8 @@ export function V2RunNetworkAuthorityControl({ client, threadID = "", runID, ref={menuTriggerRef} type="button"> {publicHTTPS || current.length > 0 ?
+
+ 正在加载交付工具…

}> + {showApprovals ? <> + + + : <> + {selected === "batch" && } + {selected === "git-advanced" && { setGitReview(review); onGitReviewChange(review); }} + onOpenApprovals={() => setShowApprovals(true)} />} + {selected === "github-review" && { setGithubReview(review); onGithubReviewChange(review); }} + onOpenApprovals={() => setShowApprovals(true)} onOpenDelivery={onOpenDelivery} />} + {selected === "ui-evidence" && } + {!selected &&

选择工具后才会读取对应状态。切换执行记录后,请重新选择工具并审阅。

} + } +
+
+ ; +} diff --git a/web/src/v2/components/task-review.css b/web/src/v2/components/task-review.css index c4b9e3fd..5e492dcb 100644 --- a/web/src/v2/components/task-review.css +++ b/web/src/v2/components/task-review.css @@ -35,12 +35,21 @@ .v2-review-body .file-review-drawer footer > div { flex-wrap: wrap; min-width: 0; max-width: 100%; } .v2-review-body .file-review-drawer footer button { display: inline-flex; align-items: center; justify-content: center; width: auto; max-width: 100%; height: auto; min-height: 34px; padding: 5px 7px; gap: 5px; white-space: normal; } .v2-review-project-diff { margin-top: 20px; border-top: 1px solid var(--v2-border); } +.v2-review-tool-picker { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.v2-review-tool-picker > button { display: grid; gap: 7px; text-align: left; padding: 12px; border: 1px solid var(--v2-border); border-radius: 8px; background: var(--v2-control); color: inherit; cursor: pointer; min-width: 0; } +.v2-review-tool-picker > button > span { display: flex; gap: 8px; align-items: center; } +.v2-review-tool-picker small { color: var(--v2-muted); line-height: 1.6; } +.v2-review-tool-picker > button[aria-pressed="true"] { border-color: var(--v2-accent); } +.v2-review-tool-picker > button:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; } +.v2-review-tool-content { margin-top: 20px; min-width: 0; } +.v2-review-tools code { overflow-wrap: anywhere; } .v2-review-drawer > footer { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--v2-border); font-size: 11px; } @media (max-width: 760px) { .v2-review-body { padding: 12px; } .v2-review-main-nav { gap: 10px; } .v2-review-main-nav > div { width: 100%; } .v2-review-main-nav > div > button { flex: 1; padding-inline: 7px; } + .v2-review-tool-picker { grid-template-columns: minmax(0, 1fr); } .v2-review-body .file-review-workspace.is-reviewing, .v2-review-body .checkpoint-layout, .v2-review-body .delivery-truth-columns { grid-template-columns: minmax(0, 1fr); } diff --git a/web/src/v2/components/task-review.test.tsx b/web/src/v2/components/task-review.test.tsx index ab2c3a8a..d8cd0208 100644 --- a/web/src/v2/components/task-review.test.tsx +++ b/web/src/v2/components/task-review.test.tsx @@ -38,7 +38,7 @@ it("reviews each task execution and carries exact edit context into a correction ); - expect(within(screen.getByRole("group", { name: "交付流程" })).getAllByRole("button")).toHaveLength(3); + expect(within(screen.getByRole("group", { name: "交付流程" })).getAllByRole("button")).toHaveLength(4); expect(screen.queryByRole("button", { name: "编辑明细" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "查看改动" })).toHaveAttribute("aria-pressed", "true"); await openHistory(user, "编辑明细"); diff --git a/web/src/v2/components/task-review.tsx b/web/src/v2/components/task-review.tsx index 210cb161..0896ff1f 100644 --- a/web/src/v2/components/task-review.tsx +++ b/web/src/v2/components/task-review.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, ChevronDown, FileDiff, GitCommitHorizontal, GitPullRequest, X } from "lucide-react"; +import { ArrowLeft, ChevronDown, FileDiff, GitCommitHorizontal, GitPullRequest, Wrench, X } from "lucide-react"; import type { APIClient } from "../../api/client"; import type { RunDetailView, StandardCodeDeliveryRecordRequestView, StandardCodeDeliveryView, SupervisorToolRoundView, ThreadDetailView } from "../../api/types"; import { FileEditPanel, type FileEditReviewTarget } from "../../components/file-edit-panel"; @@ -19,15 +19,17 @@ import { v2QueryKeys } from "../query-keys"; import { TaskOverview } from "./task-overview"; import { TaskGit } from "./task-git"; import { TaskPullRequest } from "./task-pull-request"; +import { TaskReviewTools, type TaskReviewToolReviews } from "./task-review-tools"; import type { WorkspaceView } from "../../api/types"; import "./task-review.css"; -type ReviewTab = "overview" | "git" | "pr" | "files" | "checks" | "records" | "restore" | "evidence"; +type ReviewTab = "overview" | "git" | "pr" | "tools" | "files" | "checks" | "records" | "restore" | "evidence"; const historyTabs: [ReviewTab, string][] = [["files", "编辑明细"], ["checks", "检查与交付"], ["records", "执行记录"], ["evidence", "参考资料"], ["restore", "撤销与恢复"]]; const primaryTabs = [{ value: "overview", label: "查看改动", icon: FileDiff }, { value: "git", label: "提交与推送", icon: GitCommitHorizontal }, - { value: "pr", label: "PR 状态", icon: GitPullRequest }] as const; + { value: "pr", label: "PR 状态", icon: GitPullRequest }, + { value: "tools", label: "更多交付工具", icon: Wrench }] as const; const executionStatusLabels: Record = { created: "尚未开始", preparing: "准备中", running: "未结束", paused: "已暂停", waiting_approval: "等待批准", completed: "已完成", failed: "执行失败", cancelled: "已停止" }; @@ -52,7 +54,15 @@ export function V2TaskReview({ client, detail, working, onClose, onRequestChange }) { const queryClient = useQueryClient(); const currentRun = detail.active_run ?? detail.last_run; - const [selectedRunID, setSelectedRunID] = useState(initialFileTarget?.runID ?? currentRun.id); + const toolReviews = useRef(new Map()); + const [runSelection, setRunSelection] = useState({ threadID: detail.thread.id, + runID: initialFileTarget?.runID ?? currentRun.id }); + const selectedRunID = runSelection.threadID === detail.thread.id + ? runSelection.runID : initialFileTarget?.runID ?? currentRun.id; + const setSelectedRunID = (runID: string) => { + if (runID !== selectedRunID || runSelection.threadID !== detail.thread.id) toolReviews.current.clear(); + setRunSelection({ threadID: detail.thread.id, runID }); + }; const [fileTarget, setFileTarget] = useState(initialFileTarget); const [tab, setTab] = useState(initialFileTarget ? "files" : "overview"); const [showHistory, setShowHistory] = useState(Boolean(initialFileTarget)); @@ -73,8 +83,13 @@ export function V2TaskReview({ client, detail, working, onClose, onRequestChange { isolateBackground: true, returnFocusRef }); const keys = useRef(new Map()); const selectedRun = detail.runs.find(({ run }) => run.id === selectedRunID)?.run - ?? (fileTarget && selectedRunID !== currentRun.id ? undefined : currentRun); + ?? (selectedRunID === currentRun.id ? currentRun : undefined); const reviewedRunID = selectedRun?.id ?? selectedRunID; + const toolReviewKey = `${detail.thread.id}:${reviewedRunID}`; + const retainToolReview = (kind: T, value: TaskReviewToolReviews[T]) => { + toolReviews.current.set(toolReviewKey, { git: null, github: null, + ...toolReviews.current.get(toolReviewKey), [kind]: value }); + }; const reviewFile = (target: FileEditReviewTarget) => { setSelectedRunID(target.runID); setFileTarget(target); setFilePath(null); setTab("files"); setShowHistory(true); @@ -185,7 +200,7 @@ export function V2TaskReview({ client, detail, working, onClose, onRequestChange
{!["overview", "git", "pr"].includes(tab) &&
-

{historyTabs.find(([value]) => value === tab)?.[1]}

} +

{tab === "tools" ? "更多交付工具" : historyTabs.find(([value]) => value === tab)?.[1]}

} {tab === "overview" && selectTab("git", true)} onPullRequest={() => selectTab("pr", true)} @@ -193,7 +208,13 @@ export function V2TaskReview({ client, detail, working, onClose, onRequestChange currentRunDetail={currentPlanQuery.data} />} {tab === "git" && selectTab("pr", true)} onOpenWorktree={onOpenWorktree} />} {tab === "pr" && selectTab("git", true)} />} - {!["overview", "git", "pr"].includes(tab) && !selectedRun &&

无法找到目标执行记录 {selectedRunID},尚未打开文件提案。请刷新任务后重试,或明确选择另一次执行。

} + {!["overview", "git", "pr"].includes(tab) && !selectedRun &&

无法找到目标执行记录 {selectedRunID},尚未打开审阅工具。请刷新任务后重试,或明确选择另一次执行。

} + {tab === "tools" && selectedRun && retainToolReview("git", review)} + onGithubReviewChange={(review) => retainToolReview("github", review)} + onOpenDelivery={() => selectTab("checks", true)} />} {tab === "files" && selectedRun && <>

{selectedRun.id === currentRun.id ? "下面是当前执行的文件提案与编辑记录。" : "下面是所选历史执行的文件提案与编辑记录。"}当前执行目录与历史原目录分别标明;尚未应用的提案需批准后应用。历史记录不代表来源项目的当前内容。

refresh()} From bdccceef41dfa534f15d70a72365bed96cdc39f7 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:23:03 +0800 Subject: [PATCH 2/3] test(web): avoid declaring a protocol for the readiness fixture --- internal/httpapi/capability_readiness_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/httpapi/capability_readiness_test.go b/internal/httpapi/capability_readiness_test.go index d419f6d8..b7d98c15 100644 --- a/internal/httpapi/capability_readiness_test.go +++ b/internal/httpapi/capability_readiness_test.go @@ -85,7 +85,7 @@ func TestRunCapabilityReadinessHTTPCurrentFixtureMatchesGoProjection(t *testing. OperatorApprovalEnabled: true, WorkspaceSandboxEnabled: true, } adapters := []commandruntimeadapter.Identity{commandruntimeadapter.SandboxedWorkspace( - application.CommandRuntimeLocalSandboxBackend, "readiness-fixture-local.v1", strings.Repeat("a", 64))} + application.CommandRuntimeLocalSandboxBackend, "readiness-fixture-local", strings.Repeat("a", 64))} runtime := application.CapabilityReadinessRuntime{ RunControlEnabled: true, ExecutionPermissionControlEnabled: true, ExecutionPermissionCapabilities: capabilities, From 65de06592d96aaa51e10ba9e0bea46009deac2ea Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:53:21 +0800 Subject: [PATCH 3/3] test(web): use complete GitHub review contracts in task tools --- .../v2/components/task-review-tools.test.tsx | 106 +++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/web/src/v2/components/task-review-tools.test.tsx b/web/src/v2/components/task-review-tools.test.tsx index d273ad2a..cea0a4aa 100644 --- a/web/src/v2/components/task-review-tools.test.tsx +++ b/web/src/v2/components/task-review-tools.test.tsx @@ -3,13 +3,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../../api/client"; -import type { ThreadDetailView } from "../../api/types"; +import type { GitHubReviewConnectionView, GitHubReviewCredentialView, GitHubReviewProjectionView, + GitHubReviewWriteReviewResultView, ThreadDetailView } from "../../api/types"; +import { standardCodeDeliveryFixture } from "../../test/standard-code-delivery"; import { V2TaskReview } from "./task-review"; vi.mock("../../lib/locale", () => ({ useLocale: () => ({ t: (chinese: string) => chinese }) })); const sha = "a".repeat(64); const oid = "b".repeat(40); +const timestamp = "2026-10-07T00:00:00Z"; function detail(): ThreadDetailView { const current = { id: "run-current", status: "running" }; return { thread: { id: "thread-1", title: "审阅工具", workspace_id: "source-current" }, @@ -26,6 +29,86 @@ function gitProjection(runID: string) { status_sha256: sha, stash_sha256: sha, sequence_sha256: sha }, conflict: { active: false, files: [] }, stashes: [], worktrees: [], operations: [] }; } + +function githubConnection(): GitHubReviewConnectionView { + return { protocol_version: "github-review-connection.v1", id: "connection-1", + repository: { host: "github.com", owner: "fixture", name: "project", + full_name: "fixture/project", private: false }, + credential: { name: "fixture-reference", kind: "github_app_device" }, + client_id: "Iv1.fixture", network: { host: "github.com", api_host: "api.github.com", + oauth_host: "github.com", allowed_log_hosts: [], read_enabled: true, write_enabled: true }, + enabled: true, generation: 1, created_at: timestamp, updated_at: timestamp }; +} + +function githubCredential(): GitHubReviewCredentialView { + const connection = githubConnection(); + return { protocol_version: "github-review-api.v1", connection, + credential: { protocol_version: "github-review-provider.v1", credential: connection.credential, + configured: true, refreshable: true, store_available: true, store_kind: "test" } }; +} + +function githubSnapshot(): GitHubReviewProjectionView["snapshots"][number] { + const connection = githubConnection(); + const text = (value: string) => ({ text: value, original_bytes: value.length, stored_bytes: value.length, + sha256: sha, redacted: false, truncated: false, untrusted: true }); + return { protocol_version: "github-review-snapshot.v1", id: "snapshot-exact", + identity: { repository: connection.repository, number: 1, node_id: "PR_fixture_1", + state: "open", merged: false, draft: false, fork: false, base_ref: "main", base_sha: "c".repeat(40), + head_ref: "codex/fixture", head_sha: oid, merge_base_sha: "c".repeat(40), updated_at: timestamp }, + capability: { protocol_version: "github-review-capability.v1", generation: sha, + api_host: "api.github.com", api_version: "2026-03-10", account_login: "fixture-operator", + installation_id: 1, repository: connection.repository, credential: connection.credential, + permissions: { pull_requests: "write" }, read: true, reply: true, resolve: true, + review: true, request_reviewer: true, push: false, logs: false, captured_at: timestamp }, + title: text("Fixture PR"), body: text(""), author: "fixture-operator", requested_reviewers: [], + files: [], reviews: [], threads: [], loose_comments: [], check_suites: [], check_runs: [], + jobs: [], artifacts: [], pagination: [], state: "verified", omissions: [], fingerprint: sha, + rate_limit: { limit: 5000, remaining: 4999, used: 1, reset_at: timestamp }, fetched_at: timestamp }; +} + +function githubProjection(runID: string, connectionID: string, pullRequest = 0): GitHubReviewProjectionView { + if (connectionID !== githubConnection().id) throw new Error(`Unknown fixture connection ${connectionID}`); + const credential = githubCredential(); + const snapshot = githubSnapshot(); + const delivery = standardCodeDeliveryFixture(); + return { protocol_version: "github-review-api.v1", run_id: runID, + connection: credential.connection, credential: credential.credential, + // Zero browses the saved snapshots; a selected PR must match its identity. + snapshots: pullRequest === 0 || pullRequest === snapshot.identity.number ? [snapshot] : [], + evidence: [], writes: [], standard_code_delivery: { ...delivery, + binding: { ...delivery.binding, run_id: runID, session_id: `${runID}-session`, + source_workspace_id: `${runID}-workspace` }, + diff: { ...delivery.diff, changed_count: 0, files: [] }, verifications: [] } }; +} + +function githubReviewedWrite(runID: string, + body: Parameters[1]): GitHubReviewWriteReviewResultView { + const preview: GitHubReviewWriteReviewResultView["preview"] = { + protocol_version: body.spec.protocol_version, id: "github-preview-exact", operation: body.spec.operation, + identity: body.spec.identity, credential: body.spec.credential, + capability_generation: body.spec.capability_generation, reviewers: body.spec.reviewers, + body_summary: body.spec.body, body_sha256: sha, review_event: body.spec.review_event, + target_id: body.spec.target_id, validation_summary: body.spec.validation_summary, + local_change_summary: body.spec.local_change_summary, approval_fingerprint: sha, + idempotency_marker: `fixture:${body.operation_key}`, created_at: timestamp, + }; + return { protocol_version: "github-review-api.v1", preview, replayed: false, + operation: { protocol_version: "github-review-api.v1", id: "github-write-exact", run_id: runID, + session_id: `${runID}-session`, workspace_id: `${runID}-workspace`, connection_id: body.connection_id, + operation_key_sha256: sha, request_fingerprint: sha, approval_fingerprint: preview.approval_fingerprint, + approval_id: "github-approval-exact", status: "awaiting_approval", preview, created_at: timestamp, + receipt: { protocol_version: "github-review-write.v1", id: "github-receipt-exact", + preview_id: preview.id, identity: preview.identity, operation: preview.operation, + idempotency_marker: preview.idempotency_marker, status: "pending", recovered: false, + started_at: timestamp, completed_at: timestamp } }, + approval: { ID: "github-approval-exact", RunID: runID, SessionID: `${runID}-session`, + WorkspaceID: `${runID}-workspace`, ActionClass: "github_write", ToolName: "github-review", + Status: "pending", Mode: "explicit", Version: 1, ProposalID: "github-write-exact", + GrantID: "", IdempotencyKey: body.operation_key, RequestFingerprint: preview.approval_fingerprint, + RequestedBy: "fixture-operator", ReviewedBy: "", DecisionReason: "", + CreatedAt: timestamp, UpdatedAt: timestamp, DecidedAt: timestamp } }; +} + function client() { return { get: vi.fn().mockRejectedValue(new Error("Overview is outside this fixture")), @@ -34,16 +117,13 @@ function client() { hasUIEvidence: false, uiEvidenceUnavailableReason: "ui_evidence_disabled", getRunBatchDeliveries: vi.fn().mockResolvedValue({ items: [] }), gitAdvancedProjection: vi.fn((runID: string) => Promise.resolve(gitProjection(runID))), - githubReviewConnections: vi.fn().mockResolvedValue([{ connection: { id: "connection-1", - repository: { full_name: "fixture/project" } }, credential: { configured: true } }]), - githubReviewProjection: vi.fn((runID: string) => Promise.resolve({ run_id: runID, - connection: { network: { write_enabled: true }, credential: { name: "fixture-reference" } }, - snapshots: [{ id: "snapshot-exact", identity: { number: 1, head_sha: oid }, - capability: { generation: sha }, title: { text: "Fixture PR" }, - jobs: [], files: [], threads: [], check_runs: [], fetched_at: "2026-10-07T00:00:00Z" }], - evidence: [], writes: [], standard_code_delivery: { receipt_sha256: sha, - diff: { changed_count: 0 }, verifications: [], status: "passed" }, - })), + githubReviewConnections: vi.fn().mockResolvedValue([githubCredential()]), + githubReviewCredential: vi.fn((connectionID: string) => { + if (connectionID !== githubConnection().id) return Promise.reject(new Error(`Unknown fixture connection ${connectionID}`)); + return Promise.resolve(githubCredential()); + }), + githubReviewProjection: vi.fn((runID: string, connectionID: string, pullRequest = 0) => + Promise.resolve(githubProjection(runID, connectionID, pullRequest))), uiEvidence: vi.fn().mockResolvedValue([]), approvalQueue: vi.fn().mockResolvedValue({ items: [] }), controlledCommandProposals: vi.fn().mockResolvedValue({ items: [] }), @@ -55,8 +135,8 @@ function client() { operation: { id: "operation-exact" }, approval: { ID: "approval-exact" }, })), executeGitAdvanced: vi.fn().mockResolvedValue({ receipt: { status: "succeeded" } }), - reviewGitHubWrite: vi.fn().mockResolvedValue({ preview: { approval_fingerprint: sha }, - operation: { id: "github-write-exact" }, approval: { ID: "github-approval-exact" } }), + reviewGitHubWrite: vi.fn((runID: string, body: Parameters[1]) => + Promise.resolve(githubReviewedWrite(runID, body))), executeGitHubWrite: vi.fn().mockResolvedValue({ receipt: { status: "succeeded" } }), runCapabilityReadiness: vi.fn().mockRejectedValue(new Error("Readiness is outside this fixture")), standardCodeDelivery: vi.fn().mockRejectedValue(new Error("Delivery is outside this fixture")),