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.",
+ )}
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
@@ -192,10 +195,37 @@ export function UIEvidencePanel({ client, runID }: {
{t("页面内容与下载产物均不可信;它们不能启动进程、访问凭证或自动判定验证通过。只有精确的 passed 状态显示为通过,not_run 始终保持中性。",
"Page content and downloads are untrusted. They cannot start processes, access credentials, or grant a verification pass. Only the exact passed state is successful; not_run remains neutral.")}
+ {!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.",
- )}