diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84800f65..a83e507c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,3 +232,234 @@ jobs: - name: Build and verify portable Desktop shell shell: pwsh run: ./scripts/build-desktop.ps1 -SkipFrontend -VerifyReproducible + + ui-evidence-windows: + name: Real Edge UI evidence (standard user, Windows 2022) + runs-on: windows-2022 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Set up current Go 1.25 patch + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.x' + check-latest: true + cache: true + - name: Verify real Edge UI evidence matrix and regression detection + shell: pwsh + run: | + $artifactDirectory = Join-Path $env:RUNNER_TEMP "ui-evidence-smoke" + New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null + if ($env:GITHUB_RUN_ID -notmatch '^\d+$' -or $env:GITHUB_RUN_ATTEMPT -notmatch '^\d+$') { + throw "GitHub run identity is unavailable" + } + # Hosted Windows RUNNER_TEMP is reparse-backed, and runneradmin's + # LOCALAPPDATA is not traversable by the disposable standard user. + # Use one exact child of the direct system volume root, then grant + # that child (never the root) only to the temporary SID below. + if ([string]::IsNullOrWhiteSpace($env:SystemRoot) -or + -not [System.IO.Path]::IsPathFullyQualified($env:SystemRoot)) { + throw "Windows system root is unavailable" + } + $directTempParent = [System.IO.Path]::GetPathRoot( + [System.IO.Path]::GetFullPath($env:SystemRoot)) + $directTempParentItem = Get-Item -LiteralPath $directTempParent -Force + if (-not $directTempParentItem.PSIsContainer -or + ($directTempParentItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Windows system volume root is indirect" + } + $directTempName = "cyberagent-ui-evidence-$($env:GITHUB_RUN_ID)-$($env:GITHUB_RUN_ATTEMPT)" + $directTempRoot = Join-Path $directTempParent $directTempName + $resolvedTempParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $directTempRoot)) + if (-not [string]::Equals( + $resolvedTempParent.TrimEnd('\'), + $directTempParent.TrimEnd('\'), + [System.StringComparison]::OrdinalIgnoreCase)) { + throw "UI evidence direct temp root escaped the system volume root" + } + if (Test-Path -LiteralPath $directTempRoot) { + throw "UI evidence direct temp root already exists" + } + $directTempItem = New-Item -ItemType Directory -Path $directTempRoot + if (($directTempItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "UI evidence direct temp root is indirect" + } + $directArtifactDirectory = Join-Path $directTempRoot "artifacts" + $testBinary = Join-Path $directTempRoot "browserruntime.test.exe" + $userHome = Join-Path $directTempRoot "home" + $userLocalAppData = Join-Path $userHome "AppData\Local" + $userRoamingAppData = Join-Path $userHome "AppData\Roaming" + foreach ($directory in @($directArtifactDirectory, $userLocalAppData, $userRoamingAppData)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $runSuffix = $env:GITHUB_RUN_ID.Substring( + [Math]::Max(0, $env:GITHUB_RUN_ID.Length - 8)) + $standardUserName = "cyberui$runSuffix$($env:GITHUB_RUN_ATTEMPT)" + if ($standardUserName -notmatch '^cyberui\d{1,12}$' -or + $standardUserName.Length -gt 20) { + throw "derived UI evidence standard-user name is invalid" + } + if (Get-LocalUser -Name $standardUserName -ErrorAction SilentlyContinue) { + throw "UI evidence standard user already exists" + } + $standardUserCreated = $false + $workspaceGrantAdded = $false + $standardUserSID = $null + $process = $null + $securePassword = $null + $cleanupFailure = $null + try { + go test -c -o $testBinary ./internal/browserruntime + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $testBinary)) { + throw "compile real Edge UI evidence test binary failed" + } + + $plainPassword = "Aa1!$([guid]::NewGuid().ToString('N'))zZ9!" + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + New-LocalUser -Name $standardUserName -Password $securePassword ` + -AccountNeverExpires -PasswordNeverExpires -UserMayNotChangePassword | Out-Null + $standardUserCreated = $true + $usersGroup = Get-LocalGroup -SID "S-1-5-32-545" + Add-LocalGroupMember -Group $usersGroup -Member $standardUserName + $standardUserSID = (Get-LocalUser -Name $standardUserName).SID.Value + if ($standardUserSID -notmatch '^S-1-5-21-(\d+-){3}\d+$') { + throw "UI evidence standard-user SID is invalid" + } + + & icacls.exe $env:GITHUB_WORKSPACE /grant:r ` + ("*{0}:(OI)(CI)RX" -f $standardUserSID) /Q | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "grant standard-user workspace read access failed" + } + $workspaceGrantAdded = $true + & icacls.exe $directTempRoot /grant:r ` + ("*{0}:(OI)(CI)M" -f $standardUserSID) /Q | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "grant standard-user temporary-root access failed" + } + + $start = [System.Diagnostics.ProcessStartInfo]::new() + $start.FileName = $testBinary + $start.WorkingDirectory = $env:GITHUB_WORKSPACE + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Domain = $env:COMPUTERNAME + $start.UserName = $standardUserName + $start.Password = $securePassword + # Edge requires the disposable account's HKCU hive during browser + # initialization. The exact profile is removed by SID below. + $start.LoadUserProfile = $true + $start.ArgumentList.Add("-test.v") + $start.ArgumentList.Add("-test.timeout=3m") + $start.ArgumentList.Add( + "-test.run=^TestInstalledEdgeUIEvidenceHeadlessMatrixAndRegression$") + $start.Environment.Clear() + $processEnvironment = [ordered]@{ + APPDATA = $userRoamingAppData + CYBERAGENT_UI_EVIDENCE_ARTIFACT_DIR = $directArtifactDirectory + CYBERAGENT_UI_EVIDENCE_SMOKE = "1" + GITHUB_ACTIONS = "true" + GIT_CONFIG_COUNT = "1" + GIT_CONFIG_GLOBAL = "NUL" + GIT_CONFIG_KEY_0 = "safe.directory" + GIT_CONFIG_NOSYSTEM = "1" + GIT_CONFIG_VALUE_0 = $env:GITHUB_WORKSPACE + GIT_OPTIONAL_LOCKS = "0" + GIT_TERMINAL_PROMPT = "0" + HOME = $userHome + LOCALAPPDATA = $userLocalAppData + PATH = "C:\Program Files\Git\cmd;$env:SystemRoot\System32;$env:SystemRoot" + PATHEXT = ".COM;.EXE;.BAT;.CMD" + SystemRoot = $env:SystemRoot + TEMP = $directTempRoot + TMP = $directTempRoot + USERPROFILE = $userHome + WINDIR = $env:WINDIR + } + foreach ($entry in $processEnvironment.GetEnumerator()) { + $start.Environment[$entry.Key] = $entry.Value + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $start + if (-not $process.Start()) { + throw "start real Edge UI evidence as a standard user failed" + } + $plainPassword = $null + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(240000)) { + $process.Kill($true) + $process.WaitForExit() + throw "real Edge UI evidence standard-user process timed out" + } + $stdout.Result | Write-Host + $stderr.Result | Write-Host + + if (Test-Path -LiteralPath $directArtifactDirectory) { + Get-ChildItem -LiteralPath $directArtifactDirectory -File | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $artifactDirectory -Force + } + } + if ($process.ExitCode -ne 0) { + throw "real Edge UI evidence verification failed with exit code $($process.ExitCode)" + } + if (-not (Test-Path -LiteralPath (Join-Path $artifactDirectory "receipt.json"))) { + throw "real Edge UI evidence receipt was not produced" + } + } + finally { + if ($null -ne $process) { + $process.Dispose() + } + if ($null -ne $securePassword) { + $securePassword.Dispose() + } + if ($workspaceGrantAdded -and $null -ne $standardUserSID) { + & icacls.exe $env:GITHUB_WORKSPACE /remove:g ` + ("*{0}" -f $standardUserSID) /Q | Out-Host + if ($LASTEXITCODE -ne 0) { + $cleanupFailure = "remove standard-user workspace grant failed" + } + } + if ($standardUserCreated) { + $profileDeadline = (Get-Date).AddSeconds(5) + do { + $loadedProfile = Get-CimInstance -ClassName Win32_UserProfile | + Where-Object { $_.SID -eq $standardUserSID } + if ($null -eq $loadedProfile -or -not $loadedProfile.Loaded) { + break + } + Start-Sleep -Milliseconds 250 + } while ((Get-Date) -lt $profileDeadline) + if ($null -ne $loadedProfile) { + if ($loadedProfile.Special -or $loadedProfile.Loaded) { + $cleanupFailure = "UI evidence standard-user profile is not removable" + } + else { + $loadedProfile | Remove-CimInstance + } + } + Remove-LocalUser -Name $standardUserName + } + if (Test-Path -LiteralPath $directTempRoot) { + Remove-Item -LiteralPath $directTempRoot -Recurse -Force + } + if (Test-Path -LiteralPath $directTempRoot) { + throw "UI evidence direct temp root was not cleaned" + } + if ($null -ne $cleanupFailure) { + throw $cleanupFailure + } + } + - name: Upload real Edge UI evidence receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ui-evidence-edge-smoke + path: ${{ runner.temp }}/ui-evidence-smoke + if-no-files-found: warn + retention-days: 5 diff --git a/README.en.md b/README.en.md index d759ebf6..1d7ca2b5 100644 --- a/README.en.md +++ b/README.en.md @@ -93,6 +93,12 @@ A child reaches `ready_for_review` only with a clean worktree, a HEAD descending By default the only executable check is `git diff --check`, which does not run repository code. Because `go_test` and `npm_test` execute child-authored code on the host, the relevant control capability must be enabled and the current Run must still be running with `full_access` (or the explicitly higher `debug` mode); Desktop also requires explicit `--enable-batch-delivery-control`, while host validation additionally requires permission control, danger-full-access, and `--enable-batch-validation-execution`. Validation uses a Windows Job Object or Unix process-group lifecycle boundary, bypasses the Go test cache, and persists only complete-stream output digests. The stripped/offline environment still is not an OS network or filesystem sandbox, and deliberate POSIX daemonization outside the inherited process group remains an explicit host-execution residual. See [Deliverable Multi-Agent Batches](docs/batch-delivery.md) and [ADR 0119](docs/adr/0119-deliverable-batch-agents.md). +### Source-bound real-browser UI evidence + +Schema v119 `ui-evidence.v1` binds real-page verification to the commit/dirty digest/index/worktree manifest, exact build/start recipes, fixed browser version and executable SHA-256, literal loopback URL/route, viewport/DPR, locale/theme/reduced motion, deterministic fixture/seed/page state, steps, and capture policy. Application revalidates source before build, after readiness, after browser assertions, and again after owned-process cleanup before terminal completion. It refuses an occupied port and never adopts an existing service or personal browser Profile. Windows Desktop execution is off by default and appears only when Run execution, `full_access`, danger-full-access, restricted CDP, and `--enable-ui-evidence` all hold. + +Desktop, authenticated OpenAPI, and the read/export-only CLI share immutable Attempt, step, and artifact semantics. PNG, DOM, accessibility, console/page-error, network/HTTP, and performance evidence retain SHA-256, MIME, dimensions, viewport, source step/commit, Run/Attempt, redaction provenance, and the retention policy; PNG dimensions must match `viewport × DPR`. Page content and artifacts remain untrusted and non-authorizing. `not_run` is always neutral; only exact `passed` is success. Windows CI runs real Edge in a creation-time Job Object and temporary Profile across desktop/mobile, theme/locale/reduced-motion cells, and proves a missing click handler is detected only by a real-page interaction assertion. See the [UI Evidence guide](docs/ui-evidence.md) and [ADR 0120](docs/adr/0120-source-bound-real-browser-ui-evidence.md). + ### Real Git, PowerShell, and Bash Prayu invokes real Git and operating-system shells; it is not a command emulator. It deliberately does not give the model a permanent, unreviewed raw terminal. The Code workflow separates execution by risk: @@ -119,7 +125,7 @@ Every `debug_terminal` write still passes Shell Policy; commands that require se - Conservative commands use Go-owned fixed templates. PowerShell/Bash is available only through one of three independent paths: the Code/Deliver/root + `full_access` Run-owned runtime, per-command approval, or a revocable Debug lease. General host execution and Debug authority cannot be enabled by a model, Skill, or repository document. - The Docker Sandbox product entry is disabled by default. An explicit process capability, the current `docker` Profile, a matching permission tier, an exact per-call approval, Policy, budgets, and a 30-second readiness check must all hold at once; database records can never restore start authority after a restart. - Product execution currently accepts only environment-free, secret-free `network=disabled` Manifests and pins `network none` on both the Docker create and inspect sides. Allowlist/scoped egress still lacks a Go-owned host/port/protocol guard, so it always fails closed with `managed_egress_unavailable`; there is no host fallback when Docker is unavailable. -- The built-in browser has no product entry point yet. A restricted runtime core exists, but independent OS/container network-containment evidence is incomplete. +- Windows Desktop exposes loopback-only real-browser evidence only when explicit `--enable-ui-evidence` and its Run-execution/danger-full-access/restricted-CDP prerequisites all hold. macOS and the ordinary CLI remain read-only; Full CDP is still a separate, default-off Debug authority surface. - Windows/macOS Desktop are currently unsigned developer/operator portable previews, not released installers; the macOS artifact is only ad-hoc signed and not notarized. ### Docker Sandbox product entry (disabled by default) @@ -277,7 +283,7 @@ At **2026-08-13 / schema v96 / P13-H1 through P13-H3**, the old task book estima | P6-P8 | Sandbox evidence contracts, Skill Registry, Finding/Evidence/Report, SARIF, and CI projection | | P9 / Desktop D0-D1 | HTTP/OpenAPI, React/TUI/Desktop, repository/diff/editor/verification/Handoff, and liquid-glass workbench | | P10-A through P10-M | Go/Rust Analyzer protocol, vectors, embedded WASI execution, one-shot capability, and product integration | -| P11-A through P11-C | Browser permissions, Profiles, CDP, and WFP evidence; product entry remains closed | +| P11-A through P11-C / schema v119 | Browser permissions, Profiles, CDP/WFP evidence, and the gated source-bound UI-evidence product path | | P12-A through P12-E | Interaction models, controlled Windows Runner, user terminal, four permission tiers, command approval, and host-execution ledger | | P13-A through P13-H | Run Activity, public model stream, continuous chat, Markdown, diff review, Live Activity, and desktop visual consolidation | diff --git a/README.md b/README.md index 1ca59236..1bb3b7d8 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,12 @@ child 只有在 worktree clean、HEAD 是 base 的后代、全部 changed path 默认验证只执行不会运行仓库代码的 `git diff --check`。`go_test`/`npm_test` 会执行 child 提交的代码,因此只有操作者启用相应控制能力,且当前 Run 仍为 `running` 并持有 `full_access`(或显式更高的 `debug`)时才可在宿主运行;Desktop 还需显式 `--enable-batch-delivery-control`,宿主校验另需 permission control、danger-full-access 与 `--enable-batch-validation-execution`。验证进程使用 Windows Job Object / Unix process-group 生命周期边界,Go 测试禁用缓存,持久层只记录完整输出流摘要;其离线/去凭证环境仍只是降险措施,不是 OS 网络或文件系统沙箱,POSIX 主动脱离 inherited process group 也仍是显式宿主权限的残余风险。完整操作与恢复说明见[可交付多代理](docs/batch-delivery.md),设计决策见 [ADR 0119](docs/adr/0119-deliverable-batch-agents.md)。 +### 源码绑定的真实浏览器 UI 证据 + +Schema v119 的 `ui-evidence.v1` 把真实页面验证绑定到 commit/dirty digest/index/worktree manifest、精确 build/start recipe、固定浏览器 version/可执行文件 SHA-256、literal loopback URL/route、viewport/DPR、locale/theme/reduced motion、deterministic fixture/seed/page state、步骤与 capture policy。Application 在 build 前、readiness 后、浏览器断言后以及 owned process cleanup 完成后重新核对源码;拒绝已占用端口,不收养既有服务或个人 Browser Profile。Windows Desktop 的执行入口默认关闭,只有 Run execution、`full_access`、danger-full-access、restricted CDP 与 `--enable-ui-evidence` 同时成立才开放。 + +Desktop、认证 OpenAPI 与只读/导出 CLI 共用同一份不可变 Attempt、step 和 artifact 语义。PNG、DOM、accessibility、console/page error、network/HTTP 与 performance 产物都保存 SHA-256/MIME/尺寸/viewport/source step/commit/Run/Attempt/redaction/retention policy,且 PNG 尺寸必须匹配 `viewport × DPR`;页面和产物始终不可信、不授权。`not_run` 明确保持中性,只有 exact `passed` 才算通过。Windows CI 用 creation-time Job Object、临时 Profile 和 deterministic loopback fixture 跑真实 Edge 的 desktop/mobile、theme/locale/reduced-motion 矩阵,并证明缺失 click handler 的回归只能被真实页面交互断言捕获。详见 [UI Evidence 操作手册](docs/ui-evidence.md)与 [ADR 0120](docs/adr/0120-source-bound-real-browser-ui-evidence.md)。 + ### 真实 Git、PowerShell 与 Bash Prayu 调用真实的 Git 和操作系统 Shell,不是命令模拟器;但它也不会给模型一个永久、无审阅的裸终端。当前 Code 工作流按风险拆成以下入口: @@ -119,7 +125,7 @@ Prayu 调用真实的 Git 和操作系统 Shell,不是命令模拟器;但它 - 受控命令默认使用 Go 固定模板;PowerShell/Bash 只通过 Code/Deliver/root + `full_access` 的 Run-owned runtime、逐条审批,或可撤销 Debug 租约三条独立路径开放。通用宿主执行与 Debug 能力不会因模型、Skill 或仓库文档而自动开启。 - Docker Sandbox 产品入口默认关闭。显式进程 capability、当前 `docker` Profile、匹配权限档、精确 per-call 审批、Policy、预算与 30 秒 readiness 必须同时成立;数据库记录不能在重启后恢复 start authority。 - 当前产品执行只接受 environment-free、secret-free 的 `network=disabled` Manifest,并在 Docker create/inspect 两侧固定 `network none`。allowlist/scoped egress 仍缺少 Go-owned host/port/protocol guard,因此一律以 `managed_egress_unavailable` 失败关闭;Docker 不可用时没有宿主 fallback。 -- 内置浏览器仍没有产品入口:受限运行时核心存在,但独立 OS/容器网络隔离证据尚未完成。 +- Windows Desktop 只在显式 `--enable-ui-evidence` 及其 Run execution/danger-full-access/restricted-CDP 前置条件同时成立时开放 loopback-only 真实浏览器证据;macOS 与普通 CLI 保持只读,Full CDP 仍是独立且默认关闭的 Debug 权限面。 - Windows/macOS Desktop 当前都是未签名的开发者/操作者便携预览,不是正式安装包;macOS 产物只有 ad-hoc 签名且未公证。 ### Docker Sandbox 产品入口(默认关闭) @@ -330,14 +336,14 @@ Get-AuthenticodeSignature .\PrayuDesktop.msix | Format-List Status, StatusMessag | P6-P8 | Sandbox 证据合同、非授权 Docker 生命周期探针、Skill Registry、Finding/Evidence/Report、SARIF 与 CI 投影 | | P9 / Desktop D0-D1 | HTTP/OpenAPI、React/TUI/Desktop、仓库/Diff/编辑/验证/Handoff 与液态玻璃工作台 | | P10-A 至 P10-M | Go/Rust Analyzer 协议、共享向量、内嵌 WASI 执行、一次性能力与产品接入 | -| P11-A 至 P11-C | 浏览器权限、Profile、CDP 与 WFP 证据链;产品入口仍关闭 | +| P11-A 至 P11-C / schema v119 | 浏览器权限、Profile、CDP/WFP 证据链与显式门禁的源码绑定 UI-evidence 产品路径 | | P12-A 至 P12-E | 交互模型、受控 Windows Runner、用户终端、四档权限、固定命令审批与宿主执行账本 | | P13-A 至 P13-H | Run Activity、公开模型流、连续对话、Markdown、Diff 审阅、Live Activity 与桌面视觉收口 | 完整逐切片原始记录保留在 [`PROGRESS_BOOK.md`](docs/PROGRESS_BOOK.md),当前检查点与验收证据保留在 [`PROJECT_STATUS.md`](docs/PROJECT_STATUS.md),恢复上下文见 [`PROJECT_MEMORY.md`](docs/PROJECT_MEMORY.md)。这些账本是历史记录,不应被当作待重新执行的任务列表。
-SQLite Schema v1-v118 迁移审计表 / Migration ledger +SQLite Schema v1-v119 迁移审计表 / Migration ledger 此表是 Store 防漏迁移测试使用的审计合同。新增 schema 时必须按顺序追加,不得改写或删除既有行。 @@ -461,6 +467,7 @@ Get-AuthenticodeSignature .\PrayuDesktop.msix | Format-List Status, StatusMessag | v116 | 增加 Run-owned command-runtime.v2 Job 与 Supervisor 调用账本 | add Run-owned command-runtime.v2 jobs and Supervisor call ledger support | | v117 | 增加事务化 workspace-checkpoint.v1、恢复/Fork 账本与内容寻址 blob | add transactional workspace-checkpoint.v1, restore/Fork ledger, and content-addressed blobs | | v118 | 增加 batch-delivery.v1、child Worktree/邮箱/交付复核与顺序合并队列 | add batch-delivery.v1, child worktrees/mailbox, delivery review, and ordered merge queues | +| v119 | 增加源码绑定的 ui-evidence.v1 Attempt、步骤与内容寻址真实浏览器产物 | add source-bound ui-evidence.v1 attempts, steps, and content-addressed real-browser artifacts |
diff --git a/cmd/cyberagent-desktop/main.go b/cmd/cyberagent-desktop/main.go index 52d26c8f..cdafd1b5 100644 --- a/cmd/cyberagent-desktop/main.go +++ b/cmd/cyberagent-desktop/main.go @@ -14,6 +14,7 @@ import ( "cyberagent-workbench/internal/app" "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/browserruntime" "cyberagent-workbench/internal/desktop" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/httpapi" @@ -64,6 +65,7 @@ type desktopOptions struct { embeddedAnalyzer bool batchDeliveryControl bool batchValidation bool + uiEvidence bool userTerminal bool dockerExecution bool version bool @@ -274,6 +276,8 @@ func parseDesktopOptions(args []string) (desktopOptions, error) { "enable confirmed batch delivery preparation, review, merge, cancellation, and recovery") batchValidation := fs.Bool("enable-batch-validation-execution", false, "enable fixed offline go/npm checks for confirmed batch deliveries") + uiEvidence := fs.Bool("enable-ui-evidence", false, + "enable source-bound real-browser UI evidence for explicitly authorized Runs") userTerminal := fs.Bool("enable-user-terminal", false, "enable the user-owned Debug ConPTY terminal") dockerExecution := fs.Bool("enable-docker-execution", false, @@ -339,6 +343,10 @@ func parseDesktopOptions(args []string) (desktopOptions, error) { return desktopOptions{}, errors.New( "full CDP debug requires --enable-browser-cdp-control and --enable-debug-maximum-access") } + if *uiEvidence && (!*runExecution || !*dangerFullAccess || !*browserCDPControl) { + return desktopOptions{}, errors.New( + "UI evidence requires --enable-run-execution, --enable-danger-full-access, and --enable-browser-cdp-control") + } return desktopOptions{operatorPreview: *operatorPreview, profileControl: *profileControl, runCreation: *runCreation, permissionControl: *permissionControl, dangerFullAccess: *dangerFullAccess, @@ -367,6 +375,7 @@ func parseDesktopOptions(args []string) (desktopOptions, error) { embeddedAnalyzer: *embeddedAnalyzer, batchDeliveryControl: *batchDeliveryControl, batchValidation: *batchValidation, + uiEvidence: *uiEvidence, userTerminal: *userTerminal, dockerExecution: *dockerExecution, version: *version}, nil @@ -397,7 +406,8 @@ func runDesktop(config desktopOptions) error { config.fileEditApply || config.runWakeExecution || config.runWakeWorker || config.skillInstallation || config.evidenceAttachment || config.verificationEvidence || config.embeddedAnalyzer || config.userTerminal || - config.dockerExecution || config.batchDeliveryControl || config.batchValidation { + config.dockerExecution || config.batchDeliveryControl || config.batchValidation || + config.uiEvidence { controlToken, err = httpapi.GenerateAccessToken() if err != nil { return err @@ -443,9 +453,14 @@ func runDesktop(config desktopOptions) error { EmbeddedAnalyzerExecutionEnabled: config.embeddedAnalyzer, BatchDeliveryControlEnabled: config.batchDeliveryControl, BatchDeliveryHostValidationEnabled: config.batchValidation, - UserTerminalEnabled: config.userTerminal, - DockerExecutionEnabled: config.dockerExecution, - AppVersion: app.Version, UIHandler: bundle, + UIEvidenceControlEnabled: config.uiEvidence, + BrowserRuntimeCapabilities: browserruntime.ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, RestrictedCDPEnabled: true, + }, + UserTerminalEnabled: config.userTerminal, + DockerExecutionEnabled: config.dockerExecution, + AppVersion: app.Version, UIHandler: bundle, OnWakeWorkerError: func(runErr error) { fmt.Fprintln(os.Stderr, "wake-worker:", runErr) }, @@ -503,6 +518,7 @@ func runDesktop(config desktopOptions) error { EmbeddedAnalyzerExecutionEnabled: config.embeddedAnalyzer, BatchDeliveryControlEnabled: config.batchDeliveryControl, BatchDeliveryHostValidationEnabled: config.batchValidation, + UIEvidenceControlEnabled: config.uiEvidence, UserTerminalEnabled: config.userTerminal, DockerExecutionEnabled: dockerExecutionEnabled, AppVersion: app.Version, UIDigest: bundle.Digest(), Selector: selector, diff --git a/docs/DESKTOP_PLAN.md b/docs/DESKTOP_PLAN.md index 793bfdd3..f9fb21ae 100644 --- a/docs/DESKTOP_PLAN.md +++ b/docs/DESKTOP_PLAN.md @@ -1,6 +1,8 @@ # Prayu Desktop Plan -状态:Desktop D0-A、D0-B、D1-R1 至 D1-G13/V12 与 D1-UX11 自动化核心已完成,数据库 schema 为 v91。Wails v2.13.0 Windows 壳、嵌入式 React bundle、进程内 Go API、同库恢复、高水位事件续传、WebView2 失败关闭、内存令牌、原生 `.zip` 对话框、路径隔离 Skill、受控 Run/Session/Plan/审批、安全恢复的 Monaco FileEdit、只读 Repository/脱敏 Diff/本地历史/精确提交预览/可导航精确文件历史/精确提交比较与键盘可访问的成对 base/head 预览、多文件独立审阅、不可变操作者验证、snapshot-keyset 逐检查项下钻/快照下载/record-only 回执历史/不授权复核、可恢复 Code Handoff、Code Journey、generation-safe Windows Credential Manager Provider reload、默认关闭的有界 wake worker、用户所有的可选 ConPTY/xterm、四档宿主权限、两档 CDP 权限、独立权限设置页和固定命令提案审批面板已经落地。R10 仍只在内部 `NonProductOnly` 测试边界固定接受信封的 bytes/SHA;P10 Analyzer 与 P11-C5-C7 受限浏览器核心都没有 Desktop 产品进程入口。Windows 10 实机矩阵、Agent-owned Debug 终端、浏览器 OS/容器网络隔离、可操作内置浏览器、完整 CDP、安装包、签名正式发行、注册表、自启动和更新仍未实现。macOS 便携构建(D0-Mac)已落地:同一控制平面在 darwin 构建标签下编译,WKWebView 无需运行时预检,启动失败走有界 osascript 对话框,工作区启动器只通过固定 /usr/bin/open 打开已验证的 .app,脚本产出 ad-hoc 签名的未公证 `Prayu.app` 并复用双构建 SHA-256 校验;签名、公证与人工 macOS 矩阵仍未实现,边界见 ADR 0097。 +状态:Desktop D0-A、D0-B、D1-R1 至 D1-G13/V12 与 D1-UX11 自动化核心已完成,数据库 schema 为 v119。Wails v2.13.0 Windows 壳、嵌入式 React bundle、进程内 Go API、同库恢复、高水位事件续传、WebView2 失败关闭、内存令牌、原生 `.zip` 对话框、路径隔离 Skill、受控 Run/Session/Plan/审批、安全恢复的 Monaco FileEdit、只读 Repository/脱敏 Diff/本地历史/精确提交预览/可导航精确文件历史/精确提交比较与键盘可访问的成对 base/head 预览、多文件独立审阅、不可变操作者验证、snapshot-keyset 逐检查项下钻/快照下载/record-only 回执历史/不授权复核、可恢复 Code Handoff、Code Journey、generation-safe Windows Credential Manager Provider reload、默认关闭的有界 wake worker、用户所有的可选 ConPTY/xterm、四档宿主权限、两档 CDP 权限、独立权限设置页和固定命令提案审批面板已经落地。R10 仍只在内部 `NonProductOnly` 测试边界固定接受信封的 bytes/SHA;P11-C5-C7 的通用浏览器产品入口仍关闭,schema v119 只开放来源绑定、literal-loopback、restricted-CDP 的 UI-evidence exact subset。Windows 10 实机矩阵、Agent-owned Debug 终端、可操作通用内置浏览器、完整 CDP、安装包、签名正式发行、注册表、自启动和更新仍未实现。macOS 便携构建(D0-Mac)已落地:同一控制平面在 darwin 构建标签下编译,WKWebView 无需运行时预检,启动失败走有界 osascript 对话框,工作区启动器只通过固定 /usr/bin/open 打开已验证的 .app,脚本产出 ad-hoc 签名的未公证 `Prayu.app` 并复用双构建 SHA-256 校验;签名、公证与人工 macOS 矩阵仍未实现,边界见 ADR 0097。 + +Issue #102 增加独立“真实浏览器 UI 证据”页签。只读 manifest/step/artifact 历史始终可见;执行必须额外开启 `--enable-ui-evidence`,并同时满足 Run execution、permission control、danger-full-access 与 restricted browser-CDP gates。面板要求操作者审阅完整 JSON,异步启动/取消走同一 Go Application service;只将 `passed` 显示为成功,`not_run` 保持中性。浏览器使用固定 executable/version/hash、attempt-private Profile、Safe Web 网络 guard 与创建时 Job Object,不接管用户浏览器、cookie 或已存在服务;关闭 Desktop 会取消并等待 exact-owned application/browser/Profile/network/port 清理。字段、矩阵与收据见 `docs/ui-evidence.md` 和 ADR 0120。 D1-UX1 至 D1-UX10 已把用户可见名称统一为 Prayu,并落地艺术字、无边框标题栏、透明热区的有界可调工作台/设置侧栏、复用既有 Go 边界的 Agent 输入区、可组合工具面板、独立权限中心,以及 Windows 原生 Acrylic 和浅/深色玻璃令牌。D1-UX10 明确取代早期整页工作台/设置背景和笔刷式选中视觉;选中态现在由 CSS 绘制为高对比圆角白色表面。设置页只调用 Go-owned 运行时与控制服务;内置浏览器仍未启动,用户终端只有显式开关后才可用且默认属于用户。ADR 0114 追加了可撤销的限时 Agent 输入授权,bearer 仍只在 Go 内存中。TypeScript 没有获得凭证、Policy、Docker、任意路径或通用进程权限;模型与权限切换仍走既有 Go mutation。操作员可在原生确认后用固定外部应用打开精确已登记 Workspace,但 renderer 不接收路径、命令、环境或任意参数,该能力不属于 Agent/Runner/Shell。CLI、module、数据目录和协议标识继续兼容既有 CyberAgent 名称。 diff --git a/docs/PROGRESS_BOOK.md b/docs/PROGRESS_BOOK.md index 3d97f9f0..cb5168fd 100644 --- a/docs/PROGRESS_BOOK.md +++ b/docs/PROGRESS_BOOK.md @@ -2,10 +2,49 @@ > 本文件是按时间追加的历史开发账本,不是待办列表。当前产品范围以 [PRODUCT_SCOPE.md](PRODUCT_SCOPE.md) 为准;CTF 专用求解和攻防自动化已移出活跃路线图,本文中的旧 Cyber 百分比仅保留为历史快照。 -更新时间:2026-08-14 +更新时间:2026-08-20 ## 一、当前阶段 +2026-08-20 单切片 `来源绑定真实浏览器 UI 证据(Issue #102)` 推进到 SQLite v119。 +`ui-evidence.v1` 封存 Run/Mission/Session/Workspace、fresh source checkpoint、可选 build/ +必需 start recipe、readiness、固定浏览器 version/executable SHA-256、literal-loopback +URL/route、viewport/DPR、locale/theme/reduced-motion、deterministic fixture/seed/page state、 +有序交互/断言、capture/mask 和全开启 diagnostics failure policy。Application 在 build 前、 +readiness 后、浏览器断言后与 owned process cleanup 后重验来源,拒绝已占用端口,使用 v116 +Run-owned command runtime +和 Safe Web exact-owned browser/Profile/Job/network lifecycle;真实受限 CDP 可导航、点击、 +输入、断言和采集 PNG/DOM/a11y/performance/console/page/network/HTTP,不开放个人 Profile、 +cookie、任意 JS、response body、request mutation/replay 或 Full CDP。状态、失败阶段、step +receipts、artifact hash/MIME/dimensions/source/Run/Attempt/redaction 与清理回执均持久化; +`not_run` 不通过,启动恢复只收敛为 `interrupted` 而不收养 PID/端口/authority。 + +Desktop 默认保留历史只读 UI,显式五项启动 gate 后才能执行;OpenAPI 使用独立 read/control +bearer,CLI 只 list/show/hash-verified exclusive export。`run-verify` 升级为 1.1.0,README、 +usage、HTTP、architecture、ADR 与独立 guide 统一 receipt 语义。Windows CI 新增真实 Edge +headless matrix:1440×900/light/en-US/full-motion 与 390×844/dark/zh-CN/reduced-motion, +采集 screenshots/receipt.json 并用仅缺失 click handler 的 regression route 证明真实页面 +断言能捕获源码/build-only 检查无法单独证明的错误。 + +最终审计进一步把 screenshot 像素面和 dimensions 在 domain/SQLite/React 三层绑定到 +`viewport × DPR`,在完整 PNG 解码前验证 header 尺寸,并由真实 Edge 暴露、修复 Windows +Profile sharing-lock 清理竞态:只对 +exact-owner quarantine 做 5 秒有界重试,超限仍是 cleanup failure。真实 Edge +151.0.4129.93 的两组矩阵、故意回归与全部资源回收通过;最终 source-bound receipt SHA-256 +由 PR/CI 对最终提交报告。CI fixture 固定 native control 状态后,连续真实运行的三张 PNG +逐字节一致。全仓 Go(含 v1→v119 +Store 迁移链)、受影响路径 race/vet/staticcheck、OpenAPI/TypeScript 确定性生成、62 个前端 +文件/266 项测试、production build、npm audit、Rust fmt/test/clippy、CI YAML 与 Windows +Desktop 可复现双构建均通过。本机 Go 1.26.5 的 govulncheck 仍报告 5 项标准库问题,均由 +1.26.6 修复,未把该结果误写为 zero finding。 + +同轮审计新增 cleanup-only 精确 durable Job binding,使 Run lease 在取消、timeout 或撤权后失效时 +仍只能回收本 Attempt 自己启动且由当前 manager 持有的进程树;普通 command Kill 在 lease +释放后继续被拒绝。最终 source checkpoint 移到清理证明之后,diagnostic URL 重新过 exact +TargetScope,越界 scheme 固定为 `[blocked-url]`,React 复核 step/artifact 时间窗。对应定向 +普通与 race 回归以及全量 Go 套件均通过;本轮 Application/HTTP/Store 分别为 611.312/229.102/ +974.372 秒,Desktop 可复现 EXE 的最终 source-bound SHA-256 由 PR/CI 报告。 + 2026-08-14 单切片 `Docker 容器 I/O 合同(新增标签,对应 Issue #39)` 推进到 SQLite v98,仍未接入任何产品入口或执行授权。只读输入投影 `sandbox_docker_input_projection.v1` 把规范化相对路径、SHA-256、大小与媒体类型封印到生命周期 attempt/generation、Plan、Observation、Run/Mission/Workspace 与 Spec 指纹上,输入挂载固定 `/run/cyberagent/inputs` 只读,并新增真实 inspect Mounts 隔离校验(唯一可写挂载必须是专用输出挂载,输入与工作区树必须只读)。日志捕获 `sandbox_docker_log_capture.v1` 只走 `POST containers/{id}/attach?logs=1&stderr=1&stdout=1&stream=0`:解复用器按流限制 256 KiB/4096 行与墙钟时限,拒绝畸形/超大帧,替换非法 UTF-8 并计数,脱敏 Secret,只落库元数据与摘要回执。输出暂存 `sandbox_docker_output_staging.v1` 只导出专用输出挂载的 `GET containers/{id}/archive`,tar 走查在 Windows/Linux 同一路径规则下拒绝绝对/穿越/反斜杠/盘符路径、symlink/hardlink/设备节点、重复项,并限制 64 文件/单文件 4 MiB/总量 16 MiB,文本文件按媒体类型识别与脱敏后写入进程内暂存目录;被拒归档不带任何受信清单。原子提交 `sandbox_docker_output_commit.v1` 要求接受清单与已完成暂存回执逐项精确一致,从暂存目录重读并复哈希后在同一 SQLite 事务中写入回执与全部条目,operation key 幂等,失败不残留半提交行。HTTP 白名单只开放上述两个端点,应用服务不接 CLI/HTTP/Desktop,新增 15 个事件类型记录 prepared/acquired/taken-over/failed/completed。域/黄金向量/对抗路径/传输/Store/应用定向测试在 macOS 通过;Windows 与 Linux CI 将跑同一路径矩阵。边界见 ADR 0098 与 Issue #39;产品准入(Policy/Approval/Budget/Network 与 CLI/HTTP/Desktop 接线)仍是下一个 Docker 切片。 2026-08-14 单切片 `D0-Mac(macOS Desktop 便携构建,新增标签)` 保持 SQLite v97 与全部权限边界不变,为 Desktop 增加 macOS 构建:`cmd/cyberagent-desktop` 按构建标签拆分为共享 `desktop`、Windows `windows && desktop && wv2runtime.error` 与新增 darwin 实现;macOS 使用系统自带 WKWebView 且无运行时预检,启动失败只写 stderr 并显示有界严格转义的 osascript 对话框,工作区启动器只通过固定 `/usr/bin/open` 打开已验证 .app(Finder、Terminal、Antigravity、PyCharm、WebStorm、Visual Studio Code)。Windows 专属的 Credential Manager、ConPTY 用户终端、Safe Web/WFP 与受控宿主执行在 macOS 保持失败关闭。`scripts/build-desktop-darwin.sh` 产出 ad-hoc 签名、未公证的 `build/desktop/Prayu.app`,复用连续双构建 SHA-256 校验并写入 `portable_release_metadata.v1`;`scripts/check-macos-compat.sh` 检查 Mach-O/架构/codesign/元数据与 operator-preview 包边界;CI 新增 `desktop-macos` 任务;React 标题栏为原生红绿灯预留空间。desktop-tag Go 测试在 macOS 通过,App/desktop-bridge React 测试通过;签名、公证与人工 macOS 矩阵未做,`release_ready` 保持 false。边界见 ADR 0097 与 `docs/DESKTOP_PLAN.md`;本轮没有重跑 WFP、Docker、付费 Provider 或全仓重型门,下次上下文压缩后不得重复。 diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index 62434cd5..0b30273e 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -4,7 +4,7 @@ Last updated: 2026-08-20 -## Current Single-Slice Checkpoint: Deliverable Batch Agents / Issue #103 +## Upstream Checkpoint: Deliverable Batch Agents / Issue #103 2026-08-20 的 issue #103 落地 `batch-delivery.v1`(ADR 0119,schema v118)。该合同只消费 已审批/admission 的 core child proposal,精确复核 Agent、DAG、预算与 expected artifacts, @@ -32,6 +32,56 @@ running + current full_access(或显式更高的 debug);Desktop batch muta 清理 exact clean identity,dirty/committed/drifted 成果保留;启动 reconciliation 在非 running Run 上不产生文件/Git 副作用,也不恢复 token、进程或旧 authority。完整边界见 `docs/batch-delivery.md` 与 ADR 0119。 +## Current Single-Slice Checkpoint: Source-Bound Real-Browser UI Evidence / Issue #102 + +2026-08-20 的 issue #102 落地 `ui-evidence.v1`(ADR 0120,schema v119)。不可变 +manifest 把 Run/Mission/Session/Workspace 与 fresh source checkpoint、可选 build/必需 start +`command-runtime.v2` recipe、readiness、固定浏览器 executable/version/SHA-256、literal +loopback URL/route、viewport/DPR、locale/theme/reduced-motion、deterministic fixture/seed/page +state、有序交互/断言、capture/mask 和 fail-closed diagnostics 绑定。Application 在 build +前、readiness 后、浏览器断言后和 owned process cleanup 后重捕获 source;commit/branch/index/tracked/untracked/root +漂移失败关闭。已占用 readiness 端口返回 `launch/preexisting_service`,不会收养或停止外部 +服务。 + +应用由 v116 Run-owned command runtime 持有,浏览器沿用 Safe Web 的固定安装验证、创建时 +Job Object/WFP 绑定与 attempt-private Profile,但使用独立 +`restricted-cdp-ui-evidence.v1` 方法白名单。允许真实 navigate/click/type/selector、PNG、 +DOM、accessibility、performance 和 bounded console/page/network/HTTP diagnostics;拒绝 +`Runtime.evaluate`、cookie、response body、request mutation/replay、Full CDP 和个人 Profile。 +取消、超时与 Desktop shutdown 等待 browser/application tree、Profile、network guard 和端口 +清理;启动/读取仍要求 live lease,清理只能凭本 Attempt 的 durable Job/operation/Run/lease +绑定回收当前 manager 精确持有的 Job。重启只把残留 `running` 收敛为 +`interrupted/cleanup`,不恢复启动 authority。 + +状态闭集为 `not_run|running|passed|failed|cancelled|timed_out|interrupted`,只有 `passed` +可通过;失败阶段固定 build/launch/readiness/navigation/selector/assertion/console/network/ +capture/cleanup。SQLite v119 封存 manifest、append-only steps 与内容寻址 artifacts,限制 +32 MiB/artifact、128 MiB/attempt、2 GiB/store。每个 artifact 绑定 source commit、Run/ +Attempt/step、MIME、bytes、SHA-256、viewport/dimensions、capture time、redaction 和 +`untrusted=true`;文本、URL 和动态 screenshot 区域按统一规则脱敏。 + +Desktop 默认保留历史只读面,只有 Run execution + permission control + danger-full-access + +restricted browser CDP + `--enable-ui-evidence` 同时启用时允许启动/取消。认证 OpenAPI 与 +Desktop 共用异步 Application service;CLI 仅 list/show/hash-verified exclusive export。 +`run-verify` 升级为 1.1.0,并要求与 `focused-checks`/PR receipt 对齐。Windows CI 用真实 +Edge、临时 Profile、创建时 Job 和 deterministic loopback fixture 覆盖 desktop/mobile、 +light/dark、en-US/zh-CN、full/reduced motion;`receipt.json` 固定 clean commit、浏览器 +version/hash、matrix 和 artifact hash/尺寸,并由缺失 click handler 的回归页证明真实交互 +断言能发现源码/build 检查无法单独证明的错误。完整操作边界见 `docs/ui-evidence.md`。 + +最终本地门:完整 Go 套件通过(Application 611.312 秒、HTTP 229.102 秒、Store v1→v119 +974.372 秒),受影响路径 race/vet/staticcheck、模块完整性、OpenAPI/TypeScript 确定性生成、 +62 个前端文件/266 项测试、build/npm audit、Rust fmt/test/clippy、CI YAML 和 Windows Desktop +可复现双构建均通过。真实 Edge 151.0.4129.93 的最终 source-bound receipt 由 PR/CI 报告; +regression caught 与 browser tree/port/Profile/fixture cleanup 全为 true。本次真实运行还修复了 Windows Profile +sharing-lock 的 exact-owner 5 秒有界清理重试,并把 screenshot pixel surface/dimensions 在 +domain/SQLite/React 三层绑定到 viewport×DPR、在完整 PNG 解码前限制 header dimensions。 +diagnostic URL 会重新通过 exact TargetScope,越界 scheme 只保留 `[blocked-url]`;React 还 +复核 step/artifact chronology。连续真实运行的三张 PNG 逐字节一致,Desktop 可复现 EXE +的最终 source-bound SHA-256 由 PR/CI 报告。 +最终本地与 CI receipt 均在 clean checkout 强制 source identity。Go 1.26.5 的 govulncheck +5 项可达结果均来自标准库且修复于 +1.26.6,没有新增模块依赖问题。 ## Previous Single-Slice Checkpoint: Transactional Workspace Checkpoints / Issue #101 @@ -750,8 +800,8 @@ Read in this order after a long context break: - Generic coding-agent workflow usability: about 98%. - Cyber autonomous-workflow usability: about 20%. - These are engineering estimates based on tested roadmap slices, not performance benchmarks. Do not reuse the retired single-axis "overall product vision" percentage. -- Database schema: v98. -- `README.md` carries the canonical bilingual schema timeline in strict `v1 -> v98` order. `internal/store/readme_history_test.go` binds its row count and ordering to `LatestSchemaVersion`, so a future migration cannot silently leave the public history missing or out of sequence. +- Database schema: v118. +- `README.md` carries the canonical bilingual schema timeline in strict `v1 -> v118` order. `internal/store/readme_history_test.go` binds its row count and ordering to `LatestSchemaVersion`, so a future migration cannot silently leave the public history missing or out of sequence. - Main languages: Go control plane, TypeScript React/Vite local console, and deterministic Rust 1.97.1 digest/ZIP protocol functions. Rust has no Agent, LLM, config, key, persistence, network, filesystem, subprocess, or product-lifecycle ownership. - Analyzer status: P10-A through P10-K define and validate the Go/Rust protocol and embedded-WASI boundary. P10-L/schema v94-v95 adds real fixed-module execution, one-shot exact-bound authorization, atomic consumption, redacted execution, metadata-only Artifact content, and Run events. P10-M exposes only this embedded module through CLI/control-token HTTP/Desktop/React; callers cannot provide WebAssembly, imports, mount, network, command, argv, environment, or native process. See ADR 0062, ADR 0063, ADR 0090, ADR 0091, and `analyzers/README.md`. - Model Harness status: non-schema A1/A2/A3 adds `model_harness.v1` exact transport/tool/JSON/streaming profiles, Go preflight for Root/Specialist/read-only Fan-out, and `model_harness_qualification.v1` at-most-two-call synthetic qualification. Mock is trusted offline; Anthropic-compatible models require explicit qualification. Qualification stores only exact binding digest, capability booleans, and seven-day expiry in existing Provider settings; the synthetic Tool is never executed, availability remains no-probe, and qualification grants no Tool/Shell/file/browser/Docker authority. P13-A adds the durable public-activity read projection; P13-B adds a separate process-local safe public assistant stream with exact cancellation and no raw Provider persistence; P13-C adds the Root-only, independently reviewed `approval` host-command proposal Tool without granting the model execution authority. P10-M2 proves the production Anthropic-compatible route and durable chat path against deterministic local SSE, while P13-B3 verifies one configured real DeepSeek path. Current OpenAPI is 88 paths / 96 operations / 212 schemas. See ADR 0074, ADR 0080, ADR 0091, ADR 0092, and ADR 0093. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index d28b4bbf..0dfbc817 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -6,7 +6,7 @@ Last updated: 2026-08-20 ## Resume Context -当前检查点是 issue #103 / schema v118 的可交付多代理、Worktree 隔离与独立合并复核。 +当前分支所基于的上游检查点是 issue #103 / schema v118 的可交付多代理、Worktree 隔离与独立合并复核。 `batch-delivery.v1` 只绑定已审批/admission 的 core child proposal,并复核相同 Agent、DAG、 预算和 expected artifacts;最多两个 child 各自使用独立 branch/worktree、generation lease、 一次性 owner token 与关闭的 `batch-delivery-tools.v1`。工具仅覆盖 owned Scope 内的有界 @@ -51,6 +51,66 @@ Go 1.26.6 修复,并非本次仓库依赖引入或零发现结论。 前一检查点是 issue #101 / schema v117 的事务化 Workspace Checkpoint、恢复与独立 Fork。 `workspace-checkpoint.v1` 固定 Run/Workspace/root、base commit、branch、原始 Git + +当前检查点是 issue #102 / schema v119 的来源绑定真实浏览器 UI 证据。`ui-evidence.v1` +不可变绑定 Run/Mission/Session/Workspace、fresh source checkpoint、可选 build/必需 start +recipe、readiness、固定浏览器 version/executable hash、literal-loopback URL/route、 +viewport/DPR、locale/theme/reduced-motion、deterministic fixture/seed/page state、有序 +navigate/click/type/assert/capture 步骤、mask 与 fail-closed diagnostics。源码在 build 前、 +readiness 后、浏览器断言后和 owned process cleanup 完成后重捕获;外部服务端口、源码漂移、 +console/page error、failed/ +blocked request、HTTP failure、capture 或 cleanup 不完整都会以稳定阶段失败。只有 `passed` +为成功,`not_run` 保持中性。 + +应用进程由 v116 command runtime 持有;真实 Edge/Chrome 使用固定安装复核、新的一次性 +Profile、创建时 Job Object 与 Safe Web 网络 guard,并只开放独立 +`restricted-cdp-ui-evidence.v1` 方法集合。页面、DOM/a11y/log/network/artifact 全部不可信且 +不授权;cookie、登录态、个人 Profile、Full CDP、任意 JS、response body 和 request +mutation/replay 不可达。SQLite 封存 manifest、append-only steps 和 hash-addressed artifacts, +并在 Go/trigger 两层约束状态、来源、大小和配额。取消、timeout、Desktop shutdown 与 +restart reconciliation 均不收养历史 PID/端口/Profile/authority。 + +Desktop 默认可只读查看历史;执行需显式 `--enable-ui-evidence` 以及 Run execution、 +permission control、danger-full-access 和 restricted browser CDP 全部成立。OpenAPI 与 +Desktop 共用异步 Application service,CLI 仅 list/show/hash-verified exclusive export。 +`run-verify@1.1.0`、`focused-checks` 与 PR receipt 使用相同状态和来源字段。Windows CI +以真实 headless Edge 跑 desktop/mobile、light/dark、en-US/zh-CN 和 reduced-motion 矩阵, +上传截图与 receipt,并通过仅缺失 click handler 的 fixture 证明真实交互证据能发现源码/ +build 检查无法单独证明的回归。边界见 ADR 0120 与 `docs/ui-evidence.md`。 + +Issue #102 的最终本地门已通过 `go test -count=1 -timeout 25m ./...`:Application +611.312 秒、HTTP API 229.102 秒、Store 完整 v1→v119 迁移链 974.372 秒。UI-evidence、 +browserruntime、Application、Store 与 HTTP 新路径的定向 `-race` 均通过;`go vet`、受影响包 +`SA*`/`S1*`/`QF*` staticcheck、`go mod verify`、`go mod tidy -diff`、OpenAPI golden、strict +TypeScript、62 个前端 +文件/266 项测试、production build、npm audit(0 vulnerability)、Rust fmt/test/clippy、CI YAML +解析和 Windows Desktop 可复现双构建全部通过。Desktop EXE 的最终 source-bound SHA-256 +由 PR/CI 报告。 + +本机 Edge 151.0.4129.93(executable SHA-256 +`486c0e70f7c66a3288f3b00acf25452b69e08c2cb1f361937d8f6e720ee01ece`)完成两组矩阵和故意 +interaction regression;最终 source-bound receipt SHA-256 由 PR/CI 对最终提交报告,三张 PNG 的 SHA-256 +分别为 `f215d70b144e3116838e3bc6fb579e42385927d03ac316cee19d2522ac3be337`、 +`deb11ce9f55258066d7902bd7857d873007e484380454bb3332231b186c9fbf3` 和 +`d8e58eac08a8dc1e81e8799721e29f462b2137d67b54e1990186268bb1d5723d`。连续两次运行的三张 +PNG 逐字节一致;CI fixture 显式固定了 native control 的 hover/focus/caret 绘制。首次真实 +运行捕获了 +Windows Profile 数据库短暂 sharing lock;exact-owner quarantine 删除现采用 5 秒有界重试, +重跑证明 browser tree、DevTools port、Profile 与 fixture 均被回收。像素面与 PNG dimensions +也在 domain/SQLite/React 三层绑定到 viewport×DPR。最终本地与 CI receipt 均在固定 clean +checkout 上强制 source identity。`govulncheck@v1.6.0` 如实报告本机 Go 1.26.5 +标准库 5 项可达问题,均修复于 Go 1.26.6;没有新增模块依赖问题,CI 仍以 latest Go 1.25 +patch 的扫描结果作为合并门。 + +最终安全审计还关闭了四个跨层窗口:命令启动/读/等仍要求 live lease,但取消、timeout 或撤权 +后的清理只能凭 Attempt 封存的 durable Job/operation/Run/lease identity 回收精确的进程内 owned +Job;通过判定在全部 owned resource 回收后再次重验 source;diagnostic URL 必须重新通过 exact +TargetScope,越界 scheme 只保留 `[blocked-url]`;PNG 在完整解码前先限制 header dimensions。 +React 同时拒绝落在 Attempt 时间窗外的 step/artifact。对应普通、race、真实 Edge 与 strict +response-parser 回归均通过。 + +前一检查点是 issue #101 / schema v117 的事务化 Workspace Checkpoint、恢复与独立 +Fork。`workspace-checkpoint.v1` 固定 Run/Workspace/root、base commit、branch、原始 Git index、稳定 manifest、内容哈希、触发收据、attempt/capability generation 和恢复等级; 普通内容以 SHA-256 去重,SQLite seal/refcount/quota trigger 保证不可变引用和 2 GiB 全局 blob 硬上限,并将 checkpoint/manifest entry/transaction 元数据分别限制为 diff --git a/docs/README.md b/docs/README.md index 944a3310..10ffdf8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ This directory separates user-facing documentation, current engineering state, a | [使用手册 / Usage](usage.md) | CLI、Provider、Workspace、Run、审批和操作者工作流 | | [Workspace Checkpoints](workspace-checkpoints.md) | 检查点时间线、预览、Undo/Redo/Rewind、独立 Fork 与故障处理 | | [可交付多代理 / Deliverable Batches](batch-delivery.md) | child Worktree、缩权工具、邮箱、交付收据、复核、合并与恢复 | +| [真实浏览器 UI 证据 / UI Evidence](ui-evidence.md) | 源码/配方绑定、真实 Edge 矩阵、产物、失败语义与操作手册 | | [Windows Desktop 计划](DESKTOP_PLAN.md) | 桌面架构、发布门和仍未开放的能力 | | [Skill 包计划](SKILL_PACKAGE_PLAN.md) | 惰性 Skill 导入、校验和未来分发边界 | @@ -28,6 +29,7 @@ This directory separates user-facing documentation, current engineering state, a | [错误模型 / Errors](errors.md) | 稳定错误类别、CLI 退出码和 HTTP 映射 | | [ADR 0118 / Workspace Checkpoints](adr/0118-transactional-workspace-checkpoints.md) | 事务边界、内容寻址、三方恢复、崩溃收敛与权限模型 | | [ADR 0119 / Deliverable Batch Agents](adr/0119-deliverable-batch-agents.md) | child 所有权、一次性 authority、提交 WAL、独立复核与本地合并队列 | +| [ADR 0120 / UI Evidence](adr/0120-source-bound-real-browser-ui-evidence.md) | 真实浏览器所有权、受限 CDP、来源绑定、脱敏与 CI 决策 | | [ADR 索引](adr/) | 权限、持久化、执行、浏览器、Desktop 等架构决策 | ## 当前工程上下文 / Current Engineering Context diff --git a/docs/TASK_BOOK.md b/docs/TASK_BOOK.md index be2222d1..2409c5e2 100644 --- a/docs/TASK_BOOK.md +++ b/docs/TASK_BOOK.md @@ -1,6 +1,6 @@ # Prayu V2 任务书 -更新时间:2026-08-15 +更新时间:2026-08-20 ## 目标 @@ -10,6 +10,18 @@ ## 当前基线 +来源绑定真实浏览器 UI 证据(Issue #102)已随 schema v119 落地(ADR 0120): +`ui-evidence.v1` 把 fresh source checkpoint、受审阅 build/start recipe、固定浏览器 +version/executable hash、literal-loopback URL/route、viewport/DPR、locale/theme/ +reduced-motion、deterministic fixture/seed/page state、有序交互断言、capture/mask 与 +fail-closed diagnostics 封存到同一 Attempt。Application 拒绝预存服务,跨 build、readiness +和最终判定重验源码,通过 v116 command runtime 与 Safe Web exact-owned 浏览器生命周期 +负责 app/browser/Profile/network/port 清理。`not_run` 永不通过;console/page/request/HTTP、 +source drift 和 cleanup failure 有稳定阶段。SQLite 持久化不可变 manifest/steps/hash-addressed +artifacts;Desktop/OpenAPI/只读 CLI 与 `run-verify@1.1.0` 使用同一语义,Windows CI 以真实 +Edge 跑呈现矩阵并证明缺失 click handler 只能由真实页面交互断言发现。个人 Profile、cookie、 +Full CDP、外部网络、任意 JS 和历史 authority 保持不可达。 + Ollama 本地 Provider(Issue #48)已落地(ADR 0100):无凭证 loopback-only 适配器只连接显式配置的 `http://127.0.0.1:11434` 类端点,拒绝非 loopback/HTTPS/redirect/代理绕过;`/api/tags` 模型列表、`/api/chat` 同步与 NDJSON 流式、usage 估算与稳定错误映射齐全;`/api/show` 能力探测把 tools/vision/JSON/context 按“未知即不支持”失败关闭,no-tool 模型绝不收到 Tool schema;能力探测在路由选择、qualification 与 diagnostic 前 best-effort 执行,探测到的 context window 进入真实预算规划。Registry kind `ollama` 与 transport `ollama_chat` 已接入 CLI/HTTP/OpenAPI/Desktop/Web;credential 枚举保持四位(Ollama 无凭证)。本机未安装 Ollama,真实 smoke 为 usage 文档化的可选人工步骤;fake-server 离线测试覆盖 list/chat/stream/取消/不可达/redirect/代理/no-tool 与探测语义。 Docker Sandbox 产品准入(Issue #40)已随 schema v99 落地(ADR 0099):同一个 Go Application 服务把 CLI、HTTP/OpenAPI、Desktop 与模型 admission proposal 接到 v97 生命周期和 v98 有界 I/O;真实 start 默认关闭,必须重新通过当前 Profile/权限/精确 per-call 审批/Policy/预算/30 秒 readiness 与进程内 capability。当前只开放 environment-free、secret-free 的 network-none 精确计划;managed allowlist 因缺少 Go-owned host/port/protocol guard 继续失败关闭。Issue #40 集成收口已完成:全仓 ordinary Go、vet、Web strict TypeScript/235 项测试/Vite build 与 OpenAPI 再生全绿;真实 Docker Desktop 29.6.2 上 lifecycle、readiness 与容器内主动探测 IPv4/IPv6/DNS/gateway/代理变量的断网验收全部通过(environment-free 夹具由 testdata/docker-lifecycle-fixture/build-fixture.ps1 可复现构建),并把 daemon /info 的 x86_64/aarch64 架构名规范化到 OCI amd64/arm64。 @@ -598,6 +610,9 @@ schema v65 已增加不可变 `sandbox_docker_production_evidence.v1`:Go 固 - [x] P11-C8B / schema v92:完成可恢复 runtime lifecycle 编排、只追加 checkpoint/receipt、恢复投影、审计事件,以及进程退出到 WFP/Profile release/cleanup 的精确对账;未增加模型 Tool。 - [x] P11-C8A/C8B 功能门与组合审计:全仓 Go 约 435 秒、vet/staticcheck、browserruntime 普通/race 和 Linux 无 CGO 交叉编译通过;修复 WFP x64 ABI 对齐、Profile 删除后复核和并发 Finalize 唯一收据,未启动真实浏览器。 - [ ] P11-C8C:仅在 C8A/C8B 独立接纳后增加操作者专用的 Restricted Safe Web 产品入口;完整 CDP、个人 Profile 和模型控制继续分离。 +- [x] P11-C9A / schema v119:定义并封存 `ui-evidence.v1` source/recipe/runtime/presentation/fixture/step/capture/failure 合同、严格状态机、稳定失败阶段和不可变 SQLite Attempt/step/content-addressed artifact 账本;历史迁移不回填证据,`not_run` 永不通过。 +- [x] P11-C9B:以 v116 Run-owned command runtime 和 exact-owned Safe Web browser 组合受审阅应用启动、readiness、来源重验、真实 navigate/click/type/selector 与 PNG/DOM/a11y/console/page/network/HTTP/performance 采集;拒绝预存服务、外部 origin、个人 Profile/cookie、Full CDP、任意 JS、request body/mutation/replay,并在 success/failure/timeout/cancel/shutdown 后等待清理。 +- [x] P11-C9C:接入 Desktop 审阅/执行/历史面板、认证 OpenAPI、read-only/hash-verified CLI、`run-verify@1.1.0`、README/guide/ADR;Windows CI 真实 Edge 矩阵覆盖 desktop/mobile、theme/locale/reduced-motion,并用缺失 click handler 的页面证明真实交互证据可捕获 source/build-only 检查不能证明的回归。 - [ ] P11-D:CTF Lab 抓包/改包/重放、Cookie 与代理;所有请求继续绑定 exact scope、预算和事件审计。 - [ ] P11-E:仅容器内开放 Instrumented 安全放宽并强制证据标记,默认档永久保持浏览器原生安全。 - [ ] 在 Profiles/Skills/Finding/Sandbox 稳定后实现 CTF Mission Profile。 diff --git a/docs/adr/0120-source-bound-real-browser-ui-evidence.md b/docs/adr/0120-source-bound-real-browser-ui-evidence.md new file mode 100644 index 00000000..5e62bedb --- /dev/null +++ b/docs/adr/0120-source-bound-real-browser-ui-evidence.md @@ -0,0 +1,84 @@ +# ADR 0120: Source-bound Real-browser UI Evidence / 源码绑定的真实浏览器 UI 证据 + +Date: 2026-08-20 + +## Status / 状态 + +Accepted for schema v119, `ui-evidence.v1`, and the gated Windows Desktop execution surface. + +已接受,用于 schema v119、`ui-evidence.v1` 与显式门禁的 Windows Desktop 执行面。 + +## Context / 背景 + +Source review, snapshots rendered outside the application, and successful frontend builds do not prove routing, runtime CSS/media behavior, event handlers, focus, accessibility, console health, or request outcomes in a real engine. Conversely, a screenshot without source/runtime provenance can be stale, captured from a personal browser session, or produced by an unrelated process already using the expected port. + +The existing Safe Web runtime already defines executable discovery, publisher review, disposable Profiles, Windows WFP containment, creation-time Job Objects, exact target scope, and a closed restricted-CDP transport. The Run-owned command runtime provides cancellable application ownership but is a host `full_access` capability, not a general network sandbox. UI verification must compose these boundaries without turning persisted evidence, a Skill, React, or page content into authority. + +源码审阅、脱离应用的 mock/snapshot 与 build success 无法证明真实引擎中的 route、CSS media、事件处理、focus、accessibility、console 和 request 结果;无来源/runtime 的截图也可能陈旧、来自个人 Profile,或实际上由已占用端口的外来服务生成。新能力必须复用既有 Safe Web 与 Run-owned command 边界,同时保持证据、Skill、React 和页面内容均不授权。 + +## Decision / 决策 + +### 1. Immutable protocol and strict state machine + +Schema v119 stores immutable `ui-evidence.v1` manifests, append-only step receipts, content-addressed artifacts, and a bounded Attempt state machine. The only transitions are `not_run -> running -> passed|failed|cancelled|timed_out|interrupted`. `not_run` is explicitly unknown and `Status.Passed()` is true only for `passed`. SQLite triggers reject terminal mutation, deletion, late steps/artifacts, authority-widening manifest JSON, quota bypass, and invalid source/Run joins. + +Operation-key digests and request fingerprints make identical retries converge and changed intent conflict. Startup reconciliation converts only persisted `running` attempts to `interrupted/cleanup`; it never restarts a PID, browser, command, Profile, lease, or capability. + +### 2. Exact source and recipe binding + +The manifest seals the repository kind, commit/branch, dirty flag/digest, canonical root fingerprint, exact Git index digest, deterministic worktree manifest digest, optional build and mandatory start recipes, readiness contract, fixed browser identity/version/executable hash, restricted driver version, literal URL/route, viewport/DPR, locale/theme/reduced motion, deterministic fixture/seed/page state/data digest, ordered steps, masks, failure policy, Run identities, and creation time. + +Command recipes retain canonical argv and Workspace-relative cwd while replacing host executable paths and environment values with SHA-256. They require `network=disabled`, `credentials=none`, closed initial stdin, bounded output/time, and reject known network-client or dependency-install intent. Raw typed fixture input is request-local and digest-sealed; it is not persisted. + +Application captures a fresh checkpoint before build, after readiness, after browser assertions, and once more after owned application/browser cleanup before terminal success. Each resulting source binding is compared with the immutable manifest, closing the interval in which the still-running application could otherwise mutate source after the last assertion. Drift fails at a stable stage. Ignored/generated output follows the explicit workspace-checkpoint exclusion model; tracked or relevant untracked changes cannot silently inherit an old evidence identity. + +### 3. Attempt-owned application and browser lifecycle + +Before application start, Go probes the exact literal readiness endpoint. An existing listener yields `launch/preexisting_service`; it is neither adopted nor stopped, and cleanup does not claim its port. The application starts only through the current Code/Local/Deliver/root Run execution lease and `full_access` command-runtime capability. + +The browser is freshly discovered from the fixed registry, version/hash/publisher revalidated, and launched headless with a disposable Profile through the reviewed Safe Web/WFP/Job lifecycle. There is no connection to an existing DevTools endpoint or personal Profile. The service, not the HTTP request, owns the bounded execution context. Start/read/wait authority continues to require the current Run lease. A distinct internal cleanup-only capability carries the exact durable Job, operation, Run, Workspace, root, and original lease identity and can only kill/reap that process-local owned Job after expiry, cancellation, or revocation; it cannot start, adopt, read, write, or target another Job. Cancel and Desktop shutdown wait for browser/application process trees, network containment, Profile removal, and owned-port release before terminal completion. After exact-owner quarantine, transient Windows sharing violations receive a five-second bounded deletion retry; exhaustion remains a cleanup failure and cannot be converted into a pass. + +Read-only construction is separate from execution construction. Disabling UI-evidence control on a later launch still permits historical manifest/receipt/hash-verified artifact reads and startup reconciliation, but cannot start or cancel a process. + +### 4. Closed real-browser action and capture surface + +The UI-evidence CDP extension is a second explicit authorization bit over the base restricted session. Its closed method set supports device metrics, locale/media emulation, DOM query/focus/box/outerHTML, mouse/text input, accessibility, log/runtime event subscription, performance metrics, and screenshots. It deliberately excludes arbitrary JavaScript evaluation, cookie/storage access, request/response bodies, authentication material, request mutation/replay, and fulfillment. + +Every navigation, subresource, and redirect stays within the exact literal loopback origin. The driver provides only navigate, click, bounded digest-sealed type, present/absent selector assertions, and capture. A first navigate step is mandatory. Screenshot masks are selector-based and fail when absent rather than silently leaking an expected region. + +### 5. Evidence, redaction, and failure policy + +V1 execution requires PNG screenshots together with DOM, accessibility, console/page-error, network/HTTP, and performance JSON so pixels cannot stand in for behavioral and runtime-health evidence. Video is reserved and rejected in V1. The logical viewport/DPR pair must fit a 7680×4320 pixel surface. PNG dimensions are bounded from `DecodeConfig` before full decode/allocation and must match `viewport × DPR` within one pixel of browser rounding; domain validation, SQLite insertion triggers, and the React receipt parser enforce the same relation. Artifact metadata binds SHA-256, MIME, length, viewport, image dimensions, source step, source commit, Run/Attempt, capture time, redaction, `retention_policy=run_history`, and `untrusted=true`. Local evidence is retained with Run history without silent expiry under per-artifact, per-Attempt, and global hard quotas of 32 MiB, 128 MiB, and 2 GiB; the CI upload copy has a five-day retention. + +Text captures pass the output-safety and secret-redaction boundary. Network capture re-authorizes every diagnostic URL against the exact TargetScope, omits header, cookie, body, userinfo, query, and fragment, and persists only `[blocked-url]` for data/file/blob or other out-of-scope schemes. Screenshots cannot be content-redacted automatically, so deterministic synthetic/cleared fixtures and explicit masks are mandatory review responsibilities. Browser content and downloaded evidence remain untrusted and grant no process, network, credential, personal-Profile, request-mutation, or verification-pass authority. + +The server enforces all four failure rules in V1: console errors, page exceptions, failed requests, and HTTP status failures. Stable failure stages separate build, launch, readiness, navigation, selector, assertion, console, network, capture, and cleanup. Incomplete cleanup overrides an otherwise successful outcome. + +### 6. Equivalent surfaces and CI proof + +Authenticated OpenAPI provides list/start/get/artifact/cancel; read and control bearers remain distinct, mutation bodies are strict/bounded, and cancellation requires `confirm=true`. Desktop uses the same Handler through its fixed bridge and adds explicit manifest review; its capability flag monotonically depends on Run execution, danger-full-access, and restricted CDP control. CLI deliberately exposes list/show/hash-verified exclusive export only, so it shares evidence semantics without copying runtime authority. + +The React parser rejects unknown status/authority widening and verifies downloaded MIME/length/hash before creating a Blob. `not_run` uses a neutral badge. The built-in `run-verify@1.1.0` requires exact evidence fields, stable failure stages, `focused-checks` mapping, and a detailed PR verification receipt. + +Windows CI launches the installed stable Edge from a clean fixed commit in a creation-time Job Object and disposable Profile against a deterministic loopback fixture. It covers desktop/light/en/full-motion and mobile/dark/zh/reduced-motion cells, real click/type/assertions, DOM/accessibility/performance/diagnostics/screenshots, exact request scope, dimensions and hashes. A second page that differs only by a missing event handler must fail the post-click real-DOM assertion. The workflow uploads screenshots and a source/browser-bound receipt. + +## Alternatives considered / 备选方案 + +- Treat build or unit tests as UI proof: rejected because they do not execute browser behavior. +- Capture an externally supplied URL or attach to an existing browser/service: rejected because ownership, source, credentials, Profile, scope, and cleanup cannot be proven. +- Use unrestricted CDP/Playwright evaluation: rejected because arbitrary script, cookies, bodies, and request mutation materially widen authority. +- Store only screenshots: rejected because console, request, DOM/accessibility, environment, and provenance are needed to interpret the pixels. +- Make evidence automatically approve a baseline or PR: rejected because evidence is untrusted observation, not authorization or review judgment. +- Hide historical evidence when execution is disabled: rejected because read authority and process authority are intentionally separate. + +## Consequences / 后果 + +- Real browser regressions can be reproduced and traced to a precise source/runtime tuple. +- The workflow is intentionally stricter and heavier than component tests; matrix breadth belongs in focused/release checks, not every edit loop. +- Windows is the current product execution platform because its reviewed WFP/Job/Profile path is complete. Other platforms retain read-only history until an equivalent containment adapter is reviewed. +- Fixture loading remains application-owned. The service binds and verifies the declared fixture and interactions but does not inject arbitrary storage or JavaScript. +- GIF/video and automatic baseline management remain out of V1. + +## Verification / 验证 + +Tests cover protocol fingerprints, `not_run` semantics, source drift through post-cleanup completion, exact Job cleanup after lease release, secret/type digest rejection, closed CDP methods, pre-decode PNG bounds, real Edge matrix/regression detection, disposable-profile Job cleanup, exact-origin network diagnostics, immutable SQLite state/quotas/migration/reconciliation, no-adoption behavior, asynchronous cancel/close, read-only history, strict bearer/JSON/OpenAPI routes, hash-verified artifacts, CLI exclusive export, Desktop capability dependencies and bridge projection, React parsing/download/chronology/status/review gates, generated API types, and Skill archive compatibility. diff --git a/docs/architecture.md b/docs/architecture.md index 72915e5f..ac070d7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -659,7 +659,7 @@ The same Go adapter owns read projections for the bounded Agent graph, operator- ## Persistence -SQLite remains the local source of truth. Schema migration `v1` records the legacy baseline, `v2`-`v18` establish the Run/Supervisor/memory/tool/Artifact/lease control plane, `v19`-`v38` add bounded Agent coordination, reviewed delegation, read-only Fan-out, Findings, and operator scheduling, and `v39`-`v47` add immutable Skill selection/context, Run modes, Plan/Delivery, provenance, checkpoints, steering, and Specialist minimization. Schemas `v48`-`v63` build the still-disabled Sandbox evidence and recovery chain; `v64`-`v68` add non-authorizing execution-profile and Docker production-evidence decisions; `v69`-`v71` add the inert user Skill Registry, exact Run selection/context, and read-only provenance; `v72`-`v113` continue the audited Run, Desktop, Provider, browser, Analyzer, Docker, dependency, MCP, project-config, Git, external-Skill, and Debug-terminal control planes; `v114` adds explicit long-term memory plus immutable instruction/continuity ledgers without authority restoration; `v115` adds model-callable workspace tools and hash-guarded file mutations; and `v116` adds the Run-owned ordinary command runtime and its Supervisor call ledger. Non-schema D1-B1 exposes the existing v69 Registry through inert HTTP/Desktop confirmation and adds no migration. Migrations are ordered, checksummed, transactional, and safe to apply repeatedly; legacy databases are upgraded without deleting their data or fabricating new operator decisions. +SQLite remains the local source of truth. Schema migration `v1` records the legacy baseline, `v2`-`v18` establish the Run/Supervisor/memory/tool/Artifact/lease control plane, `v19`-`v38` add bounded Agent coordination, reviewed delegation, read-only Fan-out, Findings, and operator scheduling, and `v39`-`v47` add immutable Skill selection/context, Run modes, Plan/Delivery, provenance, checkpoints, steering, and Specialist minimization. Schemas `v48`-`v63` build the still-disabled Sandbox evidence and recovery chain; `v64`-`v68` add non-authorizing execution-profile and Docker production-evidence decisions; `v69`-`v71` add the inert user Skill Registry, exact Run selection/context, and read-only provenance; `v72`-`v113` continue the audited Run, Desktop, Provider, browser, Analyzer, Docker, dependency, MCP, project-config, Git, external-Skill, and Debug-terminal control planes; `v114` adds explicit long-term memory plus immutable instruction/continuity ledgers without authority restoration; `v115` adds model-callable workspace tools and hash-guarded file mutations; `v116` adds the Run-owned ordinary command runtime and its Supervisor call ledger; `v117` adds content-addressed transactional Workspace checkpoints; and `v118` adds source-bound real-browser UI-evidence attempts, steps, and artifacts. Non-schema D1-B1 exposes the existing v69 Registry through inert HTTP/Desktop confirmation and adds no migration. Migrations are ordered, checksummed, transactional, and safe to apply repeatedly; legacy databases are upgraded without deleting their data or fabricating new operator decisions. ```text missions @@ -1273,3 +1273,54 @@ branch or `orphaned` directory. Startup reconciliation converges durable materia commit, expiry, and merge intents without restoring tokens, processes, or authority; a non-running Run is reported for operator attention before any filesystem or Git effect. Operational details are in [Deliverable Multi-Agent Batches](batch-delivery.md). + +## Source-Bound Real-Browser UI Evidence + +ADR 0120 and schema v119 add `ui-evidence.v1` above the v116 command runtime and +the existing Safe Web lifecycle. The immutable manifest is the join point between +one Run/Mission/Session/Workspace, a fresh source checkpoint, optional build and +required start `command-runtime.v2` recipes, readiness, an executable/version/hash- +pinned browser, literal loopback URL and route, viewport/DPR, locale/theme/reduced +motion, deterministic fixture/seed/page state, ordered steps, masks, and a mandatory +fail-closed diagnostic policy. Source identity is captured before persistence and +revalidated before build, after readiness, and immediately before pass. Build or +application mutation of tracked/untracked/index/commit/branch/root identity therefore +cannot be hidden by a successful screenshot. + +The lifecycle is `not_run -> running -> passed|failed|cancelled|timed_out`; startup +reconciliation maps a stranded `running` row to `interrupted/cleanup` without reviving +authority. `not_run` never satisfies `Passed()`. Stable failure stages are build, +launch, readiness, navigation, selector, assertion, console, network, capture, and +cleanup. Every terminal pass requires zero console/page/request/HTTP failures and a +complete browser-tree/application-tree/Profile/network/port cleanup receipt. SQLite +seals the manifest, append-only step receipts, and content-addressed artifacts while +enforcing 32-MiB artifact, 128-MiB attempt, and 2-GiB store limits. + +Execution is scoped ownership, not browser adoption. The Application service probes +the exact readiness port before start and refuses an existing listener. It runs the +reviewed application through the Run-owned command manager, launches a newly verified +fixed-location Edge/Chrome executable in an attempt-private Profile, and binds the +browser tree at creation to Safe Web network containment and a kill-on-close Job. +Cancellation and service shutdown use a context independent of the HTTP request and +wait for owned cleanup. A restart may reconcile SQLite state but never adopts a PID, +port, browser, Profile, cookie store, or old capability. + +`restricted-cdp-ui-evidence.v1` is a separate method allowlist for viewport/emulation, +navigation, click/type through fixed DOM methods, selector assertions, screenshot, +DOM/accessibility, performance, and bounded diagnostics. It excludes +`Runtime.evaluate`, cookies, credentials, response bodies, request mutation/replay, +and `Fetch.fulfillRequest`; all observed page data stays untrusted and non-authorizing. +Network evidence retains only bounded redacted origin/path metadata, method, resource +type, status, MIME, and failure summaries. Screenshots require every declared dynamic +mask to resolve before capture. + +Windows Desktop installs a read-only service unconditionally and upgrades it to the +execution service only when Run execution, permission control, danger-full-access, +restricted browser CDP, and the dedicated UI-evidence process gate all hold. React and +OpenAPI are projections over the same Application service; the CLI deliberately offers +only list/show/hash-verified exclusive export. CI exercises the production restricted +driver against a deterministic loopback fixture with desktop/mobile, theme, locale, +and reduced-motion cells and writes a source/browser/artifact receipt. Its deliberate +missing-click-handler route establishes that real interaction evidence catches a +regression that source/build checks alone do not prove. Operational details are in +[Real-browser UI Evidence](ui-evidence.md). diff --git a/docs/http-api.md b/docs/http-api.md index ca8a90bd..013ac3f2 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -1,8 +1,8 @@ # 本地 HTTP API / Local HTTP API -CyberAgent Workbench 提供由 Go 控制的本地 `api.v1`,用于检查 SQLite 持久状态并投影可恢复 Run events。独立 capability 允许受控 Run/Session/Plan/审批、固定命令提案审阅、Provider 诊断/路由/系统凭证、FileEdit 提案/只读恢复/审阅/apply、wake 意图/前台消费、不可变操作者验证、metadata-only 快照回执及其不授权复核、惰性 Skill 安装、schema v116 的 Run-owned 普通命令运行时、schema v117 的 Workspace Checkpoint 时间线/恢复/Fork、schema v118 的可交付 child Worktree/复核/本地合并,以及 schema v99 默认关闭的精确 Docker Sandbox 产品执行。只读面还提供 capability/worker health、exact-root Repository 状态与脱敏 Diff、非原子的多文件 FileEdit 汇总、逐验证项确定性快照下载/回执历史和带有界复核元数据的可重建 Code Handoff。API 不直接接受 Shell/argv/stdin 或 Job mutation endpoint;命令只能由认证 Run execution 内的 root Supervisor 通过同一 Tool Gateway/Application 服务发起,仍受 Code/Local/Deliver/full-access、当前租约、Policy、无网络/无凭证与进程启动 capability 约束。 +CyberAgent Workbench 提供由 Go 控制的本地 `api.v1`,用于检查 SQLite 持久状态并投影可恢复 Run events。独立 capability 允许受控 Run/Session/Plan/审批、固定命令提案审阅、Provider 诊断/路由/系统凭证、FileEdit 提案/只读恢复/审阅/apply、wake 意图/前台消费、不可变操作者验证、metadata-only 快照回执及其不授权复核、惰性 Skill 安装、schema v116 的 Run-owned 普通命令运行时、schema v117 的 Workspace Checkpoint 时间线/恢复/Fork、schema v118 的可交付 child Worktree/复核/本地合并、schema v119 的来源绑定真实浏览器 UI 证据,以及 schema v99 默认关闭的精确 Docker Sandbox 产品执行。只读面还提供 capability/worker health、exact-root Repository 状态与脱敏 Diff、非原子的多文件 FileEdit 汇总、逐验证项确定性快照下载/回执历史和带有界复核元数据的可重建 Code Handoff。API 不直接接受通用 Shell/argv/stdin 或 Job mutation endpoint;UI 证据启动只接受完整受审阅的有界 recipe/fixture/step 合同。命令和 UI 验证均由认证 Run execution 内的同一 Tool Gateway/Application 服务发起,仍受 Code/Local/Deliver/full-access、当前租约、Policy、无外部网络/无凭证、restricted CDP 与进程启动 capability 约束。 -CyberAgent Workbench exposes a Go-controlled local `api.v1` for durable SQLite state and resumable Run-event projections. Independent capabilities permit controlled Run/Session/Plan/approval operations, fixed-command proposal review, Provider diagnostics/routes/system credentials, operator price-snapshot import and listing, FileEdit propose/read-only recovery/review/apply, wake intent/foreground consumption, immutable operator verification, metadata-only snapshot receipts and their non-authorizing review, inert Skill installation, the schema-v116 Run-owned ordinary command runtime, schema-v117 Workspace Checkpoint timeline/restore/Fork operations, schema-v118 deliverable-child worktree/review/local-merge operations, and schema-v99 exact Docker Sandbox execution that is disabled by default. Read-only surfaces also expose capabilities/worker health, exact-root Repository state and redacted Diffs, non-atomic multi-file FileEdit summaries, deterministic per-check verification snapshot downloads/receipt history, and a regenerable Code handoff with bounded review metadata. There is no direct HTTP Shell/argv/stdin or Job-mutation endpoint: only an authenticated Run execution may let its root Supervisor call the same Tool Gateway/Application service under Code/Local/Deliver/full-access, current-lease, Policy, no-network/no-credential, and process-startup gates. +CyberAgent Workbench exposes a Go-controlled local `api.v1` for durable SQLite state and resumable Run-event projections. Independent capabilities permit controlled Run/Session/Plan/approval operations, fixed-command proposal review, Provider diagnostics/routes/system credentials, operator price-snapshot import and listing, FileEdit propose/read-only recovery/review/apply, wake intent/foreground consumption, immutable operator verification, metadata-only snapshot receipts and their non-authorizing review, inert Skill installation, the schema-v116 Run-owned ordinary command runtime, schema-v117 Workspace Checkpoint timeline/restore/Fork operations, schema-v118 deliverable-child worktree/review/local-merge operations, schema-v119 source-bound real-browser UI evidence, and schema-v99 exact Docker Sandbox execution that is disabled by default. Read-only surfaces also expose capabilities/worker health, exact-root Repository state and redacted Diffs, non-atomic multi-file FileEdit summaries, deterministic per-check verification snapshot downloads/receipt history, and a regenerable Code handoff with bounded review metadata. There is no direct generic HTTP Shell/argv/stdin or Job-mutation endpoint. UI evidence start accepts only the complete reviewed bounded recipe/fixture/step contract, and the same Tool Gateway/Application service rechecks Code/Local/Deliver/full-access, current lease, Policy, no-external-network/no-credential, restricted-CDP, and process-startup gates. ## 启动 / Start @@ -392,6 +392,24 @@ or `npm_test` fails before materialization. This is honest host code execution, OS network-sandbox claim. Full lifecycle and threat-model details are in [Deliverable Multi-Agent Batches](batch-delivery.md). +## UI Evidence API + +Schema v119 的 UI evidence 读取使用 read bearer;启动和取消使用不同的 control +bearer,并且只在 ControlPlane 安装了 UI-evidence controller 时存在。Windows Desktop +默认总会安装只读 controller,因此关闭执行 capability 后仍能查看历史;只有同时启用 +Run execution、permission control、danger-full-access、restricted browser CDP 和 +`--enable-ui-evidence` 时才设置 `ui_evidence_control_enabled=true`。独立 +`cyberagent api serve` 不构造浏览器、Profile 或进程 authority,因此不提供这些路由。 + +Start body 是最多 512 KiB 的严格单一 JSON 对象;未知字段、重复字段、外部 URL、非 +literal-loopback origin、视频、非 fail-closed policy、未绑定 type input、网络客户端 +recipe 和不确定 fixture 都被拒绝。成功只返回 `202 Accepted` 与已持久化的 `not_run` +Attempt;实际运行在 service-owned 30 分钟 context 中进行,HTTP 断开不会遗留浏览器。 +取消必须提交 `{"confirm":true}`。读取 Attempt 返回 manifest、step receipts 和 artifact +metadata;原始 artifact 下载额外返回 `no-store`、ETag、精确 SHA-256 和 +`X-CyberAgent-Evidence-Untrusted: true`,不使用普通 JSON envelope。字段和完整模板见 +[Real-browser UI Evidence](ui-evidence.md)。 + ## Endpoints | Method | Path | Result / Filters | @@ -450,6 +468,11 @@ OS network-sandbox claim. Full lifecycle and threat-model details are in | `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/undo` | Explicitly confirmed undo of the current terminal mutation boundary | | `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/redo` | Explicitly confirmed redo available only after the matching Undo terminal state | | `POST` | `/api/v1/runs/{run_id}/workspace-checkpoints/fork` | Explicitly confirmed independent Git worktree/Workspace/Mission/Run/Session Fork; no authority inheritance | +| `GET` | `/api/v1/runs/{run_id}/ui-evidence?status={status}&limit={limit}` | Up to 100 source-bound attempts; `not_run` remains neutral and no result is inferred | +| `POST` | `/api/v1/runs/{run_id}/ui-evidence` | Control-bearer asynchronous real-browser attempt from one strict reviewed manifest request; returns `202 not_run` | +| `GET` | `/api/v1/ui-evidence/{attempt_id}` | Exact immutable manifest, ordered step receipts, diagnostics/cleanup, and artifact metadata | +| `POST` | `/api/v1/ui-evidence/{attempt_id}/cancel` | Control-bearer explicit `{confirm:true}` cancellation and owned-resource cleanup wait | +| `GET` | `/api/v1/ui-evidence/{attempt_id}/artifacts/{artifact_id}` | Hash-verified raw untrusted bytes with no-store, MIME, length, ETag, and SHA-256 headers | | `GET` | `/api/v1/runs/{run_id}/external-skills` | Bounded external-Skill provenance and root/Specialist delivery counts; no content, paths, digests, or private identities | | `GET` | `/api/v1/runs/{run_id}/activity` | Chronological public model updates plus allowlisted Harness facts; redacted/bounded, no private reasoning, raw payload, Prompt, Tool arguments, or Tool output | | `GET` | `/api/v1/runs/{run_id}/events` | Ordered Run events; pagination | diff --git a/docs/openapi.json b/docs/openapi.json index 94fe97dd..929a38bc 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -62,6 +62,10 @@ "name": "Artifacts", "description": "Content-free Artifact descriptors." }, + { + "name": "UI Evidence", + "description": "Source-bound real-browser manifests, fail-closed receipts, and untrusted captured artifacts." + }, { "name": "Sandbox", "description": "Docker Sandbox readiness, admission, bounded execution, cancellation, and status." @@ -9027,6 +9031,219 @@ "x-cyberagent-read-only": true } }, + "/api/v1/runs/{run_id}/ui-evidence": { + "get": { + "operationId": "listRunUIEvidence", + "summary": "List source-bound UI evidence attempts", + "description": "Returns immutable manifests and fail-closed outcomes. not_run is unknown and never a passing result.", + "tags": [ + "UI Evidence" + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Optional exact attempt status", + "schema": { + "enum": [ + "not_run", + "running", + "passed", + "failed", + "cancelled", + "timed_out", + "interrupted" + ], + "minLength": 1, + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum attempts", + "schema": { + "default": 100, + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/UIEvidenceAttempt" + }, + "type": "array" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + }, + "post": { + "operationId": "startRunUIEvidence", + "summary": "Start real-browser UI evidence", + "description": "Persists not_run before asynchronously executing the exact source-bound build/start recipe in a Run-owned process tree, a temporary browser Profile, and a loopback-only reviewed Safe Web runtime.", + "tags": [ + "UI Evidence" + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "description": "Run identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Control request accepted or idempotently replayed", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/UIEvidenceAttempt" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/uiEvidenceStartView" + } + } + } + }, + "x-cyberagent-read-only": false + } + }, "/api/v1/runs/{run_id}/verification-evidence": { "get": { "operationId": "listRunVerificationEvidence", @@ -12782,19 +12999,19 @@ "x-cyberagent-read-only": false } }, - "/api/v1/work-items/{work_item_id}": { + "/api/v1/ui-evidence/{attempt_id}": { "get": { - "operationId": "getWorkItem", - "summary": "Inspect a WorkItem", - "description": "Returns one structured WorkItem.", + "operationId": "getUIEvidence", + "summary": "Inspect one UI evidence bundle", + "description": "Returns the exact manifest, step receipts, and artifact metadata without binary content.", "tags": [ - "Memory" + "UI Evidence" ], "parameters": [ { - "name": "work_item_id", + "name": "attempt_id", "in": "path", - "description": "WorkItem identity", + "description": "UI evidence attempt identity", "required": true, "schema": { "maxLength": 256, @@ -12813,7 +13030,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkItemView" + "$ref": "#/components/schemas/UIEvidenceBundle" }, "request_id": { "maxLength": 256, @@ -12860,71 +13077,60 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces": { + "/api/v1/ui-evidence/{attempt_id}/artifacts/{artifact_id}": { "get": { - "operationId": "listWorkspaces", - "summary": "List Workspaces", - "description": "Returns registered Workspace ids and names without local root paths.", + "operationId": "downloadUIEvidenceArtifact", + "summary": "Download one verified UI evidence artifact", + "description": "Returns exact hash-verified untrusted bytes with MIME, ETag, source digest, and no-store headers.", "tags": [ - "Workspaces" + "UI Evidence" ], "parameters": [ { - "name": "limit", - "in": "query", - "description": "Page size from 1 to 100; defaults to 50", + "name": "attempt_id", + "in": "path", + "description": "UI evidence attempt identity", + "required": true, "schema": { - "default": 50, - "maximum": 100, - "minimum": 1, - "type": "integer" + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" } }, { - "name": "cursor", - "in": "query", - "description": "Opaque cursor bound to this route and exact filter set", + "name": "artifact_id", + "in": "path", + "description": "Artifact identity", + "required": true, "schema": { - "maxLength": 512, + "maxLength": 256, "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } } ], "responses": { "200": { - "description": "Successful read", + "description": "Hash-verified untrusted evidence bytes", "content": { "application/json": { "schema": { - "additionalProperties": false, - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/WorkspaceView" - }, - "type": "array" - }, - "page": { - "$ref": "#/components/schemas/Page" - }, - "request_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" - }, - "version": { - "const": "api.v1", - "type": "string" - } - }, - "required": [ - "version", - "request_id", - "data", - "page" - ], - "type": "object" + "format": "binary", + "type": "string" + } + }, + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + }, + "image/png": { + "schema": { + "format": "binary", + "type": "string" } } } @@ -12938,6 +13144,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, @@ -12951,19 +13160,19 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/explore": { - "get": { - "operationId": "exploreWorkspace", - "summary": "Inspect a bounded Workspace entry", - "description": "Lists one directory level or returns a bounded redacted UTF-8 file preview. Go resolves the registered Workspace root, rejects traversal and symbolic links, omits internal staging files, and marks all content as non-authorizing evidence. Local root paths are never returned.", + "/api/v1/ui-evidence/{attempt_id}/cancel": { + "post": { + "operationId": "cancelUIEvidence", + "summary": "Cancel one UI evidence attempt", + "description": "Cancels the service-owned context and waits for bounded process-tree, Profile, network, and port cleanup.", "tags": [ - "Workspaces" + "UI Evidence" ], "parameters": [ { - "name": "workspace_id", + "name": "attempt_id", "in": "path", - "description": "Workspace identity", + "description": "UI evidence attempt identity", "required": true, "schema": { "maxLength": 256, @@ -12971,28 +13180,18 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } - }, - { - "name": "path", - "in": "query", - "description": "Canonical Workspace-relative path returned by a previous explorer response; defaults to the root", - "schema": { - "default": ".", - "maxLength": 512, - "type": "string" - } } ], "responses": { "200": { - "description": "Successful read", + "description": "Control request accepted or idempotently replayed", "content": { "application/json": { "schema": { "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkspaceExplorerView" + "$ref": "#/components/schemas/UIEvidenceAttempt" }, "request_id": { "maxLength": 256, @@ -13026,9 +13225,21 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/ResourceExhausted" }, @@ -13036,22 +13247,37 @@ "$ref": "#/components/responses/InternalError" } }, - "x-cyberagent-read-only": true + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/uiEvidenceCancelView" + } + } + } + }, + "x-cyberagent-read-only": false } }, - "/api/v1/workspaces/{workspace_id}/repository-commit-comparison": { + "/api/v1/work-items/{work_item_id}": { "get": { - "operationId": "compareWorkspaceRepositoryCommits", - "summary": "Compare two exact commit trees", - "description": "Compares two exact local commit objects through bounded tree metadata. The commits need not have an ancestor relationship. The response contains no author, body, blob, patch, remote, host path, rename inference, mutation, process, hook, network, or authority.", + "operationId": "getWorkItem", + "summary": "Inspect a WorkItem", + "description": "Returns one structured WorkItem.", "tags": [ - "Workspaces" + "Memory" ], "parameters": [ { - "name": "workspace_id", + "name": "work_item_id", "in": "path", - "description": "Workspace identity", + "description": "WorkItem identity", "required": true, "schema": { "maxLength": 256, @@ -13059,30 +13285,6 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } - }, - { - "name": "base_object_id", - "in": "query", - "description": "Exact lowercase base commit object identity", - "required": true, - "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", - "type": "string" - } - }, - { - "name": "head_object_id", - "in": "query", - "description": "Exact lowercase head commit object identity", - "required": true, - "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", - "type": "string" - } } ], "responses": { @@ -13094,7 +13296,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitComparisonView" + "$ref": "#/components/schemas/WorkItemView" }, "request_id": { "maxLength": 256, @@ -13141,36 +13343,33 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}": { + "/api/v1/workspaces": { "get": { - "operationId": "getWorkspaceRepositoryCommit", - "summary": "Inspect exact commit changed-file metadata", - "description": "Compares one exact local commit object with its first parent and returns only bounded path, change-kind, file-kind, and content/mode-change metadata. It returns no author, body, blob, patch, remote, or host path and performs no checkout, ref update, process, hook, or network operation.", + "operationId": "listWorkspaces", + "summary": "List Workspaces", + "description": "Returns registered Workspace ids and names without local root paths.", "tags": [ "Workspaces" ], "parameters": [ { - "name": "workspace_id", - "in": "path", - "description": "Workspace identity", - "required": true, + "name": "limit", + "in": "query", + "description": "Page size from 1 to 100; defaults to 50", "schema": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", - "type": "string" + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" } }, { - "name": "object_id", - "in": "path", - "description": "Exact lowercase forty-character Git commit object identity", - "required": true, + "name": "cursor", + "in": "query", + "description": "Opaque cursor bound to this route and exact filter set", "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", + "maxLength": 512, + "minLength": 1, "type": "string" } } @@ -13184,7 +13383,13 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitDetailView" + "items": { + "$ref": "#/components/schemas/WorkspaceView" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/Page" }, "request_id": { "maxLength": 256, @@ -13199,7 +13404,8 @@ "required": [ "version", "request_id", - "data" + "data", + "page" ], "type": "object" } @@ -13215,9 +13421,6 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, @@ -13231,11 +13434,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}/file-preview": { + "/api/v1/workspaces/{workspace_id}/explore": { "get": { - "operationId": "getWorkspaceRepositoryCommitFilePreview", - "summary": "Preview one redacted file from an exact commit", - "description": "Returns a bounded secret-redacted UTF-8 projection of one regular file from one exact local commit object. Links, binary data, oversized content, missing objects, raw blobs, host roots, remotes, checkout, ref mutation, processes, hooks, and network access are refused or excluded; returned text remains non-authorizing evidence.", + "operationId": "exploreWorkspace", + "summary": "Inspect a bounded Workspace entry", + "description": "Lists one directory level or returns a bounded redacted UTF-8 file preview. Go resolves the registered Workspace root, rejects traversal and symbolic links, omits internal staging files, and marks all content as non-authorizing evidence. Local root paths are never returned.", "tags": [ "Workspaces" ], @@ -13252,26 +13455,13 @@ "type": "string" } }, - { - "name": "object_id", - "in": "path", - "description": "Exact lowercase forty-character Git commit object identity", - "required": true, - "schema": { - "maxLength": 40, - "minLength": 40, - "pattern": "^[0-9a-f]{40}$", - "type": "string" - } - }, { "name": "path", "in": "query", - "description": "Canonical repository-relative file path", - "required": true, + "description": "Canonical Workspace-relative path returned by a previous explorer response; defaults to the root", "schema": { + "default": ".", "maxLength": 512, - "minLength": 1, "type": "string" } } @@ -13285,7 +13475,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryCommitFilePreviewView" + "$ref": "#/components/schemas/WorkspaceExplorerView" }, "request_id": { "maxLength": 256, @@ -13332,11 +13522,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-diff": { + "/api/v1/workspaces/{workspace_id}/repository-commit-comparison": { "get": { - "operationId": "getWorkspaceRepositoryDiff", - "summary": "Inspect bounded repository patches", - "description": "Returns secret-redacted bounded UTF-8 patches for a Git repository rooted exactly at the registered Workspace. Go follows no links, discovers no parent repository, starts no process, executes no hooks, uses no network, and grants no mutation authority.", + "operationId": "compareWorkspaceRepositoryCommits", + "summary": "Compare two exact commit trees", + "description": "Compares two exact local commit objects through bounded tree metadata. The commits need not have an ancestor relationship. The response contains no author, body, blob, patch, remote, host path, rename inference, mutation, process, hook, network, or authority.", "tags": [ "Workspaces" ], @@ -13352,6 +13542,30 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } + }, + { + "name": "base_object_id", + "in": "query", + "description": "Exact lowercase base commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } + }, + { + "name": "head_object_id", + "in": "query", + "description": "Exact lowercase head commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } } ], "responses": { @@ -13363,7 +13577,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryDiffView" + "$ref": "#/components/schemas/RepositoryCommitComparisonView" }, "request_id": { "maxLength": 256, @@ -13410,11 +13624,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-file-history": { + "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}": { "get": { - "operationId": "getWorkspaceRepositoryFileHistory", - "summary": "Inspect bounded history for one exact file path", - "description": "Follows at most 512 first-parent commits and returns at most fifty metadata-only changes for one canonical repository-relative path. It does not infer renames or expose blob/patch content, identities, remotes, host roots, checkout, reference mutation, processes, hooks, network, or authority.", + "operationId": "getWorkspaceRepositoryCommit", + "summary": "Inspect exact commit changed-file metadata", + "description": "Compares one exact local commit object with its first parent and returns only bounded path, change-kind, file-kind, and content/mode-change metadata. It returns no author, body, blob, patch, remote, or host path and performs no checkout, ref update, process, hook, or network operation.", "tags": [ "Workspaces" ], @@ -13432,13 +13646,14 @@ } }, { - "name": "path", - "in": "query", - "description": "Canonical repository-relative file path", + "name": "object_id", + "in": "path", + "description": "Exact lowercase forty-character Git commit object identity", "required": true, "schema": { - "maxLength": 512, - "minLength": 1, + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", "type": "string" } } @@ -13452,7 +13667,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryFileHistoryView" + "$ref": "#/components/schemas/RepositoryCommitDetailView" }, "request_id": { "maxLength": 256, @@ -13499,11 +13714,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-history": { + "/api/v1/workspaces/{workspace_id}/repository-commits/{object_id}/file-preview": { "get": { - "operationId": "getWorkspaceRepositoryHistory", - "summary": "Inspect bounded local repository history", - "description": "Returns at most fifty first-parent commit subjects and sixty-four local branch heads for a Git repository rooted exactly at the registered Workspace. Subjects are secret-redacted; author identities, commit bodies, remotes, host paths, processes, hooks, and network access are excluded.", + "operationId": "getWorkspaceRepositoryCommitFilePreview", + "summary": "Preview one redacted file from an exact commit", + "description": "Returns a bounded secret-redacted UTF-8 projection of one regular file from one exact local commit object. Links, binary data, oversized content, missing objects, raw blobs, host roots, remotes, checkout, ref mutation, processes, hooks, and network access are refused or excluded; returned text remains non-authorizing evidence.", "tags": [ "Workspaces" ], @@ -13519,19 +13734,42 @@ "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", "type": "string" } - } - ], - "responses": { - "200": { - "description": "Successful read", - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "data": { - "$ref": "#/components/schemas/RepositoryHistoryView" - }, + }, + { + "name": "object_id", + "in": "path", + "description": "Exact lowercase forty-character Git commit object identity", + "required": true, + "schema": { + "maxLength": 40, + "minLength": 40, + "pattern": "^[0-9a-f]{40}$", + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "Canonical repository-relative file path", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryCommitFilePreviewView" + }, "request_id": { "maxLength": 256, "minLength": 1, @@ -13577,11 +13815,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/repository-state": { + "/api/v1/workspaces/{workspace_id}/repository-diff": { "get": { - "operationId": "getWorkspaceRepositoryState", - "summary": "Inspect read-only repository state", - "description": "Returns a bounded status-only projection for a Git repository rooted exactly at the registered Workspace. Go does not discover parent repositories, return file content, patches, remotes or local root paths, start a process, execute hooks, or use the network.", + "operationId": "getWorkspaceRepositoryDiff", + "summary": "Inspect bounded repository patches", + "description": "Returns secret-redacted bounded UTF-8 patches for a Git repository rooted exactly at the registered Workspace. Go follows no links, discovers no parent repository, starts no process, executes no hooks, uses no network, and grants no mutation authority.", "tags": [ "Workspaces" ], @@ -13608,7 +13846,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/RepositoryStateView" + "$ref": "#/components/schemas/RepositoryDiffView" }, "request_id": { "maxLength": 256, @@ -13655,11 +13893,11 @@ "x-cyberagent-read-only": true } }, - "/api/v1/workspaces/{workspace_id}/search": { + "/api/v1/workspaces/{workspace_id}/repository-file-history": { "get": { - "operationId": "searchWorkspace", - "summary": "Search bounded Workspace evidence", - "description": "Performs one deterministic bounded scan over redacted UTF-8 Explorer projections. It follows no links, starts no indexer, returns canonical relative references and snippets only, and marks every result as non-authorizing evidence.", + "operationId": "getWorkspaceRepositoryFileHistory", + "summary": "Inspect bounded history for one exact file path", + "description": "Follows at most 512 first-parent commits and returns at most fifty metadata-only changes for one canonical repository-relative path. It does not infer renames or expose blob/patch content, identities, remotes, host roots, checkout, reference mutation, processes, hooks, network, or authority.", "tags": [ "Workspaces" ], @@ -13677,12 +13915,12 @@ } }, { - "name": "query", + "name": "path", "in": "query", - "description": "Normalized case-insensitive filename or redacted text query", + "description": "Canonical repository-relative file path", "required": true, "schema": { - "maxLength": 128, + "maxLength": 512, "minLength": 1, "type": "string" } @@ -13697,7 +13935,7 @@ "additionalProperties": false, "properties": { "data": { - "$ref": "#/components/schemas/WorkspaceSearchView" + "$ref": "#/components/schemas/RepositoryFileHistoryView" }, "request_id": { "maxLength": 256, @@ -13743,83 +13981,328 @@ }, "x-cyberagent-read-only": true } - } - }, - "components": { - "schemas": { - "APIError": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" + }, + "/api/v1/workspaces/{workspace_id}/repository-history": { + "get": { + "operationId": "getWorkspaceRepositoryHistory", + "summary": "Inspect bounded local repository history", + "description": "Returns at most fifty first-parent commit subjects and sixty-four local branch heads for a Git repository rooted exactly at the registered Workspace. Subjects are secret-redacted; author identities, commit bodies, remotes, host paths, processes, hooks, and network access are excluded.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } } - }, - "required": [ - "code", - "message" ], - "type": "object" - }, - "ActiveCallInfo": { - "additionalProperties": false, - "properties": { - "attempt_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryHistoryView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } }, - "cancel_requested": { - "type": "boolean" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "max_attempts": { - "format": "int32", - "type": "integer" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "model": { - "type": "string" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "model_attempt": { - "format": "int32", - "type": "integer" + "404": { + "$ref": "#/components/responses/NotFound" }, - "protocol_repair": { - "format": "int32", - "type": "integer" + "414": { + "$ref": "#/components/responses/RequestTooLarge" }, - "provider": { - "type": "string" + "429": { + "$ref": "#/components/responses/ResourceExhausted" }, - "run_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/repository-state": { + "get": { + "operationId": "getWorkspaceRepositoryState", + "summary": "Inspect read-only repository state", + "description": "Returns a bounded status-only projection for a Git repository rooted exactly at the registered Workspace. Go does not discover parent repositories, return file content, patches, remotes or local root paths, start a process, execute hooks, or use the network.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/RepositoryStateView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } }, - "session_id": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "started_at": { - "format": "date-time", - "type": "string" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "stream_bytes": { - "format": "int32", - "type": "integer" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "stream_chunks": { - "format": "int32", - "type": "integer" + "404": { + "$ref": "#/components/responses/NotFound" }, - "tool_round": { - "format": "int32", - "type": "integer" + "414": { + "$ref": "#/components/responses/RequestTooLarge" }, - "transport_attempt": { - "format": "int32", + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/workspaces/{workspace_id}/search": { + "get": { + "operationId": "searchWorkspace", + "summary": "Search bounded Workspace evidence", + "description": "Performs one deterministic bounded scan over redacted UTF-8 Explorer projections. It follows no links, starts no indexer, returns canonical relative references and snippets only, and marks every result as non-authorizing evidence.", + "tags": [ + "Workspaces" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "description": "Workspace identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "description": "Normalized case-insensitive filename or redacted text query", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/WorkspaceSearchView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + } + }, + "components": { + "schemas": { + "APIError": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "ActiveCallInfo": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "cancel_requested": { + "type": "boolean" + }, + "max_attempts": { + "format": "int32", + "type": "integer" + }, + "model": { + "type": "string" + }, + "model_attempt": { + "format": "int32", + "type": "integer" + }, + "protocol_repair": { + "format": "int32", + "type": "integer" + }, + "provider": { + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "stream_bytes": { + "format": "int32", + "type": "integer" + }, + "stream_chunks": { + "format": "int32", + "type": "integer" + }, + "tool_round": { + "format": "int32", + "type": "integer" + }, + "transport_attempt": { + "format": "int32", "type": "integer" } }, @@ -17211,81 +17694,186 @@ ], "type": "object" }, - "CommandSpec": { + "CommandRuntimeEnvironment": { "additionalProperties": false, "properties": { - "arguments": { - "items": { - "type": "string" - }, - "type": "array" - }, - "executable": { + "name": { "type": "string" }, - "working_directory": { + "value": { "type": "string" } }, "required": [ - "executable", - "working_directory" + "name", + "value" ], "type": "object" }, - "Conflict": { + "CommandRuntimeOutputPolicy": { "additionalProperties": false, "properties": { - "current_sha256": { - "type": "string" - }, - "expected_sha256": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "path": { - "type": "string" - }, - "reason": { - "type": "string" + "artifact_bytes": { + "format": "int32", + "type": "integer" }, - "target_sha256": { - "type": "string" + "inline_bytes": { + "format": "int32", + "type": "integer" } }, "required": [ - "kind", - "reason" + "inline_bytes", + "artifact_bytes" ], "type": "object" }, - "ContextMemoryExport": { + "CommandRuntimeSpec": { "additionalProperties": false, "properties": { - "capability_grant": { + "arguments": { + "items": { + "type": "string" + }, + "type": "array" + }, + "close_initial_stdin": { "type": "boolean" }, - "exported_at": { - "format": "date-time", + "credentials": { "type": "string" }, - "items": { + "environment": { "items": { - "$ref": "#/components/schemas/Memory" + "$ref": "#/components/schemas/CommandRuntimeEnvironment" }, "type": "array" }, - "protocol_version": { + "executable": { "type": "string" }, - "scope": { + "initial_stdin": { "type": "string" }, - "scope_id": { - "maxLength": 256, - "minLength": 1, + "network": { + "type": "string" + }, + "output": { + "$ref": "#/components/schemas/CommandRuntimeOutputPolicy" + }, + "profile": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "script": { + "type": "string" + }, + "stdin_policy": { + "type": "string" + }, + "timeout_milliseconds": { + "format": "int64", + "type": "integer" + }, + "version": { + "type": "string" + }, + "working_directory": { + "type": "string" + } + }, + "required": [ + "version", + "profile", + "working_directory", + "environment", + "stdin_policy", + "close_initial_stdin", + "timeout_milliseconds", + "output", + "network", + "credentials", + "purpose" + ], + "type": "object" + }, + "CommandSpec": { + "additionalProperties": false, + "properties": { + "arguments": { + "items": { + "type": "string" + }, + "type": "array" + }, + "executable": { + "type": "string" + }, + "working_directory": { + "type": "string" + } + }, + "required": [ + "executable", + "working_directory" + ], + "type": "object" + }, + "Conflict": { + "additionalProperties": false, + "properties": { + "current_sha256": { + "type": "string" + }, + "expected_sha256": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "target_sha256": { + "type": "string" + } + }, + "required": [ + "kind", + "reason" + ], + "type": "object" + }, + "ContextMemoryExport": { + "additionalProperties": false, + "properties": { + "capability_grant": { + "type": "boolean" + }, + "exported_at": { + "format": "date-time", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/Memory" + }, + "type": "array" + }, + "protocol_version": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "scope_id": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, @@ -21289,6 +21877,9 @@ "$ref": "#/components/schemas/OutputSpec" }, "protocol_version": { + "enum": [ + "ui-evidence.v1" + ], "type": "string" }, "resources": { @@ -26920,6 +27511,9 @@ "skill_installation_enabled": { "type": "boolean" }, + "ui_evidence_control_enabled": { + "type": "boolean" + }, "verification_evidence_enabled": { "type": "boolean" }, @@ -26964,6 +27558,7 @@ "workspace_checkpoint_control_enabled", "batch_delivery_control_enabled", "batch_delivery_host_validation_enabled", + "ui_evidence_control_enabled", "process_execution_enabled", "shell_execution_enabled", "docker_execution_enabled", @@ -27753,175 +28348,1136 @@ "minLength": 1, "type": "string" }, - "calls": { + "calls": { + "items": { + "$ref": "#/components/schemas/SupervisorToolCallView" + }, + "type": "array" + }, + "completed_at": { + "format": "date-time", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "model_attempt": { + "format": "int32", + "minimum": 1, + "type": "integer" + }, + "round": { + "format": "int32", + "minimum": 1, + "type": "integer" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "turn": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "run_id", + "turn", + "attempt_id", + "round", + "model_attempt", + "calls", + "created_at" + ], + "type": "object" + }, + "ToolUsageView": { + "additionalProperties": false, + "properties": { + "consumed": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "exhausted_at": { + "format": "date-time", + "type": "string" + }, + "limit": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "remaining": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "consumed", + "limit", + "remaining" + ], + "type": "object" + }, + "Transaction": { + "additionalProperties": false, + "properties": { + "after_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "before_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "completed_at": { + "format": "date-time", + "type": "string" + }, + "conflict_json": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "error_code": { + "type": "string" + }, + "expected_current_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "kind": { + "type": "string" + }, + "operation_key_digest": { + "type": "string" + }, + "protocol_version": { + "type": "string" + }, + "recovery_level": { + "type": "string" + }, + "request_fingerprint": { + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string" + }, + "target_checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "trigger_receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "protocol_version", + "operation_key_digest", + "request_fingerprint", + "run_id", + "workspace_id", + "kind", + "trigger_receipt_id", + "before_checkpoint_id", + "status", + "recovery_level", + "created_at", + "updated_at" + ], + "type": "object" + }, + "UIEvidenceArtifactMetadata": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "bytes": { + "format": "int64", + "type": "integer" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "height": { + "format": "int32", + "type": "integer" + }, + "id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "kind": { + "enum": [ + "screenshot", + "dom", + "accessibility", + "console", + "network", + "performance" + ], + "type": "string" + }, + "mime": { + "type": "string" + }, + "protocol_version": { + "enum": [ + "ui-evidence-artifact.v1" + ], + "type": "string" + }, + "redacted": { + "type": "boolean" + }, + "retention_policy": { + "enum": [ + "run_history" + ], + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "sha256": { + "type": "string" + }, + "source_commit": { + "type": "string" + }, + "step_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "untrusted": { + "type": "boolean" + }, + "viewport": { + "$ref": "#/components/schemas/UIEvidenceViewport" + }, + "width": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "protocol_version", + "id", + "attempt_id", + "run_id", + "step_id", + "kind", + "mime", + "sha256", + "bytes", + "viewport", + "source_commit", + "retention_policy", + "redacted", + "untrusted", + "created_at", + "fingerprint" + ], + "type": "object" + }, + "UIEvidenceAttempt": { + "additionalProperties": false, + "properties": { + "artifact_bytes": { + "format": "int64", + "type": "integer" + }, + "artifact_count": { + "format": "int32", + "type": "integer" + }, + "cleanup": { + "$ref": "#/components/schemas/UIEvidenceCleanupReceipt" + }, + "completed_at": { + "format": "date-time", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "diagnostics": { + "$ref": "#/components/schemas/UIEvidenceDiagnosticsSummary" + }, + "failure_code": { + "type": "string" + }, + "failure_message": { + "type": "string" + }, + "failure_stage": { + "enum": [ + "none", + "build", + "launch", + "readiness", + "navigation", + "selector", + "assertion", + "console", + "network", + "capture", + "cleanup" + ], + "type": "string" + }, + "manifest": { + "$ref": "#/components/schemas/UIEvidenceManifest" + }, + "operation_digest": { + "type": "string" + }, + "protocol_version": { + "enum": [ + "ui-evidence-attempt.v1" + ], + "type": "string" + }, + "request_fingerprint": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "status": { + "enum": [ + "not_run", + "running", + "passed", + "failed", + "cancelled", + "timed_out", + "interrupted" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "protocol_version", + "manifest", + "operation_digest", + "request_fingerprint", + "status", + "failure_stage", + "diagnostics", + "cleanup", + "artifact_count", + "artifact_bytes", + "version", + "created_at", + "updated_at" + ], + "type": "object" + }, + "UIEvidenceBrowserIdentity": { + "additionalProperties": false, + "properties": { + "driver_protocol": { + "enum": [ + "restricted-cdp-ui-evidence.v1" + ], + "type": "string" + }, + "executable_sha256": { + "type": "string" + }, + "headless": { + "type": "boolean" + }, + "product": { + "enum": [ + "chrome", + "edge" + ], + "type": "string" + }, + "temporary_profile": { + "type": "boolean" + }, + "version": { + "type": "string" + } + }, + "required": [ + "product", + "version", + "executable_sha256", + "driver_protocol", + "headless", + "temporary_profile" + ], + "type": "object" + }, + "UIEvidenceBrowserSelection": { + "additionalProperties": false, + "properties": { + "channel": { + "enum": [ + "stable", + "beta", + "dev", + "canary" + ], + "type": "string" + }, + "product": { + "enum": [ + "chrome", + "edge" + ], + "type": "string" + } + }, + "required": [ + "product", + "channel" + ], + "type": "object" + }, + "UIEvidenceBundle": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/UIEvidenceArtifactMetadata" + }, + "type": "array" + }, + "attempt": { + "$ref": "#/components/schemas/UIEvidenceAttempt" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/UIEvidenceStepReceipt" + }, + "type": "array" + } + }, + "required": [ + "attempt", + "steps", + "artifacts" + ], + "type": "object" + }, + "UIEvidenceCapturePolicy": { + "additionalProperties": false, + "properties": { + "accessibility": { + "type": "boolean" + }, + "console": { + "type": "boolean" + }, + "dom": { + "type": "boolean" + }, + "mask_selectors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "network": { + "type": "boolean" + }, + "performance": { + "type": "boolean" + }, + "screenshot": { + "type": "boolean" + }, + "video": { + "type": "boolean" + } + }, + "required": [ + "screenshot", + "dom", + "accessibility", + "console", + "network", + "performance", + "video", + "mask_selectors" + ], + "type": "object" + }, + "UIEvidenceCleanupReceipt": { + "additionalProperties": false, + "properties": { + "application_tree_reaped": { + "type": "boolean" + }, + "browser_tree_reaped": { + "type": "boolean" + }, + "network_released": { + "type": "boolean" + }, + "port_released": { + "type": "boolean" + }, + "profile_removed": { + "type": "boolean" + } + }, + "required": [ + "browser_tree_reaped", + "application_tree_reaped", + "profile_removed", + "network_released", + "port_released" + ], + "type": "object" + }, + "UIEvidenceCommandRecipe": { + "additionalProperties": false, + "properties": { + "canonical_argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "credentials": { + "enum": [ + "none" + ], + "type": "string" + }, + "environment_names": { + "items": { + "type": "string" + }, + "type": "array" + }, + "environment_sha256": { + "type": "string" + }, + "executable_name": { + "type": "string" + }, + "executable_path_sha256": { + "type": "string" + }, + "executable_sha256": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "network": { + "enum": [ + "disabled" + ], + "type": "string" + }, + "profile": { + "enum": [ + "powershell", + "bash", + "process" + ], + "type": "string" + }, + "protocol_version": { + "enum": [ + "command-runtime.v2" + ], + "type": "string" + }, + "purpose": { + "type": "string" + }, + "timeout_milliseconds": { + "format": "int64", + "type": "integer" + }, + "working_directory": { + "type": "string" + } + }, + "required": [ + "protocol_version", + "profile", + "executable_name", + "executable_path_sha256", + "executable_sha256", + "canonical_argv", + "working_directory", + "environment_names", + "environment_sha256", + "timeout_milliseconds", + "network", + "credentials", + "purpose", + "fingerprint" + ], + "type": "object" + }, + "UIEvidenceDiagnosticsSummary": { + "additionalProperties": false, + "properties": { + "allowed_requests": { + "format": "int32", + "type": "integer" + }, + "blocked_requests": { + "format": "int32", + "type": "integer" + }, + "console_errors": { + "format": "int32", + "type": "integer" + }, + "console_warnings": { + "format": "int32", + "type": "integer" + }, + "failed_requests": { + "format": "int32", + "type": "integer" + }, + "http_failures": { + "format": "int32", + "type": "integer" + }, + "page_errors": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "console_warnings", + "console_errors", + "page_errors", + "failed_requests", + "http_failures", + "allowed_requests", + "blocked_requests" + ], + "type": "object" + }, + "UIEvidenceEnvironment": { + "additionalProperties": false, + "properties": { + "locale": { + "type": "string" + }, + "reduced_motion": { + "type": "boolean" + }, + "theme": { + "enum": [ + "light", + "dark" + ], + "type": "string" + }, + "viewport": { + "$ref": "#/components/schemas/UIEvidenceViewport" + } + }, + "required": [ + "viewport", + "locale", + "theme", + "reduced_motion" + ], + "type": "object" + }, + "UIEvidenceEvidenceAuthority": { + "additionalProperties": false, + "properties": { + "credential_access": { + "type": "boolean" + }, + "network_access": { + "type": "boolean" + }, + "personal_profile": { + "type": "boolean" + }, + "process_start": { + "type": "boolean" + }, + "request_mutation": { + "type": "boolean" + }, + "verification_pass": { + "type": "boolean" + } + }, + "required": [ + "process_start", + "network_access", + "credential_access", + "personal_profile", + "request_mutation", + "verification_pass" + ], + "type": "object" + }, + "UIEvidenceFailurePolicy": { + "additionalProperties": false, + "properties": { + "fail_on_console_error": { + "type": "boolean" + }, + "fail_on_http_status": { + "type": "boolean" + }, + "fail_on_page_error": { + "type": "boolean" + }, + "fail_on_request_error": { + "type": "boolean" + } + }, + "required": [ + "fail_on_console_error", + "fail_on_page_error", + "fail_on_request_error", + "fail_on_http_status" + ], + "type": "object" + }, + "UIEvidenceFixture": { + "additionalProperties": false, + "properties": { + "data_sha256": { + "type": "string" + }, + "deterministic": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "page_state": { + "type": "string" + }, + "seed": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + } + }, + "required": [ + "name", + "seed", + "page_state", + "data_sha256", + "deterministic", + "synthetic" + ], + "type": "object" + }, + "UIEvidenceManifest": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "authority": { + "$ref": "#/components/schemas/UIEvidenceEvidenceAuthority" + }, + "browser": { + "$ref": "#/components/schemas/UIEvidenceBrowserIdentity" + }, + "build": { + "$ref": "#/components/schemas/UIEvidenceCommandRecipe" + }, + "capture": { + "$ref": "#/components/schemas/UIEvidenceCapturePolicy" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "environment": { + "$ref": "#/components/schemas/UIEvidenceEnvironment" + }, + "failure_policy": { + "$ref": "#/components/schemas/UIEvidenceFailurePolicy" + }, + "fingerprint": { + "type": "string" + }, + "fixture": { + "$ref": "#/components/schemas/UIEvidenceFixture" + }, + "mission_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "protocol_version": { + "enum": [ + "ui-evidence.v1" + ], + "type": "string" + }, + "readiness": { + "$ref": "#/components/schemas/UIEvidenceReadiness" + }, + "route": { + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/UIEvidenceSourceBinding" + }, + "start": { + "$ref": "#/components/schemas/UIEvidenceCommandRecipe" + }, + "steps": { "items": { - "$ref": "#/components/schemas/SupervisorToolCallView" + "$ref": "#/components/schemas/UIEvidenceStep" }, "type": "array" }, - "completed_at": { - "format": "date-time", - "type": "string" - }, - "created_at": { - "format": "date-time", + "url": { "type": "string" }, - "model_attempt": { - "format": "int32", - "minimum": 1, - "type": "integer" - }, - "round": { - "format": "int32", - "minimum": 1, - "type": "integer" - }, - "run_id": { + "workspace_id": { "maxLength": 256, "minLength": 1, "type": "string" - }, - "turn": { - "format": "int32", - "minimum": 1, - "type": "integer" } }, "required": [ - "run_id", - "turn", + "protocol_version", "attempt_id", - "round", - "model_attempt", - "calls", - "created_at" + "run_id", + "mission_id", + "session_id", + "workspace_id", + "source", + "start", + "readiness", + "browser", + "url", + "route", + "environment", + "fixture", + "steps", + "capture", + "failure_policy", + "authority", + "created_at", + "fingerprint" ], "type": "object" }, - "ToolUsageView": { + "UIEvidenceReadiness": { "additionalProperties": false, "properties": { - "consumed": { + "expected_status": { + "items": { + "format": "int32", + "type": "integer" + }, + "type": "array" + }, + "interval_milliseconds": { "format": "int64", - "minimum": 0, "type": "integer" }, - "exhausted_at": { - "format": "date-time", + "method": { "type": "string" }, - "limit": { + "timeout_milliseconds": { "format": "int64", - "minimum": 0, "type": "integer" }, - "remaining": { - "format": "int64", - "type": "integer" + "url": { + "type": "string" } }, "required": [ - "consumed", - "limit", - "remaining" + "url", + "method", + "expected_status", + "timeout_milliseconds", + "interval_milliseconds" ], "type": "object" }, - "Transaction": { + "UIEvidenceRuntimeStep": { "additionalProperties": false, "properties": { - "after_checkpoint_id": { - "maxLength": 256, - "minLength": 1, + "input": { "type": "string" }, - "before_checkpoint_id": { - "maxLength": 256, - "minLength": 1, + "step": { + "$ref": "#/components/schemas/UIEvidenceStep" + } + }, + "required": [ + "step" + ], + "type": "object" + }, + "UIEvidenceSourceBinding": { + "additionalProperties": false, + "properties": { + "branch": { "type": "string" }, - "completed_at": { - "format": "date-time", + "commit": { "type": "string" }, - "conflict_json": { + "dirty": { + "type": "boolean" + }, + "dirty_digest": { "type": "string" }, - "created_at": { - "format": "date-time", + "index_sha256": { "type": "string" }, - "error_code": { + "manifest_sha256": { "type": "string" }, - "expected_current_checkpoint_id": { - "maxLength": 256, - "minLength": 1, + "repository_kind": { + "enum": [ + "git", + "non_git" + ], + "type": "string" + }, + "root_fingerprint": { "type": "string" + } + }, + "required": [ + "repository_kind", + "commit", + "dirty", + "dirty_digest", + "root_fingerprint", + "index_sha256", + "manifest_sha256" + ], + "type": "object" + }, + "UIEvidenceStep": { + "additionalProperties": false, + "properties": { + "capture_after": { + "type": "boolean" }, "id": { "maxLength": 256, "minLength": 1, "type": "string" }, - "kind": { + "input_sha256": { "type": "string" }, - "operation_key_digest": { + "kind": { + "enum": [ + "navigate", + "click", + "type", + "assert_present", + "assert_absent", + "capture" + ], "type": "string" }, - "protocol_version": { + "selector": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "capture_after" + ], + "type": "object" + }, + "UIEvidenceStepReceipt": { + "additionalProperties": false, + "properties": { + "attempt_id": { + "maxLength": 256, + "minLength": 1, "type": "string" }, - "recovery_level": { + "completed_at": { + "format": "date-time", "type": "string" }, - "request_fingerprint": { + "failure_stage": { + "enum": [ + "none", + "build", + "launch", + "readiness", + "navigation", + "selector", + "assertion", + "console", + "network", + "capture", + "cleanup" + ], "type": "string" }, - "run_id": { - "maxLength": 256, - "minLength": 1, + "fingerprint": { "type": "string" }, - "status": { + "kind": { + "enum": [ + "navigate", + "click", + "type", + "assert_present", + "assert_absent", + "capture" + ], "type": "string" }, - "target_checkpoint_id": { - "maxLength": 256, - "minLength": 1, + "message": { "type": "string" }, - "trigger_receipt_id": { - "maxLength": 256, - "minLength": 1, + "protocol_version": { + "enum": [ + "ui-evidence-step.v1" + ], "type": "string" }, - "updated_at": { + "sequence": { + "format": "int32", + "type": "integer" + }, + "started_at": { "format": "date-time", "type": "string" }, - "workspace_id": { + "status": { + "enum": [ + "passed", + "failed", + "cancelled", + "timed_out" + ], + "type": "string" + }, + "step_id": { "maxLength": 256, "minLength": 1, "type": "string" } }, "required": [ - "id", "protocol_version", - "operation_key_digest", - "request_fingerprint", - "run_id", - "workspace_id", + "attempt_id", + "step_id", + "sequence", "kind", - "trigger_receipt_id", - "before_checkpoint_id", "status", - "recovery_level", - "created_at", - "updated_at" + "failure_stage", + "started_at", + "completed_at", + "fingerprint" + ], + "type": "object" + }, + "UIEvidenceViewport": { + "additionalProperties": false, + "properties": { + "dpr": { + "format": "double", + "type": "number" + }, + "height": { + "format": "int32", + "type": "integer" + }, + "width": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "width", + "height", + "dpr" ], "type": "object" }, @@ -30867,6 +32423,76 @@ ], "type": "object" }, + "uiEvidenceCancelView": { + "additionalProperties": false, + "properties": { + "confirm": { + "type": "boolean" + } + }, + "required": [ + "confirm" + ], + "type": "object" + }, + "uiEvidenceStartView": { + "additionalProperties": false, + "properties": { + "browser": { + "$ref": "#/components/schemas/UIEvidenceBrowserSelection" + }, + "build": { + "$ref": "#/components/schemas/CommandRuntimeSpec" + }, + "capture": { + "$ref": "#/components/schemas/UIEvidenceCapturePolicy" + }, + "environment": { + "$ref": "#/components/schemas/UIEvidenceEnvironment" + }, + "failure_policy": { + "$ref": "#/components/schemas/UIEvidenceFailurePolicy" + }, + "fixture": { + "$ref": "#/components/schemas/UIEvidenceFixture" + }, + "operation_key": { + "type": "string" + }, + "readiness": { + "$ref": "#/components/schemas/UIEvidenceReadiness" + }, + "route": { + "type": "string" + }, + "start": { + "$ref": "#/components/schemas/CommandRuntimeSpec" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/UIEvidenceRuntimeStep" + }, + "type": "array" + }, + "url": { + "type": "string" + } + }, + "required": [ + "operation_key", + "start", + "readiness", + "url", + "route", + "browser", + "environment", + "fixture", + "steps", + "capture", + "failure_policy" + ], + "type": "object" + }, "workspaceCheckpointCaptureView": { "additionalProperties": false, "properties": { diff --git a/docs/ui-evidence.md b/docs/ui-evidence.md new file mode 100644 index 00000000..df31874b --- /dev/null +++ b/docs/ui-evidence.md @@ -0,0 +1,171 @@ +# 真实浏览器 UI 证据 / Real-browser UI Evidence + +`ui-evidence.v1` 把一次真实页面验证绑定到精确源码、启动配方、浏览器运行时、呈现矩阵、交互步骤和不可变产物。它解决的是“这个固定源码在这个真实浏览器环境里实际发生了什么”,不是用截图代替测试,也不是让页面内容获得控制权。 + +`ui-evidence.v1` binds one real-page verification to exact source, launch recipes, browser runtime, presentation matrix, interaction steps, and immutable artifacts. It answers what a fixed source state actually did in a real browser. It does not turn screenshots into tests or page content into authority. + +## 结果语义 / Outcome semantics + +Attempt 只允许以下状态: + +| 状态 | 含义 | 是否通过 | +|---|---|---| +| `not_run` | 清单已持久化,尚未进入运行 | 否;保持中性 | +| `running` | Run-owned 执行与清理责任仍有效 | 否 | +| `passed` | 步骤、强制失败策略和完整清理全部通过 | 是,唯一绿色状态 | +| `failed` | 稳定阶段中的验证失败 | 否 | +| `cancelled` | 操作者取消,清理后终止 | 否 | +| `timed_out` | 有界 deadline 到期 | 否 | +| `interrupted` | 重启发现遗留 `running` Attempt,并收敛为中断 | 否 | + +失败阶段固定为 `build`、`launch`、`readiness`、`navigation`、`selector`、`assertion`、`console`、`network`、`capture` 或 `cleanup`。`not_run`、缺少产物、仅源码审阅、build success 和 mock render 都不能被投影成通过。 + +## 清单绑定 / Manifest binding + +每个不可变清单记录: + +- Run、Mission、Session、Workspace 和 Attempt; +- Git/non-Git 类型、commit、branch、dirty 标记与 digest、root fingerprint、原始 index digest、确定性 worktree manifest digest; +- 可选 build 与必需 start 的 `command-runtime.v2` 规范化配方,包括可审阅 argv、Workspace 相对 cwd、可执行文件/路径/environment digest、timeout、`network=disabled` 和 `credentials=none`; +- 固定安装位置浏览器的 product、已验证 version、可执行文件 SHA-256、`restricted-cdp-ui-evidence.v1`、headless 与临时 Profile 标记; +- literal `127.0.0.1` URL、route、readiness status/deadline、viewport、DPR、locale、theme、reduced motion; +- fixture name、seed、page state、data SHA-256、deterministic/synthetic 标记; +- 有序 navigate/click/type/assert/capture 步骤、遮罩和强制 console/page/request/HTTP failure policy。 + +准备后,Application 会在 build 前、应用 readiness 后、浏览器断言后以及 owned application/browser process cleanup 完成后重新捕获 source checkpoint。最后一次复核位于进程树回收之后,避免应用在最后断言与 terminal receipt 之间改写源码。tracked、未忽略的 untracked、index、commit、branch 或 root 发生漂移会失败关闭。新建或更新的 ignored build/cache 目录仍由完整 checkpoint 记录为显式排除项,但不改变内容级 source manifest digest;配方不得修改被绑定的源码。 + +Fixture 是对由已审阅 start recipe 提供的数据状态的声明,不是浏览器侧任意脚本注入。请在应用自己的测试入口中装载固定 seed,并用真实选择器/交互断言证明该状态。`type` 的原始值只存在于当前请求;清单仅保存 SHA-256,且疑似 secret 的输入会被拒绝。 + +## 运行所有权与网络边界 / Runtime ownership and network boundary + +执行面默认关闭。启用 Windows Desktop 控制需要同时满足: + +```powershell +./build/desktop/cyberagent-desktop.exe ` + --enable-permission-control ` + --enable-danger-full-access ` + --enable-run-execution ` + --enable-browser-cdp-control ` + --enable-ui-evidence +``` + +Run 本身还必须处于 Code/Local/Deliver/root、选择当前 `full_access`、拥有 active execution lease,并有当前 `restricted` browser-CDP permission。启动 flag 只开放进程内 capability;不会替 Run 创建权限、审批或 lease。 + +Application 在任何启动前探测 readiness 端口;发现已有 listener 就返回 `launch/preexisting_service`,不会收养、停止或等待它。应用由 Run-owned command runtime 管理,浏览器从固定受信安装位置重新校验版本、publisher 与 SHA-256,以新的 disposable Profile 启动,并进入 Safe Web/WFP/Job Object 生命周期。启动、读取和等待仍逐次要求 active Run lease;取消、timeout 或权限撤销后的回收使用只含原 Attempt durable Job/operation/Run/lease identity 的内部 cleanup-only 绑定,不能启动、收养、读写或停止其他 Job。取消和 Desktop 关闭都会等待 owned application/browser tree、Profile、network guard 与端口清理。Profile 只在进程树与网络清理证明完成后进入 exact-owner quarantine;Windows 的短暂文件共享锁采用 5 秒有界重试,超限仍以 `cleanup` 失败而不是误报通过。SQLite 中的 PID、清单或历史 Attempt 不会在重启后恢复启动权。 + +浏览器只允许精确 loopback origin。每个 request 和 redirect 都经 restricted CDP/网络边界复核;方法集合不包含 `Runtime.evaluate`、cookie API、response body、request mutation/replay 或 `Fetch.fulfillRequest`。页面和所有捕获内容均是不可信输入。 + +普通 command runtime 的 `network=disabled` 仍是宿主执行策略而非通用 OS 网络沙箱;因此 build/start 还会拒绝已知网络客户端/安装命令意图,依赖应在验证前固定安装。浏览器流量则由 UI-evidence Safe Web 生产路径的独立网络隔离约束。 + +## 产物与脱敏 / Artifacts and redaction + +V1 的执行请求必须同时采集 PNG screenshot、DOM、accessibility tree、console/page errors、network/HTTP metadata 和 performance metrics,避免以像素快照单独替代行为、可访问性与运行时健康验证;video 字段保留但当前必须为 `false`。逻辑 viewport 与 DPR 的乘积不得超过 7680×4320 像素面;PNG 在完整解码/分配前先用 header config 验证 dimensions,且 dimensions 必须与 `viewport × DPR` 在浏览器舍入允许的 1 像素内一致。领域模型、SQLite trigger 与 React 收据解析都会拒绝错配。每个产物绑定 kind、MIME、SHA-256、byte count、source commit、Run/Attempt、source step、capture time、viewport、截图 dimensions、redaction、`retention_policy=run_history` 与 `untrusted=true`。本地证据随 Run 历史保留且不静默过期,单产物、单 Attempt 和全局仓库分别有 32 MiB、128 MiB 和 2 GiB 硬上限;CI 上传副本固定保留 5 天。 + +文本产物统一做 UTF-8 修复、控制字符移除与 secret redaction。Network 只保留重新通过 exact TargetScope 的脱敏 URL(不含 query/user/fragment)、method、resource type、status、MIME 和失败摘要;`data:`、`file:`、`blob:` 与其他越界 URL 只写入固定 `[blocked-url]`,不读取 header、cookie 或 body。Screenshot 只会遮盖显式 `mask_selectors`;动态或可能含个人/敏感数据的区域必须列出 mask,任一 selector 未匹配即失败。基线差异只能由人工审阅接受,系统不自动更新 baseline。 + +下载响应带 `Cache-Control: no-store`、ETag、`X-CyberAgent-Content-SHA256` 和 `X-CyberAgent-Evidence-Untrusted: true`。React 在创建 Blob 前复核 MIME、长度与 SHA-256;CLI 也在以 `0600` 独占创建新文件前复核内容。 + +## 产品入口 / Product surfaces + +Desktop 的 Run workspace 包含“真实浏览器 UI 证据”页签。历史清单、步骤和产物在控制 capability 关闭后仍可只读查看;启动前必须加载/编辑完整 JSON,并勾选人工审阅确认。只有 `passed` 使用成功样式,`not_run` 始终中性。 + +HTTP/OpenAPI 使用 read bearer 读取,distinct control bearer 启动或取消: + +```text +GET /api/v1/runs/{run_id}/ui-evidence?status=passed&limit=100 +POST /api/v1/runs/{run_id}/ui-evidence +GET /api/v1/ui-evidence/{attempt_id} +GET /api/v1/ui-evidence/{attempt_id}/artifacts/{artifact_id} +POST /api/v1/ui-evidence/{attempt_id}/cancel {"confirm":true} +``` + +CLI 是有意设计的只读/导出入口,不复制执行 authority: + +```powershell +cyberagent ui-evidence list --run --status passed --limit 20 +cyberagent ui-evidence show +cyberagent ui-evidence artifact --output ./evidence.png +``` + +## Desktop 启动模板 / Desktop launch template + +面板内置本仓库 Vite 模板。它要求 `web/node_modules` 已固定安装,不会执行 `npm install`: + +```json +{ + "operation_key": "desktop-ui-evidence-", + "start": { + "version": "command-runtime.v2", + "profile": "powershell", + "script": "npm run dev -- --host 127.0.0.1 --port 4173", + "working_directory": "web", + "environment": [], + "stdin_policy": "closed", + "close_initial_stdin": true, + "timeout_milliseconds": 1800000, + "output": {"inline_bytes": 16384, "artifact_bytes": 262144}, + "network": "disabled", + "credentials": "none", + "purpose": "Launch the reviewed Workspace web application for source-bound UI evidence" + }, + "readiness": { + "url": "http://127.0.0.1:4173/", + "method": "GET", + "expected_status": [200], + "timeout_milliseconds": 60000, + "interval_milliseconds": 250 + }, + "url": "http://127.0.0.1:4173/", + "route": "/", + "browser": {"product": "edge", "channel": "stable"}, + "environment": { + "viewport": {"width": 1440, "height": 900, "dpr": 1}, + "locale": "en-US", + "theme": "light", + "reduced_motion": false + }, + "fixture": { + "name": "empty-local-state", + "seed": "ui-evidence-v1", + "page_state": "{}", + "data_sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "deterministic": true, + "synthetic": true + }, + "steps": [ + {"step": {"id": "navigate", "kind": "navigate", "capture_after": true}}, + {"step": {"id": "app-root", "kind": "assert_present", "selector": "#root", "capture_after": true}} + ], + "capture": { + "screenshot": true, + "dom": true, + "accessibility": true, + "console": true, + "network": true, + "performance": true, + "video": false, + "mask_selectors": [] + }, + "failure_policy": { + "fail_on_console_error": true, + "fail_on_page_error": true, + "fail_on_request_error": true, + "fail_on_http_status": true + } +} +``` + +同一 `operation_key` 加同一请求指纹会幂等返回原 Attempt;换载荷复用 key 会冲突。取消必须显式 `confirm=true`。 + +## CI 与 PR 收据 / CI and PR receipts + +Windows CI 的 `TestInstalledEdgeUIEvidenceHeadlessMatrixAndRegression` 从 clean checkout/fixed commit 启动固定 Edge,使用创建时绑定的 Job Object 和临时 Profile,只服务 deterministic `127.0.0.1` fixture。矩阵覆盖: + +- 1440×900 @1x、light、en-US、full motion; +- 390×844 @2x、dark、zh-CN、reduced motion。 + +它执行真实 click/type/selector、DOM、accessibility、performance、console/network 和 PNG 尺寸检查,并访问一个仅缺失 click handler 的 regression route,证明真实页面断言能发现 build/source inspection 不能证明的行为错误。CI 上传 screenshots 与 `receipt.json`,其中记录 commit/dirty digest、clean-checkout、浏览器 version/executable hash、scope/routes、完整 presentation matrix、artifact hash/MIME/dimensions、`regression_caught=true`,以及 Job 进程树、DevTools 端口、临时 Profile 和 fixture server 的清理结果。 + +PR 的 Verification 段应列出精确命令、平台/runtime、manifest/receipt 或 Attempt ID、矩阵、产物 SHA-256、失败策略、清理结果和未运行项。将 `not_run` 或未覆盖矩阵写成 passed 属于错误报告。内置 `run-verify@1.1.0` 将本流程与 `focused-checks` 和 PR receipt 对齐。 + +架构与威胁决策见 [ADR 0120](adr/0120-source-bound-real-browser-ui-evidence.md),API 精确 schema 见 [OpenAPI](openapi.json)。 diff --git a/docs/usage.md b/docs/usage.md index 23cab8af..47d498fb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,6 +73,9 @@ cyberagent run command-proposal review deny --operation-key cyberagent run resume cyberagent run cancel +cyberagent ui-evidence list --run [--status ] [--limit ] +cyberagent ui-evidence show +cyberagent ui-evidence artifact --output ``` Schema v115 exposes `agent-code-tools.v1` to the root Supervisor during ordinary @@ -115,6 +118,38 @@ Schema v88 adds an orthogonal `conservative|approval|full_access|debug` permissi Schema v91 adds the independent `restricted|full_debug` browser-CDP selector described above. It does not inherit Shell authority from v88, and v88 does not inherit CDP authority from v91. A future concrete browser operation must recheck both its exact method/scope contract and the current process gates. +Schema v119 supplies that concrete operation only for source-bound local UI +verification. `ui-evidence.v1` seals the exact Git/non-Git source state, reviewed +build/start recipe, fixed browser executable and version, literal loopback URL, +viewport/DPR, locale/theme/reduced-motion state, fixture/seed, ordered steps, +capture policy, and fail-closed diagnostic policy before execution. It rejects an +occupied readiness port instead of adopting another service, revalidates source +before build, after readiness, and before pass, and owns the application process, +browser Job, disposable Profile, network guard, port, and cleanup receipt. Only +`passed` is success; `not_run` is deliberately neutral. + +Execution is currently a Windows Desktop capability. Build the Desktop and launch +it with all five independent gates: + +```powershell +.\build\desktop\cyberagent-desktop.exe ` + --enable-permission-control ` + --enable-danger-full-access ` + --enable-run-execution ` + --enable-browser-cdp-control ` + --enable-ui-evidence +``` + +The selected Run must also be Code/Local/Deliver with current `full_access`, an +active root execution lease, and current `restricted` browser-CDP permission. +The Desktop panel requires review of the complete JSON request before start and +shows the manifest, steps, diagnostics, cleanup, and content-addressed artifacts. +Disabling control later leaves historical evidence readable. The standalone CLI +intentionally supports only list/show/hash-verified export and cannot start or +cancel a browser. Export creates a new `0600` file exclusively and labels its +contents untrusted. Full request examples, artifact rules, and CI receipt fields +are documented in [Real-browser UI Evidence](ui-evidence.md). + Debug Agent terminal input is a separate process-local lease bound to one Workspace, Code Run, terminal session, exact interaction snapshot ID/revision, and Local profile. The opaque bearer is never persisted or returned to the renderer/model, lasts at most 15 minutes, can be revoked immediately, and becomes invalid on restart. Active leases and revoked-token summaries are bounded. Host lock/disconnect/logoff, sleep/resume, Run termination, Workspace or interaction rebinding, terminal replacement, and shutdown revoke affected leases. A selected mode is not a lease, and a lease is not process authority. The Desktop bridge exposes only explicit grant/query/revoke; the root Supervisor receives only a token-free active binding and can write/read through Go after every scope, permission, phase, policy, and expiry check. Cyber terminal input remains unavailable. `run command-plan` accepts only `git-status`, `git-diff-check`, `go-version`, and `powershell-workspace-list`. The PowerShell option is a Go-owned fixed `-NoProfile -NonInteractive -ExecutionPolicy Restricted` template. Its Workspace-relative path is transported as canonical UTF-8 hex data and decoded inside the fixed script, so it is never evaluated as a PowerShell expression. Callers cannot supply executable names, raw script text, environment variables, stdin, pipelines, or shell chaining. The plan remains non-starting. @@ -213,13 +248,13 @@ Invocation policy is separate from delivery compatibility. `user_invocable` perm | `plan-delivery` | 1.2.0 | all | code, cyber | plan, deliver | root | explicit user only | | `doctor` | 1.0.0 | all | code, cyber | plan | root | user + model eligible | | `debug` | 1.0.0 | all | code, cyber | plan, deliver | root | user + model eligible | -| `run-verify` | 1.0.0 | code, script | code, cyber | deliver | root | user + model eligible | +| `run-verify` | 1.1.0 | code, script | code, cyber | deliver | root | user + model eligible | | `focused-checks` | 1.0.0 | code, review, script | code, cyber | deliver | root | user + model eligible | | `simplify` | 1.0.0 | code | code | deliver | root | user + model eligible | | `security-review` | 1.0.0 | code, review, script | code, cyber | plan, deliver | root | user + model eligible | | `run-skill-generator` | 1.0.0 | code | code | deliver | root | explicit user only | -`doctor` reports provider, harness, workspace, sandbox, network-scope, tool, and Skill compatibility without repairing it. `debug` builds a bounded evidence timeline and permits repair only in Deliver. `run-verify` records and executes an authorized real launch recipe; on Cyber it is restricted by guidance to an admitted local sandbox. Its first `ui-evidence` extension binds screenshots or GIFs to the source revision, launch recipe, viewport, route/page state, theme, fixture, timestamp, console findings, and request failures. `focused-checks` maps changes to the smallest credible checks, `simplify` requires call-site evidence before deletion, and `security-review` remains read-only unless a separate Deliver authorization exists. +`doctor` reports provider, harness, workspace, sandbox, network-scope, tool, and Skill compatibility without repairing it. `debug` builds a bounded evidence timeline and permits repair only in Deliver. `run-verify@1.1.0` requires an exact `ui-evidence.v1` source/recipe/runtime binding, real interaction assertions, content-addressed artifact inventory, cleanup receipt, focused-check mapping, and a PR-ready verification receipt; `not_run` and missing matrix cells must remain explicit non-passes. It does not grant browser, process, network, Profile, or credential authority, and on Cyber remains restricted by guidance to an admitted local sandbox. `focused-checks` maps changes to the smallest credible checks, `simplify` requires call-site evidence before deletion, and `security-review` remains read-only unless a separate Deliver authorization exists. `run-skill-generator` does not create a trusted Skill. When it is explicitly selected and actually delivered to a Code/Deliver root turn, Go exposes `skill_candidate_propose`; otherwise the tool is omitted and forged calls are rejected. A successful call stores at most 4096 bytes of validated, secret-screened Markdown as an inert `proposed` candidate bound to the real tool invocation, Run/Session/Workspace/root, deterministic package, and exact fingerprints. The ordinary tool result and candidate list never expose the body; `skill candidate show --show-content` uses explicit untrusted-content delimiters for human inspection. diff --git a/internal/app/app.go b/internal/app/app.go index 288d9745..e7f91d53 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -217,6 +217,8 @@ func (a *App) dispatch(ctx context.Context, args []string) error { return a.sandboxCommand(ctx, args[1:]) case "artifact": return a.artifactCommand(ctx, args[1:]) + case "ui-evidence": + return a.uiEvidenceCommand(ctx, args[1:]) case "analyzer": return a.analyzerCommand(ctx, args[1:]) case "report": @@ -270,6 +272,7 @@ func (a *App) printHelp() { fmt.Fprintln(a.out, " cyberagent approval list|show|grant") fmt.Fprintln(a.out, " cyberagent sandbox validate|template") fmt.Fprintln(a.out, " cyberagent artifact list|show|read|verify") + fmt.Fprintln(a.out, " cyberagent ui-evidence list|show|artifact") fmt.Fprintln(a.out, " cyberagent analyzer execute") fmt.Fprintln(a.out, " cyberagent report show|finding|check") fmt.Fprintln(a.out, " cyberagent report finding attach|validate|reject|accept|remediation|fix|verify") diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 12b3556f..dc9b4e1d 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -29,7 +29,7 @@ func TestSkillCLIListsShowsAndValidatesBuiltinsWithoutRuntimeState(t *testing.T) !strings.Contains(listed, "script@1.2.0") || !strings.Contains(listed, "doctor@1.0.0") || !strings.Contains(listed, "debug@1.0.0") || - !strings.Contains(listed, "run-verify@1.0.0") || + !strings.Contains(listed, "run-verify@1.1.0") || !strings.Contains(listed, "run-skill-generator@1.0.0") || !strings.Contains(listed, "focused-checks@1.0.0") || !strings.Contains(listed, "simplify@1.0.0") || diff --git a/internal/app/ui_evidence_command.go b/internal/app/ui_evidence_command.go new file mode 100644 index 00000000..dffb1e05 --- /dev/null +++ b/internal/app/ui_evidence_command.go @@ -0,0 +1,147 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/uievidence" +) + +const uiEvidenceUsage = "usage: cyberagent ui-evidence " + + "list --run [--status ] [--limit ] | " + + "show | artifact --output " + +func (a *App) uiEvidenceCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New(uiEvidenceUsage) + } + if err := a.ensureStore(); err != nil { + return err + } + switch args[0] { + case "list": + return a.uiEvidenceList(ctx, args[1:]) + case "show": + return a.uiEvidenceShow(ctx, args[1:]) + case "artifact": + return a.uiEvidenceArtifact(ctx, args[1:]) + default: + return errors.New(uiEvidenceUsage) + } +} + +func (a *App) uiEvidenceList(ctx context.Context, args []string) error { + fs := newFlagSet("ui-evidence list", a.errOut) + runID := fs.String("run", "", "Run identity") + status := fs.String("status", "", "optional exact UI evidence status") + limit := fs.Int("limit", 100, "maximum attempts") + if err := fs.Parse(reorderFlags(args, map[string]bool{ + "run": true, "status": true, "limit": true, + })); err != nil { + return err + } + if fs.NArg() != 0 || strings.TrimSpace(*runID) == "" { + return errors.New("usage: cyberagent ui-evidence list --run [--status ] [--limit ]") + } + filter := uievidence.ListFilter{RunID: strings.TrimSpace(*runID), + Status: uievidence.Status(strings.TrimSpace(*status)), Limit: *limit} + if err := filter.Validate(); err != nil { + return fmt.Errorf("invalid UI evidence list filter: %w", err) + } + values, err := a.store.ListUIEvidenceAttempts(ctx, filter) + if err != nil { + return err + } + if values == nil { + values = []uievidence.Attempt{} + } + return writeUIEvidenceJSON(a.out, values) +} + +func (a *App) uiEvidenceShow(ctx context.Context, args []string) error { + fs := newFlagSet("ui-evidence show", a.errOut) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return errors.New("usage: cyberagent ui-evidence show ") + } + attemptID := fs.Arg(0) + attempt, err := a.store.GetUIEvidenceAttempt(ctx, attemptID) + if err != nil { + return err + } + steps, err := a.store.ListUIEvidenceSteps(ctx, attemptID) + if err != nil { + return err + } + artifacts, err := a.store.ListUIEvidenceArtifacts(ctx, attemptID) + if err != nil { + return err + } + if steps == nil { + steps = []uievidence.StepReceipt{} + } + if artifacts == nil { + artifacts = []uievidence.ArtifactMetadata{} + } + return writeUIEvidenceJSON(a.out, application.UIEvidenceBundle{ + Attempt: attempt, Steps: steps, Artifacts: artifacts}) +} + +func (a *App) uiEvidenceArtifact(ctx context.Context, args []string) error { + fs := newFlagSet("ui-evidence artifact", a.errOut) + output := fs.String("output", "", "new output file for the verified artifact bytes") + if err := fs.Parse(reorderFlags(args, map[string]bool{"output": true})); err != nil { + return err + } + if fs.NArg() != 2 || strings.TrimSpace(*output) == "" { + return errors.New("usage: cyberagent ui-evidence artifact --output ") + } + artifact, err := a.store.GetUIEvidenceArtifact(ctx, fs.Arg(0), fs.Arg(1)) + if err != nil { + return err + } + if err := artifact.Validate(); err != nil { + return fmt.Errorf("UI evidence artifact failed integrity verification: %w", err) + } + outputPath, err := filepath.Abs(strings.TrimSpace(*output)) + if err != nil { + return err + } + file, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("create UI evidence artifact output: %w", err) + } + writeErr := error(nil) + if _, err := file.Write(artifact.Content); err != nil { + writeErr = err + } else if err := file.Sync(); err != nil { + writeErr = err + } + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + _ = os.Remove(outputPath) + return fmt.Errorf("write UI evidence artifact output: %w", + errors.Join(writeErr, closeErr)) + } + fmt.Fprintf(a.out, "artifact: %s\noutput: %s\nsha256: %s\nbytes: %d\nuntrusted: true\n", + artifact.Metadata.ID, outputPath, artifact.Metadata.SHA256, + artifact.Metadata.Bytes) + return nil +} + +func writeUIEvidenceJSON(destination interface { + Write([]byte) (int, error) +}, value any) error { + encoder := json.NewEncoder(destination) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/internal/app/ui_evidence_command_test.go b/internal/app/ui_evidence_command_test.go new file mode 100644 index 00000000..3e046797 --- /dev/null +++ b/internal/app/ui_evidence_command_test.go @@ -0,0 +1,234 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/store" + "cyberagent-workbench/internal/uievidence" +) + +func TestUIEvidenceCLIListsShowsAndExclusivelyExportsVerifiedUntrustedEvidence(t *testing.T) { + home := t.TempDir() + t.Setenv("CYBERAGENT_HOME", home) + if _, stderr, code := executeTestCommand(t, "workspace", "init", "ui-cli"); code != 0 || stderr != "" { + t.Fatalf("workspace init stderr=%q code=%d", stderr, code) + } + created, stderr, code := executeTestCommand(t, "run", "create", + "UI evidence CLI contract", "--workspace", "ui-cli", "--profile", "code") + if code != 0 || stderr != "" { + t.Fatalf("run create output=%q stderr=%q code=%d", created, stderr, code) + } + runID := runIDPattern.FindString(created) + if runID == "" { + t.Fatalf("run identity missing: %s", created) + } + attempt, artifact := seedCLIUIEvidence(t, home, runID) + + listed, stderr, code := executeTestCommand(t, "ui-evidence", "list", + "--run", runID, "--status", "passed", "--limit", "10") + if code != 0 || stderr != "" { + t.Fatalf("list output=%q stderr=%q code=%d", listed, stderr, code) + } + var attempts []uievidence.Attempt + if err := json.Unmarshal([]byte(listed), &attempts); err != nil || + len(attempts) != 1 || !attempts[0].Status.Passed() || + attempts[0].Manifest.Fingerprint != attempt.Manifest.Fingerprint { + t.Fatalf("listed attempts=%+v err=%v", attempts, err) + } + + shown, stderr, code := executeTestCommand(t, "ui-evidence", "show", + attempt.Manifest.AttemptID) + if code != 0 || stderr != "" { + t.Fatalf("show output=%q stderr=%q code=%d", shown, stderr, code) + } + var bundle application.UIEvidenceBundle + if err := json.Unmarshal([]byte(shown), &bundle); err != nil || + bundle.Attempt.Manifest.Source.Commit != attempt.Manifest.Source.Commit || + len(bundle.Steps) != 1 || len(bundle.Artifacts) != 6 { + t.Fatalf("shown bundle=%+v err=%v", bundle, err) + } + for _, metadata := range bundle.Artifacts { + if !metadata.Untrusted { + t.Fatalf("shown bundle contains trusted evidence metadata: %+v", metadata) + } + } + + outputPath := filepath.Join(t.TempDir(), "ui-evidence.json") + exported, stderr, code := executeTestCommand(t, "ui-evidence", "artifact", + attempt.Manifest.AttemptID, artifact.Metadata.ID, "--output", outputPath) + if code != 0 || stderr != "" || !strings.Contains(exported, "untrusted: true") || + !strings.Contains(exported, artifact.Metadata.SHA256) { + t.Fatalf("artifact output=%q stderr=%q code=%d", exported, stderr, code) + } + raw, err := os.ReadFile(outputPath) + if err != nil || string(raw) != string(artifact.Content) { + t.Fatalf("exported bytes=%q err=%v", raw, err) + } + if _, stderr, code := executeTestCommand(t, "ui-evidence", "artifact", + attempt.Manifest.AttemptID, artifact.Metadata.ID, "--output", outputPath); code == 0 || !strings.Contains(stderr, "create UI evidence artifact output") { + t.Fatalf("exclusive export did not fail closed: stderr=%q code=%d", stderr, code) + } +} + +func seedCLIUIEvidence(t *testing.T, home, runID string) ( + uievidence.Attempt, uievidence.Artifact, +) { + t.Helper() + ctx := context.Background() + state, err := store.Open(filepath.Join(home, "cyberagent.db")) + if err != nil { + t.Fatal(err) + } + defer state.Close() + runRecord, err := state.GetRun(ctx, runID) + if err != nil { + t.Fatal(err) + } + mission, err := state.GetMission(ctx, runRecord.MissionID) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 20, 2, 0, 0, 0, time.UTC) + recipe, err := uievidence.SealCommandRecipe(uievidence.CommandRecipe{ + ProtocolVersion: "command-runtime.v2", Profile: "process", + ExecutableName: "fixture-server", ExecutablePathSHA256: cliUIDigest("path"), + ExecutableSHA256: cliUIDigest("executable"), CanonicalArgv: []string{"--port", "4173"}, + WorkingDirectory: ".", EnvironmentNames: []string{}, + EnvironmentSHA256: cliUIDigest("environment"), TimeoutMilliseconds: 30000, + Network: "disabled", Credentials: "none", Purpose: "serve synthetic UI fixture"}) + if err != nil { + t.Fatal(err) + } + manifest, err := uievidence.SealManifest(uievidence.Manifest{ + AttemptID: "ui-attempt-cli", RunID: runRecord.ID, MissionID: mission.ID, + SessionID: runRecord.SessionID, WorkspaceID: mission.WorkspaceID, + Source: uievidence.SourceBinding{RepositoryKind: "git", + Commit: strings.Repeat("1", 40), Branch: "main", DirtyDigest: cliUIDigest("dirty"), + RootFingerprint: cliUIDigest("root"), IndexSHA256: cliUIDigest("index"), + ManifestSHA256: cliUIDigest("manifest")}, + Start: recipe, Readiness: uievidence.Readiness{URL: "http://127.0.0.1:4173/", + Method: "GET", ExpectedStatus: []int{200}, TimeoutMilliseconds: 30000, + IntervalMilliseconds: 100}, + Browser: uievidence.BrowserIdentity{Product: "edge", Version: "151.0.1", + ExecutableSHA256: cliUIDigest("browser"), + DriverProtocol: uievidence.DriverProtocolVersion, Headless: true, + TemporaryProfile: true}, + URL: "http://127.0.0.1:4173/", Route: "/", + Environment: uievidence.Environment{Viewport: uievidence.Viewport{ + Width: 1280, Height: 720, DPR: 1}, Locale: "en-US", + Theme: uievidence.ThemeLight, ReducedMotion: true}, + Fixture: uievidence.Fixture{Name: "CLI fixture", Seed: "42", + PageState: "ready", DataSHA256: cliUIDigest("fixture"), + Deterministic: true, Synthetic: true}, + Steps: []uievidence.Step{{ID: "navigate", Kind: uievidence.StepNavigate}}, + Capture: uievidence.CapturePolicy{Screenshot: true, DOM: true, Accessibility: true, + Console: true, Network: true, Performance: true, MaskSelectors: []string{}}, + FailurePolicy: uievidence.FailurePolicy{FailOnConsoleError: true, + FailOnPageError: true, FailOnRequestError: true, FailOnHTTPStatus: true}, + CreatedAt: now}) + if err != nil { + t.Fatal(err) + } + attempt, err := uievidence.NewAttempt(manifest, "cli-ui-evidence-operation", now) + if err != nil { + t.Fatal(err) + } + attempt, _, err = state.CreateUIEvidenceAttempt(ctx, attempt) + if err != nil { + t.Fatal(err) + } + started, err := uievidence.StartAttempt(attempt, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + started, err = state.UpdateUIEvidenceAttempt(ctx, started, attempt.Version) + if err != nil { + t.Fatal(err) + } + receipt, err := uievidence.SealStepReceipt(uievidence.StepReceipt{ + AttemptID: manifest.AttemptID, StepID: "navigate", Sequence: 1, + Kind: uievidence.StepNavigate, Status: uievidence.StatusPassed, + FailureStage: uievidence.FailureNone, StartedAt: now.Add(2 * time.Second), + CompletedAt: now.Add(3 * time.Second)}) + if err != nil { + t.Fatal(err) + } + if err := state.AddUIEvidenceStep(ctx, receipt); err != nil { + t.Fatal(err) + } + artifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: "ui-artifact-cli", AttemptID: manifest.AttemptID, RunID: runRecord.ID, + StepID: receipt.StepID, Kind: uievidence.ArtifactDOM, + MIME: "application/json", Viewport: manifest.Environment.Viewport, + SourceCommit: manifest.Source.Commit, + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, + Redacted: true, Untrusted: true, + CreatedAt: now.Add(4 * time.Second)}, []byte(`{"state":"verified"}`)) + if err != nil { + t.Fatal(err) + } + if err := state.AddUIEvidenceArtifact(ctx, artifact); err != nil { + t.Fatal(err) + } + for index, kind := range []uievidence.ArtifactKind{ + uievidence.ArtifactScreenshot, + uievidence.ArtifactAccessibility, + uievidence.ArtifactConsole, + uievidence.ArtifactNetwork, + uievidence.ArtifactPerformance, + } { + mime := "application/json" + width, height := 0, 0 + if kind == uievidence.ArtifactScreenshot { + mime = "image/png" + width = manifest.Environment.Viewport.Width + height = manifest.Environment.Viewport.Height + } + value, sealErr := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: "ui-artifact-cli-" + string(kind), AttemptID: manifest.AttemptID, + RunID: runRecord.ID, StepID: receipt.StepID, Kind: kind, MIME: mime, + Width: width, Height: height, Viewport: manifest.Environment.Viewport, + SourceCommit: manifest.Source.Commit, + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, + Redacted: true, Untrusted: true, + CreatedAt: now.Add(time.Duration(index+5) * time.Second), + }, []byte("bounded "+string(kind)+" evidence")) + if sealErr != nil { + t.Fatal(sealErr) + } + if err := state.AddUIEvidenceArtifact(ctx, value); err != nil { + t.Fatal(err) + } + } + count, bytes, err := state.UIEvidenceArtifactTotals(ctx, manifest.AttemptID) + if err != nil || count != 6 { + t.Fatalf("artifact totals=%d/%d err=%v", count, bytes, err) + } + completed, err := uievidence.CompleteAttempt(started, uievidence.StatusPassed, + uievidence.FailureNone, "", "", uievidence.DiagnosticsSummary{}, + uievidence.CleanupReceipt{BrowserTreeReaped: true, ApplicationTreeReaped: true, + ProfileRemoved: true, NetworkReleased: true, PortReleased: true}, + count, bytes, now.Add(10*time.Second)) + if err != nil { + t.Fatal(err) + } + completed, err = state.UpdateUIEvidenceAttempt(ctx, completed, started.Version) + if err != nil { + t.Fatal(err) + } + return completed, artifact +} + +func cliUIDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/application/browser_runtime.go b/internal/application/browser_runtime.go index 4a5d089e..17ac380b 100644 --- a/internal/application/browser_runtime.go +++ b/internal/application/browser_runtime.go @@ -58,6 +58,7 @@ type BrowserRuntimeLaunchRequest struct { type BrowserRuntimeHandle struct { RuntimeID string Coordinator *browserruntime.BrowserRuntimeLifecycleCoordinator + UIEvidence *browserruntime.RestrictedBrowserSession } // BrowserRuntimeService orchestrates the Safe Web operator flow @@ -66,9 +67,9 @@ type BrowserRuntimeHandle struct { // gate, durable review, disposable Profile, and network containment all remain // mandatory and fail closed. type BrowserRuntimeService struct { - store BrowserRuntimeStore - controller *browserruntime.BrowserProcessController - runtimeCapabilities browserruntime.ProductionRuntimeCapabilities + store BrowserRuntimeStore + controller *browserruntime.BrowserProcessController + runtimeCapabilities browserruntime.ProductionRuntimeCapabilities permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities } @@ -78,7 +79,7 @@ func NewBrowserRuntimeService(store BrowserRuntimeStore, permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities, ) *BrowserRuntimeService { return &BrowserRuntimeService{store: store, controller: controller, - runtimeCapabilities: runtimeCapabilities, + runtimeCapabilities: runtimeCapabilities, permissionCapabilities: permissionCapabilities} } @@ -89,6 +90,21 @@ func NewBrowserRuntimeService(store BrowserRuntimeStore, // handle whose process is not yet bound to the restricted Job. func (s *BrowserRuntimeService) Launch(ctx context.Context, request BrowserRuntimeLaunchRequest, +) (*BrowserRuntimeHandle, error) { + return s.launch(ctx, request, false) +} + +// LaunchUIEvidence runs the same reviewed Safe Web launch path and then opens +// the separately authorized fixed-method UI evidence CDP session. It never +// adopts a pre-existing browser or user Profile. +func (s *BrowserRuntimeService) LaunchUIEvidence(ctx context.Context, + request BrowserRuntimeLaunchRequest, +) (*BrowserRuntimeHandle, error) { + return s.launch(ctx, request, true) +} + +func (s *BrowserRuntimeService) launch(ctx context.Context, + request BrowserRuntimeLaunchRequest, uiEvidence bool, ) (*BrowserRuntimeHandle, error) { if s == nil || s.store == nil || s.controller == nil { return nil, errors.New("browser runtime service is not fully configured") @@ -199,13 +215,53 @@ func (s *BrowserRuntimeService) Launch(ctx context.Context, return nil, err } runtimeID := idgen.New("browser_runtime") + var restricted *browserruntime.RestrictedBrowserSession + if uiEvidence { + cdpAuthorization, authorizeErr := browserruntime.AuthorizeUIEvidenceCDP( + authorization, session, request.Identity, request.Acceptance, ownership, + launchAttempt, launchLease, launchReview, evidence, review, networkPlan, + permission, s.runtimeCapabilities, time.Now().UTC()) + if authorizeErr == nil { + restricted, authorizeErr = browserruntime.OpenRestrictedBrowserSession(ctx, + cdpAuthorization, authorization, session, request.Identity, + request.Acceptance, ownership, launchAttempt, launchLease, launchReview, + evidence, review, networkPlan, permission, profileLease, process) + } + if authorizeErr != nil { + cleanupErr := s.cleanupFailedBrowserLaunch(runtimeID, launchAttempt, + authorization, ownership, profileLease, process, now) + return nil, errors.Join(authorizeErr, cleanupErr) + } + } + coordinator, err := browserruntime.NewBrowserRuntimeLifecycleCoordinator(runtimeID, + launchAttempt, authorization, ownership, profileLease, process, restricted, s.store, now) + if err != nil { + if restricted != nil { + _ = restricted.Close(context.Background()) + } + cleanupErr := s.cleanupFailedBrowserLaunch(runtimeID, launchAttempt, + authorization, ownership, profileLease, process, now) + return nil, errors.Join(err, cleanupErr) + } + return &BrowserRuntimeHandle{RuntimeID: runtimeID, Coordinator: coordinator, + UIEvidence: restricted}, nil +} + +func (s *BrowserRuntimeService) cleanupFailedBrowserLaunch(runtimeID string, + attempt browserruntime.BrowserLaunchAttempt, + authorization browserruntime.BrowserStartAuthorization, + ownership browserruntime.ProfileOwnershipPlan, + profileLease browserruntime.ProfileRuntimeLease, + process *browserruntime.BrowserProcess, startedAt time.Time, +) error { coordinator, err := browserruntime.NewBrowserRuntimeLifecycleCoordinator(runtimeID, - launchAttempt, authorization, ownership, profileLease, process, nil, s.store, now) + attempt, authorization, ownership, profileLease, process, nil, s.store, startedAt) if err != nil { _ = process.Stop(context.Background()) - return nil, err + return err } - return &BrowserRuntimeHandle{RuntimeID: runtimeID, Coordinator: coordinator}, nil + _, err = coordinator.Finalize(context.Background()) + return err } // Close finalizes a running browser session: it stops the process tree, verifies diff --git a/internal/application/command_runtime.go b/internal/application/command_runtime.go index a0854477..cf9d38f2 100644 --- a/internal/application/command_runtime.go +++ b/internal/application/command_runtime.go @@ -234,6 +234,86 @@ func (s *CommandRuntimeService) ExecuteCommandRuntime(ctx context.Context, } } +// cleanupUIEvidenceJob is a cleanup-only capability for a Job that this +// process started for one sealed UI-evidence Attempt. It intentionally does +// not consult the current Run lease: expiry, cancellation, and revocation are +// precisely the states in which the Attempt must still be able to reap its own +// process tree. The full durable identity is checked before Stop, so this path +// cannot start, adopt, read, write to, or stop any other Job. +func (s *CommandRuntimeService) cleanupUIEvidenceJob(ctx context.Context, + binding uiEvidenceCommandCleanupBinding, +) (runner.CommandRuntimeJobSnapshot, error) { + if s == nil || s.store == nil || s.manager == nil || ctx == nil || + ctx.Err() != nil || binding.Validate() != nil { + return runner.CommandRuntimeJobSnapshot{}, apperror.New( + apperror.CodeInvalidArgument, "UI evidence command cleanup binding is invalid") + } + record, err := s.store.GetCommandRuntimeJob(ctx, binding.JobID) + if err != nil { + return runner.CommandRuntimeJobSnapshot{}, commandRuntimeError(err) + } + if !uiEvidenceCommandCleanupMatches(record, binding) { + return runner.CommandRuntimeJobSnapshot{}, apperror.New( + apperror.CodeConflict, "UI evidence command cleanup binding is stale") + } + if record.State.Terminal() { + if !record.TreeReaped { + return runner.ProjectCommandRuntimeJob(record), apperror.New( + apperror.CodeConflict, "UI evidence command process tree is not reaped") + } + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return runner.ProjectCommandRuntimeJob(record), err + } + return runner.ProjectCommandRuntimeJob(record), nil + } + if !s.manager.OwnsActiveJob(record) { + return runner.ProjectCommandRuntimeJob(record), apperror.New( + apperror.CodeConflict, "UI evidence command ownership is stale") + } + _, stopErr := s.manager.Stop(ctx, record.ID, true, 0) + for { + job, _, waitErr := s.manager.Wait(ctx, record.ID, 100*time.Millisecond, + math.MaxUint64, runner.MinCommandRuntimeOutputRead) + if waitErr != nil { + return job, errors.Join(commandRuntimeError(stopErr), + commandRuntimeError(waitErr)) + } + if !job.State.Terminal() { + continue + } + if !job.TreeReaped { + return job, apperror.New(apperror.CodeConflict, + "UI evidence command process tree is not reaped") + } + record, err = s.store.GetCommandRuntimeJob(ctx, binding.JobID) + if err != nil { + return job, commandRuntimeError(err) + } + if !uiEvidenceCommandCleanupMatches(record, binding) || + !record.State.Terminal() || !record.TreeReaped { + return job, apperror.New(apperror.CodeConflict, + "UI evidence command cleanup proof is stale") + } + if err := s.completeCommandRuntimeJobBoundary(ctx, record); err != nil { + return runner.ProjectCommandRuntimeJob(record), err + } + return runner.ProjectCommandRuntimeJob(record), nil + } +} + +func uiEvidenceCommandCleanupMatches(job runner.CommandRuntimeJob, + binding uiEvidenceCommandCleanupBinding, +) bool { + expectedDigest, expectedID := runner.CommandRuntimeOperationIdentity( + binding.RunID, binding.OperationKey) + return binding.JobID == expectedID && job.ID == expectedID && + job.OperationDigest == expectedDigest && + job.InvocationID == binding.InvocationID && job.RunID == binding.RunID && + job.MissionID == binding.MissionID && job.SessionID == binding.SessionID && + job.WorkspaceID == binding.WorkspaceID && job.RootAgentID == binding.RootAgentID && + job.LeaseID == binding.LeaseID && job.LeaseGeneration == binding.LeaseGeneration +} + func (s *CommandRuntimeService) runForeground(ctx context.Context, scope toolgateway.CommandRuntimeContext, input toolgateway.CommandRuntimeInput, bindings commandRuntimeBindings, result toolgateway.CommandRuntimeExecutionResult, diff --git a/internal/application/command_runtime_test.go b/internal/application/command_runtime_test.go index ce1238b6..598e6266 100644 --- a/internal/application/command_runtime_test.go +++ b/internal/application/command_runtime_test.go @@ -502,6 +502,85 @@ func TestCommandRuntimeBackgroundJobSurvivesSupervisorLeaseTurnover(t *testing.T } } +func TestCommandRuntimeUIEvidenceCleanupReapsExactJobAfterLeaseRelease(t *testing.T) { + ctx := context.Background() + state, runRecord, root, lease, capabilities := newCommandRuntimeTestRuntime(t, ctx) + manager, err := runner.NewPlatformCommandRuntimeManager(state, + idgen.New("command-runtime-ui-cleanup-owner")) + if err != nil { + t.Fatal(err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Shutdown(shutdownCtx); err != nil { + t.Errorf("shutdown command runtime: %v", err) + } + }() + service, err := NewCommandRuntimeService(state, manager, capabilities) + if err != nil { + t.Fatal(err) + } + profile := runner.CommandRuntimeBash + script := `IFS= read -r line; printf 'unexpected:%s\n' "$line"` + if runtime.GOOS == "windows" { + profile = runner.CommandRuntimePowerShell + script = `$line = [Console]::In.ReadLine(); [Console]::Out.WriteLine("unexpected:$line")` + } + identity := "ui-attempt-cleanup-test:application" + scope := commandRuntimeTestScope(runRecord, root, lease, identity) + scope.InvocationID = identity + started, err := service.ExecuteCommandRuntime(ctx, scope, + toolgateway.CommandRuntimeInput{Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionStart, + Commands: []runner.CommandRuntimeSpec{{ + Version: runner.CommandRuntimeProtocolVersion, Profile: profile, Script: script, + WorkingDirectory: ".", Environment: []runner.CommandRuntimeEnvironment{}, + StdinPolicy: runner.CommandRuntimeStdinPipe, CloseInitialStdin: false, + TimeoutMilliseconds: 10000, + Output: runner.CommandRuntimeOutputPolicy{InlineBytes: 4096, + ArtifactBytes: 4096}, + Network: runner.CommandRuntimeNetworkDisabled, + Credentials: runner.CommandRuntimeCredentialsNone, + Purpose: "prove exact UI evidence cleanup survives lease release", + }}}) + if errors.Is(err, runner.ErrCommandRuntimeUnavailable) { + t.Skipf("%s is unavailable: %v", profile, err) + } + if err != nil || len(started.Jobs) != 1 || + started.Jobs[0].State != runner.CommandRuntimeJobRunning { + t.Fatalf("UI cleanup Job start=%#v err=%v", started, err) + } + jobID := started.Jobs[0].ID + if _, _, err := state.ReleaseRunExecutionLease(ctx, lease); err != nil { + t.Fatal(err) + } + if _, err := service.ExecuteCommandRuntime(ctx, scope, + toolgateway.CommandRuntimeInput{Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionKill, JobID: jobID}); err == nil { + t.Fatal("ordinary command authority killed a Job after its Run lease was released") + } + binding := uiEvidenceCommandCleanupBinding{JobID: jobID, + InvocationID: identity, OperationKey: identity, RunID: runRecord.ID, + MissionID: runRecord.MissionID, SessionID: runRecord.SessionID, + WorkspaceID: "workspace-command-runtime-app", RootAgentID: root.ID, + LeaseID: lease.LeaseID, LeaseGeneration: lease.Generation} + wrong := binding + wrong.OperationKey = identity + "-other" + if _, err := service.cleanupUIEvidenceJob(ctx, wrong); err == nil { + t.Fatal("cleanup-only authority accepted a different operation identity") + } + active, err := manager.Get(ctx, jobID) + if err != nil || active.State != runner.CommandRuntimeJobRunning { + t.Fatalf("mismatched cleanup disturbed Job=%#v err=%v", active, err) + } + cleaned, err := service.cleanupUIEvidenceJob(ctx, binding) + if err != nil || cleaned.State != runner.CommandRuntimeJobKilled || + !cleaned.TreeReaped { + t.Fatalf("exact UI cleanup Job=%#v err=%v", cleaned, err) + } +} + func commandRuntimeTestScope(runRecord domain.Run, root domain.AgentNode, lease domain.RunExecutionLease, operationKey string, ) toolgateway.CommandRuntimeContext { diff --git a/internal/application/ui_evidence.go b/internal/application/ui_evidence.go new file mode 100644 index 00000000..d70e534e --- /dev/null +++ b/internal/application/ui_evidence.go @@ -0,0 +1,1240 @@ +package application + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path/filepath" + "strings" + "sync" + "time" + "unicode/utf8" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/idgen" + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/toolgateway" + "cyberagent-workbench/internal/uievidence" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +const ( + UIEvidenceExecutionTimeout = 30 * time.Minute + uiEvidenceCleanupTimeout = 30 * time.Second + uiEvidencePortReleaseWait = 5 * time.Second +) + +type UIEvidenceStore interface { + GetRun(context.Context, string) (domain.Run, error) + GetMission(context.Context, string) (domain.Mission, error) + GetWorkspaceByID(context.Context, string) (session.WorkspaceRecord, error) + GetRootAgent(context.Context, string) (domain.AgentNode, bool, error) + GetRunExecutionLease(context.Context, string) (domain.RunExecutionLease, bool, error) + CreateUIEvidenceAttempt(context.Context, uievidence.Attempt) ( + uievidence.Attempt, bool, error) + UpdateUIEvidenceAttempt(context.Context, uievidence.Attempt, int64) ( + uievidence.Attempt, error) + GetUIEvidenceAttempt(context.Context, string) (uievidence.Attempt, error) + ListUIEvidenceAttempts(context.Context, uievidence.ListFilter) ( + []uievidence.Attempt, error) + AddUIEvidenceStep(context.Context, uievidence.StepReceipt) error + ListUIEvidenceSteps(context.Context, string) ([]uievidence.StepReceipt, error) + AddUIEvidenceArtifact(context.Context, uievidence.Artifact) error + ListUIEvidenceArtifacts(context.Context, string) ([]uievidence.ArtifactMetadata, error) + GetUIEvidenceArtifact(context.Context, string, string) (uievidence.Artifact, error) + UIEvidenceArtifactTotals(context.Context, string) (int, int64, error) + ReconcileUIEvidenceAttempts(context.Context, time.Time) ([]uievidence.Attempt, error) +} + +type UIEvidenceRuntimeStep struct { + Step uievidence.Step `json:"step"` + Input string `json:"input,omitempty"` +} + +type UIEvidenceStartRequest struct { + RunID string `json:"run_id"` + OperationKey string `json:"operation_key"` + Build *runner.CommandRuntimeSpec `json:"build,omitempty"` + Start runner.CommandRuntimeSpec `json:"start"` + Readiness uievidence.Readiness `json:"readiness"` + URL string `json:"url"` + Route string `json:"route"` + Browser UIEvidenceBrowserSelection `json:"browser"` + Environment uievidence.Environment `json:"environment"` + Fixture uievidence.Fixture `json:"fixture"` + Steps []UIEvidenceRuntimeStep `json:"steps"` + Capture uievidence.CapturePolicy `json:"capture"` + FailurePolicy uievidence.FailurePolicy `json:"failure_policy"` +} + +func (r UIEvidenceStartRequest) Validate() error { + if !domain.ValidAgentID(r.RunID) || r.RunID != strings.TrimSpace(r.RunID) || + r.OperationKey == "" || r.OperationKey != strings.TrimSpace(r.OperationKey) || + len([]byte(r.OperationKey)) > 1024 || !utf8.ValidString(r.OperationKey) || + strings.ContainsRune(r.OperationKey, 0) || redact.String(r.OperationKey) != r.OperationKey || + !validUIEvidenceBrowserSelection(r.Browser) || len(r.Steps) == 0 || + len(r.Steps) > uievidence.MaxSteps || r.Capture.Video || + !r.Capture.Screenshot || !r.Capture.DOM || !r.Capture.Accessibility || + !r.Capture.Console || !r.Capture.Network || !r.Capture.Performance { + return errors.New("UI evidence start request is invalid") + } + if _, err := runner.NormalizeCommandRuntimeIntent(r.Start); err != nil { + return err + } + if uiEvidenceRecipeHasNetworkIntent(r.Start) { + return errors.New("UI evidence start recipe contains network-client intent") + } + if r.Build != nil { + if _, err := runner.NormalizeCommandRuntimeIntent(*r.Build); err != nil { + return err + } + if uiEvidenceRecipeHasNetworkIntent(*r.Build) { + return errors.New("UI evidence build recipe contains network-client intent") + } + } + if r.Readiness.Validate() != nil || r.Environment.Validate() != nil || + r.Fixture.Validate() != nil || r.Capture.Validate() != nil || + r.FailurePolicy.Validate() != nil { + return errors.New("UI evidence request contract is invalid") + } + seen := make(map[string]struct{}, len(r.Steps)) + for index, runtimeStep := range r.Steps { + if runtimeStep.Step.Validate() != nil || + (index == 0 && runtimeStep.Step.Kind != uievidence.StepNavigate) { + return errors.New("UI evidence step contract is invalid") + } + if _, exists := seen[runtimeStep.Step.ID]; exists { + return errors.New("UI evidence step identity is duplicated") + } + seen[runtimeStep.Step.ID] = struct{}{} + if runtimeStep.Step.Kind == uievidence.StepType { + digest, err := uievidence.InputSHA256(runtimeStep.Input) + if err != nil || digest != runtimeStep.Step.InputSHA256 { + return errors.New("UI evidence step input does not match its digest") + } + } else if runtimeStep.Input != "" { + return errors.New("UI evidence non-input step contains raw input") + } + } + return nil +} + +type UIEvidenceBundle struct { + Attempt uievidence.Attempt `json:"attempt"` + Steps []uievidence.StepReceipt `json:"steps"` + Artifacts []uievidence.ArtifactMetadata `json:"artifacts"` +} + +// uiEvidenceCommandRuntime deliberately keeps cleanup authority separate from +// ordinary command authority. Starting, reading, and waiting still require the +// live Run lease through ExecuteCommandRuntime. Cleanup can only reap the exact +// durable Job identity sealed into an Attempt before that lease expired. +type uiEvidenceCommandRuntime interface { + toolgateway.CommandRuntimeExecutor + cleanupUIEvidenceJob(context.Context, uiEvidenceCommandCleanupBinding) ( + runner.CommandRuntimeJobSnapshot, error) +} + +type uiEvidenceCommandCleanupBinding struct { + JobID string + InvocationID string + OperationKey string + RunID string + MissionID string + SessionID string + WorkspaceID string + RootAgentID string + LeaseID string + LeaseGeneration int64 +} + +func (b uiEvidenceCommandCleanupBinding) Validate() error { + for _, value := range []string{b.JobID, b.InvocationID, b.OperationKey, + b.RunID, b.MissionID, b.SessionID, b.WorkspaceID, b.RootAgentID, + b.LeaseID} { + if value == "" || value != strings.TrimSpace(value) || + !utf8.ValidString(value) || len([]rune(value)) > 256 || + strings.ContainsRune(value, 0) { + return errors.New("UI evidence command cleanup binding is invalid") + } + } + if b.LeaseGeneration <= 0 { + return errors.New("UI evidence command cleanup lease generation is invalid") + } + return nil +} + +type UIEvidenceService struct { + store UIEvidenceStore + commands uiEvidenceCommandRuntime + browsers UIEvidenceBrowserProvider + profileRoot string + now func() time.Time + mu sync.Mutex + cancel map[string]context.CancelFunc + wg sync.WaitGroup + closed bool +} + +func NewUIEvidenceService(store UIEvidenceStore, + commands uiEvidenceCommandRuntime, browsers UIEvidenceBrowserProvider, + profileRoot string, +) (*UIEvidenceService, error) { + service, err := NewUIEvidenceReadService(store) + if err != nil { + return nil, err + } + profileRoot = strings.TrimSpace(profileRoot) + if commands == nil || browsers == nil || profileRoot == "" || + !filepath.IsAbs(profileRoot) { + return nil, apperror.New(apperror.CodeFailedPrecondition, + "UI evidence requires store, command runtime, browser runtime, and private Profile root") + } + service.commands = commands + service.browsers = browsers + service.profileRoot = filepath.Clean(profileRoot) + return service, nil +} + +// NewUIEvidenceReadService exposes persisted manifests, receipts, and +// hash-verified artifacts without constructing process, browser, Profile, or +// network authority. It also owns startup reconciliation for interrupted +// attempts when execution capability is disabled on a later launch. +func NewUIEvidenceReadService(store UIEvidenceStore) (*UIEvidenceService, error) { + if store == nil { + return nil, apperror.New(apperror.CodeFailedPrecondition, + "UI evidence read service requires a store") + } + return &UIEvidenceService{store: store, + now: func() time.Time { return time.Now().UTC() }, + cancel: make(map[string]context.CancelFunc)}, nil +} + +func (s *UIEvidenceService) Run(ctx context.Context, + request UIEvidenceStartRequest, +) (uievidence.Attempt, error) { + if err := s.requireExecutionOpen(); err != nil { + return uievidence.Attempt{}, err + } + prepared, existing, err := s.prepare(ctx, request) + if err != nil || existing.Status != "" { + return existing, err + } + attemptID := prepared.attempt.Manifest.AttemptID + runContext, cancel := uiEvidenceExecutionContext(ctx, prepared.lease.ExpiresAt) + s.mu.Lock() + if s.closed { + s.mu.Unlock() + cancel() + return uievidence.Attempt{}, apperror.New(apperror.CodeFailedPrecondition, + "UI evidence service is closed") + } + if _, active := s.cancel[attemptID]; active { + s.mu.Unlock() + cancel() + return uievidence.Attempt{}, apperror.New(apperror.CodeConflict, + "UI evidence attempt is already active") + } + s.cancel[attemptID] = cancel + s.wg.Add(1) + s.mu.Unlock() + defer func() { + cancel() + s.mu.Lock() + delete(s.cancel, attemptID) + s.mu.Unlock() + s.wg.Done() + }() + return s.execute(runContext, prepared) +} + +// Start persists not_run before returning and runs asynchronously under a +// bounded service-owned context. An HTTP request cancellation therefore cannot +// orphan the browser or application process tree. +func (s *UIEvidenceService) Start(ctx context.Context, + request UIEvidenceStartRequest, +) (uievidence.Attempt, error) { + if err := s.requireExecutionOpen(); err != nil { + return uievidence.Attempt{}, err + } + prepared, existing, err := s.prepare(ctx, request) + if err != nil { + return uievidence.Attempt{}, err + } + if existing.Status != "" { + return existing, nil + } + attemptID := prepared.attempt.Manifest.AttemptID + runContext, cancel := uiEvidenceExecutionContext(context.Background(), + prepared.lease.ExpiresAt) + s.mu.Lock() + if s.closed { + s.mu.Unlock() + cancel() + return uievidence.Attempt{}, apperror.New(apperror.CodeFailedPrecondition, + "UI evidence service is closed") + } + if _, active := s.cancel[attemptID]; active { + s.mu.Unlock() + cancel() + return prepared.attempt, nil + } + s.cancel[attemptID] = cancel + s.wg.Add(1) + s.mu.Unlock() + go func() { + defer func() { + cancel() + s.mu.Lock() + delete(s.cancel, attemptID) + s.mu.Unlock() + s.wg.Done() + }() + _, _ = s.execute(runContext, prepared) + }() + return prepared.attempt, nil +} + +func (s *UIEvidenceService) requireExecutionOpen() error { + if s == nil || s.store == nil || s.commands == nil || s.browsers == nil || + strings.TrimSpace(s.profileRoot) == "" { + return apperror.New(apperror.CodeFailedPrecondition, + "UI evidence execution capability is unavailable") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return apperror.New(apperror.CodeFailedPrecondition, + "UI evidence service is closed") + } + return nil +} + +func uiEvidenceExecutionContext(parent context.Context, + leaseExpiresAt time.Time, +) (context.Context, context.CancelFunc) { + deadline := time.Now().UTC().Add(UIEvidenceExecutionTimeout) + if leaseExpiresAt.Before(deadline) { + deadline = leaseExpiresAt.UTC() + } + return context.WithDeadline(parent, deadline) +} + +// Close prevents new asynchronous attempts, cancels every service-owned +// attempt, and waits for their independent process/profile/network cleanup. +func (s *UIEvidenceService) Close(ctx context.Context) error { + if s == nil { + return nil + } + if ctx == nil { + return errors.New("UI evidence close context is required") + } + s.mu.Lock() + s.closed = true + cancellations := make([]context.CancelFunc, 0, len(s.cancel)) + for _, cancel := range s.cancel { + cancellations = append(cancellations, cancel) + } + s.mu.Unlock() + for _, cancel := range cancellations { + cancel() + } + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *UIEvidenceService) Cancel(ctx context.Context, attemptID string) ( + uievidence.Attempt, error, +) { + if s == nil || s.store == nil || !domain.ValidAgentID(strings.TrimSpace(attemptID)) { + return uievidence.Attempt{}, apperror.New(apperror.CodeInvalidArgument, + "UI evidence attempt id is invalid") + } + if attemptID != strings.TrimSpace(attemptID) { + return uievidence.Attempt{}, apperror.New(apperror.CodeInvalidArgument, + "UI evidence attempt id must be normalized") + } + s.mu.Lock() + cancel := s.cancel[attemptID] + s.mu.Unlock() + if cancel != nil { + cancel() + } + deadline := time.NewTimer(uiEvidenceCleanupTimeout) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + attempt, err := s.store.GetUIEvidenceAttempt(ctx, attemptID) + if err != nil { + return uievidence.Attempt{}, err + } + s.mu.Lock() + _, active := s.cancel[attemptID] + s.mu.Unlock() + if attempt.Status.Terminal() || !active { + return attempt, nil + } + select { + case <-ctx.Done(): + return uievidence.Attempt{}, ctx.Err() + case <-deadline.C: + return attempt, apperror.New(apperror.CodeUnavailable, + "UI evidence cancellation cleanup is still pending") + case <-ticker.C: + } + } +} + +func (s *UIEvidenceService) Get(ctx context.Context, attemptID string) ( + UIEvidenceBundle, error, +) { + attempt, err := s.store.GetUIEvidenceAttempt(ctx, attemptID) + if err != nil { + return UIEvidenceBundle{}, err + } + steps, err := s.store.ListUIEvidenceSteps(ctx, attemptID) + if err != nil { + return UIEvidenceBundle{}, err + } + artifacts, err := s.store.ListUIEvidenceArtifacts(ctx, attemptID) + if err != nil { + return UIEvidenceBundle{}, err + } + return UIEvidenceBundle{Attempt: attempt, Steps: steps, Artifacts: artifacts}, nil +} + +func (s *UIEvidenceService) List(ctx context.Context, filter uievidence.ListFilter) ( + []uievidence.Attempt, error, +) { + return s.store.ListUIEvidenceAttempts(ctx, filter) +} + +func (s *UIEvidenceService) Artifact(ctx context.Context, attemptID, + artifactID string, +) (uievidence.Artifact, error) { + return s.store.GetUIEvidenceArtifact(ctx, attemptID, artifactID) +} + +func (s *UIEvidenceService) Reconcile(ctx context.Context) ([]uievidence.Attempt, error) { + return s.store.ReconcileUIEvidenceAttempts(ctx, s.now()) +} + +type preparedUIEvidence struct { + request UIEvidenceStartRequest + attempt uievidence.Attempt + browser UIEvidenceBrowserPreparation + run domain.Run + mission domain.Mission + workspace session.WorkspaceRecord + root domain.AgentNode + lease domain.RunExecutionLease +} + +func (s *UIEvidenceService) prepare(ctx context.Context, + request UIEvidenceStartRequest, +) (preparedUIEvidence, uievidence.Attempt, error) { + if s == nil || s.store == nil || s.commands == nil || s.browsers == nil || + ctx == nil || ctx.Err() != nil || request.Validate() != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, apperror.New( + apperror.CodeInvalidArgument, "UI evidence request is invalid") + } + runRecord, err := s.store.GetRun(ctx, request.RunID) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + mission, err := s.store.GetMission(ctx, runRecord.MissionID) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + workspace, err := s.store.GetWorkspaceByID(ctx, mission.WorkspaceID) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + root, found, err := s.store.GetRootAgent(ctx, runRecord.ID) + if err != nil || !found { + return preparedUIEvidence{}, uievidence.Attempt{}, + apperror.New(apperror.CodeFailedPrecondition, "UI evidence root Agent is unavailable") + } + lease, found, err := s.store.GetRunExecutionLease(ctx, runRecord.ID) + if err != nil || !found || lease.Status != domain.RunExecutionLeaseActive || + !lease.ExpiresAt.After(s.now()) { + return preparedUIEvidence{}, uievidence.Attempt{}, apperror.New( + apperror.CodeFailedPrecondition, "UI evidence requires the active Run execution lease") + } + if runRecord.Status != domain.RunRunning || root.Role != domain.AgentRoleRoot || + root.ParentID != "" || runRecord.SessionID == "" || mission.WorkspaceID == "" { + return preparedUIEvidence{}, uievidence.Attempt{}, apperror.New( + apperror.CodeFailedPrecondition, "UI evidence Run binding is not executable") + } + _, attemptID := uievidence.OperationIdentity(runRecord.ID, request.OperationKey) + createdAt := s.now() + if existing, getErr := s.store.GetUIEvidenceAttempt(ctx, attemptID); getErr == nil { + createdAt = existing.Manifest.CreatedAt + } else if apperror.CodeOf(apperror.Normalize(getErr)) != apperror.CodeNotFound { + return preparedUIEvidence{}, uievidence.Attempt{}, getErr + } + startResolved, err := runner.NormalizeCommandRuntimeSpec(request.Start, workspace.RootPath) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + startRecipe, err := uievidence.CommandRecipeFromResolved(startResolved) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + var buildRecipe *uievidence.CommandRecipe + if request.Build != nil { + resolved, err := runner.NormalizeCommandRuntimeSpec(*request.Build, workspace.RootPath) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + recipe, err := uievidence.CommandRecipeFromResolved(resolved) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + buildRecipe = &recipe + } + snapshot, err := workspacecheckpoint.Capture(ctx, workspacecheckpoint.CaptureRequest{ + ID: "ui-source-" + attemptID[len("ui-attempt-"):], RunID: runRecord.ID, + MissionID: mission.ID, SessionID: runRecord.SessionID, WorkspaceID: mission.WorkspaceID, + WorkspaceRoot: workspace.RootPath, AttemptID: attemptID, + CapabilityGeneration: strings.Repeat("0", 64), Trigger: workspacecheckpoint.TriggerManual, + Phase: workspacecheckpoint.PhaseStandalone, TriggerReceiptID: attemptID, + RequestedBy: "run_supervisor", Title: "UI evidence source binding", CreatedAt: createdAt}) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + source, err := uievidence.BindSource(ctx, workspace.RootPath, snapshot) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + browser, err := s.browsers.Prepare(ctx, request.Browser) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + steps := make([]uievidence.Step, len(request.Steps)) + for index := range request.Steps { + steps[index] = request.Steps[index].Step + } + manifest, err := uievidence.SealManifest(uievidence.Manifest{ + AttemptID: attemptID, RunID: runRecord.ID, MissionID: mission.ID, + SessionID: runRecord.SessionID, WorkspaceID: mission.WorkspaceID, + Source: source, Build: buildRecipe, Start: startRecipe, + Readiness: request.Readiness, Browser: browser.ManifestIdentity, + URL: request.URL, Route: request.Route, Environment: request.Environment, + Fixture: request.Fixture, Steps: steps, Capture: request.Capture, + FailurePolicy: request.FailurePolicy, CreatedAt: createdAt}) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + attempt, err := uievidence.NewAttempt(manifest, request.OperationKey, createdAt) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + stored, replayed, err := s.store.CreateUIEvidenceAttempt(ctx, attempt) + if err != nil { + return preparedUIEvidence{}, uievidence.Attempt{}, err + } + if replayed { + return preparedUIEvidence{}, stored, nil + } + return preparedUIEvidence{request: request, attempt: stored, browser: browser, + run: runRecord, mission: mission, workspace: workspace, root: root, lease: lease}, + uievidence.Attempt{}, nil +} + +type uiEvidenceOutcome struct { + status uievidence.Status + stage uievidence.FailureStage + code string + message string + diagnostics uievidence.DiagnosticsSummary +} + +type uiEvidenceResources struct { + applicationJobs []uiEvidenceCommandCleanupBinding + browser *UIEvidenceBrowserRun + cleanup uievidence.CleanupReceipt +} + +func (s *UIEvidenceService) execute(ctx context.Context, + prepared preparedUIEvidence, +) (uievidence.Attempt, error) { + started, err := uievidence.StartAttempt(prepared.attempt, s.now()) + if err != nil { + return uievidence.Attempt{}, err + } + transitionContext, transitionCancel := context.WithTimeout( + context.Background(), 5*time.Second) + started, err = s.store.UpdateUIEvidenceAttempt(transitionContext, started, + prepared.attempt.Version) + transitionCancel() + if err != nil { + return uievidence.Attempt{}, err + } + prepared.attempt = started + resources := uiEvidenceResources{cleanup: uievidence.CleanupReceipt{ + BrowserTreeReaped: true, ApplicationTreeReaped: true, ProfileRemoved: true, + NetworkReleased: true, PortReleased: true}} + outcome := s.perform(ctx, prepared, &resources) + cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), uiEvidenceCleanupTimeout) + cleanupErr := s.cleanup(cleanupContext, prepared, &resources) + cleanupCancel() + if cleanupErr != nil || !resources.cleanup.Complete() { + outcome.status, outcome.stage, outcome.code = uievidence.StatusFailed, + uievidence.FailureCleanup, "cleanup_incomplete" + outcome.message = errors.Join(errors.New(outcome.message), cleanupErr).Error() + } + // A passing receipt is only valid after every owned process has stopped. + // Rebind the source once more after cleanup so the application cannot mutate + // tracked files between the last browser assertion and process-tree reaping. + if cleanupErr == nil && resources.cleanup.Complete() && + outcome.status == uievidence.StatusPassed { + if sourceErr := s.verifyPreparedSource(ctx, prepared); sourceErr != nil { + status := uievidence.StatusFailed + if errors.Is(ctx.Err(), context.Canceled) { + status = uievidence.StatusCancelled + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + status = uievidence.StatusTimedOut + } + outcome.status, outcome.stage = status, uievidence.FailureAssertion + outcome.code, outcome.message = "source_changed_before_completion", sourceErr.Error() + } + } + completionContext, completionCancel := context.WithTimeout( + context.Background(), 5*time.Second) + defer completionCancel() + count, bytes, totalsErr := s.store.UIEvidenceArtifactTotals(completionContext, + prepared.attempt.Manifest.AttemptID) + if totalsErr != nil { + return uievidence.Attempt{}, totalsErr + } + completed, err := uievidence.CompleteAttempt(started, outcome.status, outcome.stage, + outcome.code, outcome.message, outcome.diagnostics, resources.cleanup, + count, bytes, s.now()) + if err != nil { + return uievidence.Attempt{}, err + } + completed, updateErr := s.store.UpdateUIEvidenceAttempt(completionContext, + completed, started.Version) + return completed, errors.Join(cleanupErr, updateErr) +} + +func (s *UIEvidenceService) perform(ctx context.Context, prepared preparedUIEvidence, + resources *uiEvidenceResources, +) uiEvidenceOutcome { + fail := func(stage uievidence.FailureStage, code string, err error) uiEvidenceOutcome { + status := uievidence.StatusFailed + if errors.Is(ctx.Err(), context.Canceled) { + status = uievidence.StatusCancelled + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + status = uievidence.StatusTimedOut + } + return uiEvidenceOutcome{status: status, stage: stage, code: code, + message: err.Error()} + } + if err := s.verifyPreparedSource(ctx, prepared); err != nil { + return fail(uievidence.FailureBuild, "source_changed_before_build", err) + } + if prepared.request.Build != nil { + job, err := s.startCommand(ctx, prepared, *prepared.request.Build, "build") + if err == nil { + resources.applicationJobs = append(resources.applicationJobs, + s.commandCleanupBinding(prepared, "build", job.ID)) + resources.cleanup.ApplicationTreeReaped = false + job, err = s.waitCommand(ctx, prepared, job.ID, "build:wait") + if job.State.Terminal() && job.TreeReaped { + resources.applicationJobs = resources.applicationJobs[:0] + resources.cleanup.ApplicationTreeReaped = true + } + } + if err != nil || job.State != runner.CommandRuntimeJobCompleted { + if err == nil { + err = fmt.Errorf("build Job ended as %s", job.State) + } + return fail(uievidence.FailureBuild, "build_failed", err) + } + if err := s.verifyPreparedSource(ctx, prepared); err != nil { + return fail(uievidence.FailureBuild, "source_changed_by_build", err) + } + } + if err := ensureUIEvidencePortFree(ctx, prepared.request.Readiness.URL); err != nil { + return fail(uievidence.FailureLaunch, "preexisting_service", err) + } + job, err := s.startCommand(ctx, prepared, prepared.request.Start, "application") + if err != nil { + return fail(uievidence.FailureLaunch, "application_start_failed", err) + } + resources.applicationJobs = append(resources.applicationJobs, + s.commandCleanupBinding(prepared, "application", job.ID)) + resources.cleanup.ApplicationTreeReaped = false + resources.cleanup.PortReleased = false + if err := s.waitReadiness(ctx, prepared, job.ID); err != nil { + return fail(uievidence.FailureReadiness, "readiness_failed", err) + } + if err := s.verifyPreparedSource(ctx, prepared); err != nil { + return fail(uievidence.FailureLaunch, "source_changed_by_start", err) + } + browserRun, err := s.browsers.Open(ctx, prepared.browser, BrowserRuntimeLaunchRequest{ + RunID: prepared.run.ID, Target: prepared.request.URL, ProfileRoot: s.profileRoot, + OperationKey: uiEvidenceRuntimeIdentity(prepared, "browser-prepare"), + LeaseOwnerIdentity: "ui-evidence-runtime", + ReviewerIdentity: "ui-evidence-runtime", + ReviewOperationKey: uiEvidenceRuntimeIdentity(prepared, "browser-review")}) + if err != nil { + return fail(uievidence.FailureLaunch, "browser_launch_failed", err) + } + resources.browser = browserRun + resources.cleanup.BrowserTreeReaped = false + resources.cleanup.ProfileRemoved = false + resources.cleanup.NetworkReleased = false + if err := browserRun.Driver.ConfigureUIEvidence(ctx, + prepared.request.Environment); err != nil { + return fail(uievidence.FailureLaunch, "browser_configuration_failed", err) + } + for index, runtimeStep := range prepared.request.Steps { + stepStarted := s.now() + stage, stepErr := s.performStep(ctx, prepared, browserRun.Driver, runtimeStep) + status := uievidence.StatusPassed + message := "" + if stepErr != nil { + status, message = uiEvidenceStepFailureStatus(ctx, stepErr), stepErr.Error() + } + receipt, receiptErr := uievidence.SealStepReceipt(uievidence.StepReceipt{ + AttemptID: prepared.attempt.Manifest.AttemptID, StepID: runtimeStep.Step.ID, + Sequence: index + 1, Kind: runtimeStep.Step.Kind, Status: status, + FailureStage: stage, Message: message, StartedAt: stepStarted, + CompletedAt: s.now()}) + receiptFailureStage := stage + if receiptFailureStage == uievidence.FailureNone { + receiptFailureStage = uievidence.FailureCapture + } + if receiptErr != nil { + return fail(receiptFailureStage, "step_receipt_failed", receiptErr) + } + receiptContext, receiptCancel := context.WithTimeout(context.Background(), 5*time.Second) + err := s.store.AddUIEvidenceStep(receiptContext, receipt) + receiptCancel() + if err != nil { + return fail(receiptFailureStage, "step_receipt_failed", err) + } + if stepErr != nil { + return fail(stage, "step_failed", stepErr) + } + if runtimeStep.Step.CaptureAfter || runtimeStep.Step.Kind == uievidence.StepCapture { + if err := s.captureVisual(ctx, prepared, browserRun.Driver, + runtimeStep.Step.ID); err != nil { + return fail(uievidence.FailureCapture, "capture_failed", err) + } + } + } + lastRuntimeStep := prepared.request.Steps[len(prepared.request.Steps)-1] + lastStep := lastRuntimeStep.Step.ID + if !lastRuntimeStep.Step.CaptureAfter && lastRuntimeStep.Step.Kind != uievidence.StepCapture { + if err := s.captureVisual(ctx, prepared, browserRun.Driver, lastStep); err != nil { + return fail(uievidence.FailureCapture, "final_capture_failed", err) + } + } + diagnostics, _, err := browserRun.Driver.DiagnosticsUIEvidence(ctx) + if err != nil { + return fail(uievidence.FailureCapture, "diagnostics_failed", err) + } + if err := s.captureDiagnosticArtifacts(ctx, prepared, lastStep, diagnostics); err != nil { + return fail(uievidence.FailureCapture, "final_capture_failed", err) + } + summary := diagnostics.Summary + if summary.ConsoleErrors > 0 || summary.PageErrors > 0 { + return uiEvidenceOutcome{status: uievidence.StatusFailed, + stage: uievidence.FailureConsole, code: "browser_console_failed", + message: "browser console or page errors were observed", diagnostics: summary} + } + if summary.FailedRequests > 0 || summary.HTTPFailures > 0 || summary.BlockedRequests > 0 { + return uiEvidenceOutcome{status: uievidence.StatusFailed, + stage: uievidence.FailureNetwork, code: "browser_network_failed", + message: "failed, blocked, or unsuccessful browser requests were observed", + diagnostics: summary} + } + if err := s.verifyPreparedSource(ctx, prepared); err != nil { + return fail(uievidence.FailureAssertion, "source_changed_during_evidence", err) + } + return uiEvidenceOutcome{status: uievidence.StatusPassed, + stage: uievidence.FailureNone, diagnostics: summary} +} + +func (s *UIEvidenceService) verifyPreparedSource(ctx context.Context, + prepared preparedUIEvidence, +) error { + manifest := prepared.attempt.Manifest + suffix := strings.TrimPrefix(manifest.AttemptID, "ui-attempt-") + snapshot, err := workspacecheckpoint.Capture(ctx, workspacecheckpoint.CaptureRequest{ + ID: "ui-source-verify-" + suffix, RunID: prepared.run.ID, + MissionID: prepared.mission.ID, SessionID: prepared.run.SessionID, + WorkspaceID: prepared.mission.WorkspaceID, WorkspaceRoot: prepared.workspace.RootPath, + AttemptID: manifest.AttemptID, CapabilityGeneration: manifest.Fingerprint, + Trigger: workspacecheckpoint.TriggerManual, Phase: workspacecheckpoint.PhaseStandalone, + TriggerReceiptID: manifest.AttemptID, RequestedBy: "run_supervisor", + Title: "UI evidence source revalidation", CreatedAt: s.now()}) + if err != nil { + return fmt.Errorf("revalidate UI evidence source: %w", err) + } + current, err := uievidence.BindSource(ctx, prepared.workspace.RootPath, snapshot) + if err != nil { + return fmt.Errorf("bind revalidated UI evidence source: %w", err) + } + if current != manifest.Source { + return errors.New("UI evidence source changed after its manifest was sealed") + } + return nil +} + +func (s *UIEvidenceService) performStep(ctx context.Context, + prepared preparedUIEvidence, driver UIEvidenceBrowserDriver, + runtimeStep UIEvidenceRuntimeStep, +) (uievidence.FailureStage, error) { + switch runtimeStep.Step.Kind { + case uievidence.StepNavigate: + _, err := driver.Navigate(ctx, prepared.request.URL) + return stageForError(uievidence.FailureNavigation, err), err + case uievidence.StepClick: + err := driver.ClickUIEvidence(ctx, runtimeStep.Step.Selector) + return stageForError(uievidence.FailureSelector, err), err + case uievidence.StepType: + err := driver.TypeUIEvidence(ctx, runtimeStep.Step.Selector, + runtimeStep.Input, runtimeStep.Step.InputSHA256) + return stageForError(uievidence.FailureSelector, err), err + case uievidence.StepAssertPresent: + err := driver.AssertUIEvidenceSelector(ctx, runtimeStep.Step.Selector, true) + return stageForError(uievidence.FailureAssertion, err), err + case uievidence.StepAssertAbsent: + err := driver.AssertUIEvidenceSelector(ctx, runtimeStep.Step.Selector, false) + return stageForError(uievidence.FailureAssertion, err), err + case uievidence.StepCapture: + return uievidence.FailureNone, nil + default: + return uievidence.FailureAssertion, errors.New("unsupported UI evidence step") + } +} + +func stageForError(stage uievidence.FailureStage, err error) uievidence.FailureStage { + if err == nil { + return uievidence.FailureNone + } + return stage +} + +func uiEvidenceStepFailureStatus(ctx context.Context, err error) uievidence.Status { + if errors.Is(ctx.Err(), context.DeadlineExceeded) || + errors.Is(err, context.DeadlineExceeded) { + return uievidence.StatusTimedOut + } + if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { + return uievidence.StatusCancelled + } + return uievidence.StatusFailed +} + +func (s *UIEvidenceService) captureVisual(ctx context.Context, + prepared preparedUIEvidence, driver UIEvidenceBrowserDriver, stepID string, +) error { + policy := prepared.request.Capture + if policy.Screenshot { + screenshot, width, height, err := driver.ScreenshotUIEvidence(ctx, + policy.MaskSelectors, prepared.request.Environment.Viewport.DPR) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactScreenshot, + screenshot.MediaType, screenshot.PNG, len(policy.MaskSelectors) > 0, + width, height, screenshot.CompletedAt); err != nil { + return err + } + } + if policy.DOM { + capture, err := driver.DOMUIEvidence(ctx) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactDOM, + capture.MIME, capture.Content, capture.Redacted, 0, 0, capture.CapturedAt); err != nil { + return err + } + } + if policy.Accessibility { + capture, err := driver.AccessibilityUIEvidence(ctx) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactAccessibility, + capture.MIME, capture.Content, capture.Redacted, 0, 0, capture.CapturedAt); err != nil { + return err + } + } + if policy.Performance { + capture, err := driver.PerformanceUIEvidence(ctx) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactPerformance, + capture.MIME, capture.Content, capture.Redacted, 0, 0, capture.CapturedAt); err != nil { + return err + } + } + return nil +} + +func (s *UIEvidenceService) captureDiagnosticArtifacts(ctx context.Context, + prepared preparedUIEvidence, stepID string, + diagnostics browserruntime.UIEvidenceDiagnostics, +) error { + policy := prepared.request.Capture + if policy.Console { + content, err := json.Marshal(struct { + Console []browserruntime.UIEvidenceConsoleEntry `json:"console"` + PageErrors []browserruntime.UIEvidencePageError `json:"page_errors"` + Summary uievidence.DiagnosticsSummary `json:"summary"` + UntrustedEvidence bool `json:"untrusted_evidence"` + CapturedAt time.Time `json:"captured_at"` + }{Console: diagnostics.Console, PageErrors: diagnostics.PageErrors, + Summary: diagnostics.Summary, UntrustedEvidence: true, + CapturedAt: diagnostics.CapturedAt}) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactConsole, + "application/json", content, true, 0, 0, + diagnostics.CapturedAt); err != nil { + return err + } + } + if policy.Network { + content, err := json.Marshal(struct { + Network []browserruntime.UIEvidenceNetworkEntry `json:"network"` + Summary uievidence.DiagnosticsSummary `json:"summary"` + UntrustedEvidence bool `json:"untrusted_evidence"` + CapturedAt time.Time `json:"captured_at"` + }{Network: diagnostics.Network, Summary: diagnostics.Summary, + UntrustedEvidence: true, CapturedAt: diagnostics.CapturedAt}) + if err != nil { + return err + } + if err := s.addArtifact(ctx, prepared, stepID, uievidence.ArtifactNetwork, + "application/json", content, true, 0, 0, + diagnostics.CapturedAt); err != nil { + return err + } + } + return nil +} + +func (s *UIEvidenceService) addArtifact(ctx context.Context, + prepared preparedUIEvidence, stepID string, kind uievidence.ArtifactKind, + mime string, content []byte, redacted bool, width, height int, at time.Time, +) error { + artifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: idgen.New("ui_artifact"), AttemptID: prepared.attempt.Manifest.AttemptID, + RunID: prepared.run.ID, StepID: stepID, Kind: kind, MIME: mime, + Width: width, Height: height, Viewport: prepared.request.Environment.Viewport, + SourceCommit: prepared.attempt.Manifest.Source.Commit, + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, Redacted: redacted, + Untrusted: true, CreatedAt: at.UTC()}, content) + if err != nil { + return err + } + return s.store.AddUIEvidenceArtifact(ctx, artifact) +} + +func (s *UIEvidenceService) cleanup(ctx context.Context, + prepared preparedUIEvidence, resources *uiEvidenceResources, +) error { + var cleanupErrors []error + if resources.browser != nil { + receipt, err := s.browsers.Close(ctx, resources.browser) + if err != nil { + cleanupErrors = append(cleanupErrors, err) + } + resources.cleanup.BrowserTreeReaped = receipt.ProcessTreeQuiescent + resources.cleanup.ProfileRemoved = receipt.ProfileReleased && receipt.ProfileCleaned + resources.cleanup.NetworkReleased = receipt.NetworkCleanupVerified + } + if len(resources.applicationJobs) > 0 { + allReaped := true + for index := len(resources.applicationJobs) - 1; index >= 0; index-- { + job, err := s.stopCommand(ctx, resources.applicationJobs[index]) + if err != nil { + cleanupErrors = append(cleanupErrors, err) + } + allReaped = allReaped && err == nil && job.TreeReaped + } + resources.cleanup.ApplicationTreeReaped = allReaped + } + // Port release is an ownership assertion, not a general availability check. + // A pre-existing listener rejected before application start is intentionally + // left alone and must not turn the stable launch failure into cleanup_failed. + if !resources.cleanup.PortReleased { + if err := waitUIEvidencePortReleased(ctx, prepared.request.Readiness.URL); err != nil { + cleanupErrors = append(cleanupErrors, err) + resources.cleanup.PortReleased = false + } else { + resources.cleanup.PortReleased = true + } + } + return errors.Join(cleanupErrors...) +} + +func (s *UIEvidenceService) commandScope(prepared preparedUIEvidence, + suffix string, +) toolgateway.CommandRuntimeContext { + identity := uiEvidenceRuntimeIdentity(prepared, suffix) + return toolgateway.CommandRuntimeContext{ + InvocationID: identity, + OperationKey: identity, + RunID: prepared.run.ID, RootAgentID: prepared.root.ID, + SessionID: prepared.run.SessionID, WorkspaceID: prepared.mission.WorkspaceID, + CapabilityGeneration: prepared.attempt.Manifest.Fingerprint, + LeaseID: prepared.lease.LeaseID, LeaseGeneration: prepared.lease.Generation, + RequestedBy: "run_supervisor", PolicyDecision: toolgateway.Decision{ + Allowed: true, Approval: toolgateway.ApprovalAutomatic, Risk: "high", + Reason: "exact ui-evidence.v1 recipe passed the Run execution boundary"}} +} + +func (s *UIEvidenceService) startCommand(ctx context.Context, + prepared preparedUIEvidence, spec runner.CommandRuntimeSpec, suffix string, +) (runner.CommandRuntimeJobSnapshot, error) { + result, err := s.commands.ExecuteCommandRuntime(ctx, s.commandScope(prepared, suffix), + toolgateway.CommandRuntimeInput{Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionStart, + Commands: []runner.CommandRuntimeSpec{spec}}) + if err != nil || len(result.Jobs) != 1 { + if err == nil { + err = errors.New("command runtime did not return one Job") + } + return runner.CommandRuntimeJobSnapshot{}, err + } + return result.Jobs[0], nil +} + +func (s *UIEvidenceService) waitCommand(ctx context.Context, + prepared preparedUIEvidence, jobID, suffix string, +) (runner.CommandRuntimeJobSnapshot, error) { + for { + cursor, maxBytes, wait := uint64(0), runner.MinCommandRuntimeOutputRead, 1000 + result, err := s.commands.ExecuteCommandRuntime(ctx, s.commandScope(prepared, suffix), + toolgateway.CommandRuntimeInput{Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionWait, JobID: jobID, + Cursor: &cursor, MaxBytes: &maxBytes, WaitMilliseconds: &wait}) + if err != nil || len(result.Jobs) != 1 { + if err == nil { + err = errors.New("command runtime wait returned no Job") + } + return runner.CommandRuntimeJobSnapshot{}, err + } + if result.Jobs[0].State.Terminal() { + return result.Jobs[0], nil + } + } +} + +func uiEvidenceRuntimeIdentity(prepared preparedUIEvidence, suffix string) string { + return prepared.attempt.Manifest.AttemptID + ":" + suffix +} + +func (s *UIEvidenceService) commandCleanupBinding(prepared preparedUIEvidence, + suffix, jobID string, +) uiEvidenceCommandCleanupBinding { + identity := uiEvidenceRuntimeIdentity(prepared, suffix) + return uiEvidenceCommandCleanupBinding{JobID: jobID, + InvocationID: identity, OperationKey: identity, + RunID: prepared.run.ID, MissionID: prepared.mission.ID, + SessionID: prepared.run.SessionID, WorkspaceID: prepared.mission.WorkspaceID, + RootAgentID: prepared.root.ID, LeaseID: prepared.lease.LeaseID, + LeaseGeneration: prepared.lease.Generation} +} + +func (s *UIEvidenceService) readCommand(ctx context.Context, + prepared preparedUIEvidence, jobID string, +) (runner.CommandRuntimeJobSnapshot, error) { + cursor, maxBytes, wait := uint64(0), runner.MinCommandRuntimeOutputRead, 0 + result, err := s.commands.ExecuteCommandRuntime(ctx, + s.commandScope(prepared, "readiness-read"), toolgateway.CommandRuntimeInput{ + Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionRead, JobID: jobID, + Cursor: &cursor, MaxBytes: &maxBytes, WaitMilliseconds: &wait}) + if err != nil || len(result.Jobs) != 1 { + if err == nil { + err = errors.New("command runtime read returned no Job") + } + return runner.CommandRuntimeJobSnapshot{}, err + } + return result.Jobs[0], nil +} + +func (s *UIEvidenceService) stopCommand(ctx context.Context, + binding uiEvidenceCommandCleanupBinding, +) (runner.CommandRuntimeJobSnapshot, error) { + return s.commands.cleanupUIEvidenceJob(ctx, binding) +} + +func (s *UIEvidenceService) waitReadiness(ctx context.Context, + prepared preparedUIEvidence, jobID string, +) error { + contract := prepared.request.Readiness + deadline := time.NewTimer(time.Duration(contract.TimeoutMilliseconds) * time.Millisecond) + defer deadline.Stop() + ticker := time.NewTicker(time.Duration(contract.IntervalMilliseconds) * time.Millisecond) + defer ticker.Stop() + client, err := newUIEvidenceHTTPClient(contract.URL) + if err != nil { + return err + } + for { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, contract.URL, nil) + if err != nil { + return err + } + response, requestErr := client.Do(request) + ready := false + if requestErr == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1024)) + _ = response.Body.Close() + for _, expected := range contract.ExpectedStatus { + if response.StatusCode == expected { + ready = true + break + } + } + } + job, readErr := s.readCommand(ctx, prepared, jobID) + if readErr != nil { + return readErr + } + if job.State.Terminal() { + return fmt.Errorf("application Job exited before readiness with state %s", job.State) + } + if ready { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("UI evidence readiness deadline expired") + case <-ticker.C: + } + } +} + +func newUIEvidenceHTTPClient(rawURL string) (*http.Client, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + expected := parsed.Host + dialer := &net.Dialer{Timeout: time.Second} + transport := &http.Transport{Proxy: nil, DisableKeepAlives: true, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || !strings.EqualFold(address, expected) { + return nil, errors.New("UI evidence readiness attempted to leave its literal loopback endpoint") + } + return dialer.DialContext(ctx, network, address) + }} + return &http.Client{Transport: transport, Timeout: 2 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errors.New("UI evidence readiness redirects are forbidden") + }}, nil +} + +func ensureUIEvidencePortFree(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return err + } + connection, err := (&net.Dialer{Timeout: 200 * time.Millisecond}). + DialContext(ctx, "tcp", parsed.Host) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if uiEvidenceConnectionRefused(err) { + return nil + } + return fmt.Errorf("UI evidence cannot prove the application port is free: %w", err) + } + _ = connection.Close() + return errors.New("UI evidence refuses to adopt a pre-existing service") +} + +func waitUIEvidencePortReleased(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return err + } + deadline := time.NewTimer(uiEvidencePortReleaseWait) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + connection, dialErr := (&net.Dialer{Timeout: 100 * time.Millisecond}). + DialContext(ctx, "tcp", parsed.Host) + if dialErr != nil && ctx.Err() != nil { + return ctx.Err() + } + if uiEvidenceConnectionRefused(dialErr) { + return nil + } + if connection != nil { + _ = connection.Close() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("UI evidence application port remained open after cleanup") + case <-ticker.C: + } + } +} + +func uiEvidenceRecipeHasNetworkIntent(spec runner.CommandRuntimeSpec) bool { + value := strings.ToLower(spec.Script + " " + spec.Executable + " " + + strings.Join(spec.Arguments, " ")) + base := strings.ToLower(filepath.Base(spec.Executable)) + for _, executable := range []string{"curl", "curl.exe", "wget", "wget.exe", + "ssh", "ssh.exe", "scp", "scp.exe", "sftp", "ftp", "nc", "netcat", + "nmap", "telnet", "ping", "ping.exe"} { + if base == executable { + return true + } + } + for _, marker := range []string{"http://", "https://", "invoke-webrequest", + "invoke-restmethod", "test-netconnection", "start-bitstransfer", "git clone", + "git fetch", "git pull", "git push", "git ls-remote", "npm install", + "pnpm install", "yarn install", "go get", "cargo install", "pip install"} { + if strings.Contains(value, marker) { + return true + } + } + return false +} + +var _ toolgateway.CommandRuntimeExecutor = (*CommandRuntimeService)(nil) +var _ UIEvidenceBrowserDriver = (*browserruntime.RestrictedBrowserSession)(nil) diff --git a/internal/application/ui_evidence_browser.go b/internal/application/ui_evidence_browser.go new file mode 100644 index 00000000..21c89f89 --- /dev/null +++ b/internal/application/ui_evidence_browser.go @@ -0,0 +1,159 @@ +package application + +import ( + "context" + "errors" + "fmt" + "strings" + + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/uievidence" +) + +type UIEvidenceBrowserSelection struct { + Product browserruntime.BrowserProduct `json:"product"` + Channel browserruntime.BrowserChannel `json:"channel"` +} + +type UIEvidenceBrowserPreparation struct { + ManifestIdentity uievidence.BrowserIdentity + Identity browserruntime.BrowserExecutableIdentity + Acceptance browserruntime.BrowserAcceptanceCandidate +} + +type UIEvidenceBrowserDriver interface { + ConfigureUIEvidence(context.Context, uievidence.Environment) error + Navigate(context.Context, string) (browserruntime.RestrictedNavigationResult, error) + ClickUIEvidence(context.Context, string) error + TypeUIEvidence(context.Context, string, string, string) error + AssertUIEvidenceSelector(context.Context, string, bool) error + DOMUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) + AccessibilityUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) + PerformanceUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) + DiagnosticsUIEvidence(context.Context) (browserruntime.UIEvidenceDiagnostics, + browserruntime.UIEvidenceTextCapture, error) + ScreenshotUIEvidence(context.Context, []string, float64) ( + browserruntime.RestrictedScreenshot, int, int, error) +} + +type UIEvidenceBrowserRun struct { + Driver UIEvidenceBrowserDriver + handle *BrowserRuntimeHandle +} + +type UIEvidenceBrowserProvider interface { + Prepare(context.Context, UIEvidenceBrowserSelection) (UIEvidenceBrowserPreparation, error) + Open(context.Context, UIEvidenceBrowserPreparation, BrowserRuntimeLaunchRequest) ( + *UIEvidenceBrowserRun, error) + Close(context.Context, *UIEvidenceBrowserRun) (browserruntime.BrowserRuntimeReceipt, error) +} + +// SafeWebUIEvidenceBrowserProvider is the production adapter. Discovery uses +// only the fixed OS installation registry; Open revalidates the exact bytes +// before entering the reviewed Safe Web/WFP/Job/Profile launch path. +type SafeWebUIEvidenceBrowserProvider struct { + runtime *BrowserRuntimeService +} + +func NewSafeWebUIEvidenceBrowserProvider(runtime *BrowserRuntimeService) ( + *SafeWebUIEvidenceBrowserProvider, error, +) { + if runtime == nil { + return nil, errors.New("UI evidence browser runtime is required") + } + return &SafeWebUIEvidenceBrowserProvider{runtime: runtime}, nil +} + +func (p *SafeWebUIEvidenceBrowserProvider) Prepare(ctx context.Context, + selection UIEvidenceBrowserSelection, +) (UIEvidenceBrowserPreparation, error) { + if p == nil || p.runtime == nil || !validUIEvidenceBrowserSelection(selection) { + return UIEvidenceBrowserPreparation{}, errors.New("UI evidence browser selection is invalid") + } + if ctx == nil { + return UIEvidenceBrowserPreparation{}, errors.New("UI evidence browser preparation context is required") + } + if err := ctx.Err(); err != nil { + return UIEvidenceBrowserPreparation{}, err + } + identities, err := browserruntime.DiscoverInstalledBrowsers() + if err != nil { + return UIEvidenceBrowserPreparation{}, err + } + for _, identity := range identities { + if identity.Product != selection.Product || identity.Channel != selection.Channel { + continue + } + if !identity.VersionVerified || strings.TrimSpace(identity.Version) == "" { + return UIEvidenceBrowserPreparation{}, errors.New( + "UI evidence requires a browser with a verified version") + } + acceptance, err := browserruntime.BuildBrowserAcceptanceCandidate(identity) + if err != nil { + return UIEvidenceBrowserPreparation{}, err + } + if !acceptance.ReviewEligible { + return UIEvidenceBrowserPreparation{}, fmt.Errorf( + "UI evidence browser publisher is not eligible: %s", acceptance.ReasonCode) + } + manifestIdentity := uievidence.BrowserIdentity{Product: string(identity.Product), + Version: identity.Version, ExecutableSHA256: identity.ExecutableSHA256, + DriverProtocol: uievidence.DriverProtocolVersion, Headless: true, + TemporaryProfile: true} + if err := manifestIdentity.Validate(); err != nil { + return UIEvidenceBrowserPreparation{}, err + } + return UIEvidenceBrowserPreparation{ManifestIdentity: manifestIdentity, + Identity: identity, Acceptance: acceptance}, nil + } + return UIEvidenceBrowserPreparation{}, errors.New( + "selected UI evidence browser is not installed in a fixed trusted location") +} + +func (p *SafeWebUIEvidenceBrowserProvider) Open(ctx context.Context, + preparation UIEvidenceBrowserPreparation, request BrowserRuntimeLaunchRequest, +) (*UIEvidenceBrowserRun, error) { + if p == nil || p.runtime == nil || preparation.ManifestIdentity.Validate() != nil || + browserruntime.ValidateBrowserExecutableIdentity(preparation.Identity) != nil || + browserruntime.ValidateBrowserAcceptanceCandidate(preparation.Acceptance, + preparation.Identity) != nil || + preparation.ManifestIdentity.Product != string(preparation.Identity.Product) || + preparation.ManifestIdentity.Version != preparation.Identity.Version || + preparation.ManifestIdentity.ExecutableSHA256 != preparation.Identity.ExecutableSHA256 { + return nil, errors.New("UI evidence browser preparation is invalid") + } + request.Identity = preparation.Identity + request.Acceptance = preparation.Acceptance + handle, err := p.runtime.LaunchUIEvidence(ctx, request) + if err != nil { + return nil, err + } + if handle == nil || handle.UIEvidence == nil { + if handle != nil { + _, _ = p.runtime.Close(context.Background(), handle) + } + return nil, errors.New("UI evidence browser opened without its restricted driver") + } + return &UIEvidenceBrowserRun{Driver: handle.UIEvidence, handle: handle}, nil +} + +func (p *SafeWebUIEvidenceBrowserProvider) Close(ctx context.Context, + run *UIEvidenceBrowserRun, +) (browserruntime.BrowserRuntimeReceipt, error) { + if p == nil || p.runtime == nil || run == nil || run.handle == nil { + return browserruntime.BrowserRuntimeReceipt{}, errors.New( + "UI evidence browser run is unavailable") + } + return p.runtime.Close(ctx, run.handle) +} + +func validUIEvidenceBrowserSelection(value UIEvidenceBrowserSelection) bool { + if value.Channel != browserruntime.BrowserChannelStable && + value.Channel != browserruntime.BrowserChannelBeta && + value.Channel != browserruntime.BrowserChannelDev && + value.Channel != browserruntime.BrowserChannelCanary { + return false + } + return value.Product == browserruntime.BrowserProductChrome || + value.Product == browserruntime.BrowserProductEdge +} diff --git a/internal/application/ui_evidence_port_other.go b/internal/application/ui_evidence_port_other.go new file mode 100644 index 00000000..2e2750e9 --- /dev/null +++ b/internal/application/ui_evidence_port_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package application + +import ( + "errors" + "syscall" +) + +func uiEvidenceConnectionRefused(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) +} diff --git a/internal/application/ui_evidence_port_windows.go b/internal/application/ui_evidence_port_windows.go new file mode 100644 index 00000000..640f7467 --- /dev/null +++ b/internal/application/ui_evidence_port_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package application + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func uiEvidenceConnectionRefused(err error) bool { + return errors.Is(err, windows.WSAECONNREFUSED) +} diff --git a/internal/application/ui_evidence_test.go b/internal/application/ui_evidence_test.go new file mode 100644 index 00000000..01e65021 --- /dev/null +++ b/internal/application/ui_evidence_test.go @@ -0,0 +1,948 @@ +package application + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "image" + "image/color" + "image/png" + "math" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/session" + "cyberagent-workbench/internal/toolgateway" + "cyberagent-workbench/internal/uievidence" +) + +func TestUIEvidenceServiceRunsRealLoopbackReadinessAndCleansEveryResource(t *testing.T) { + for _, test := range []struct { + name string + diagnostics uievidence.DiagnosticsSummary + wantStatus uievidence.Status + wantStage uievidence.FailureStage + }{ + {name: "pass", wantStatus: uievidence.StatusPassed, + wantStage: uievidence.FailureNone}, + {name: "console failure", + diagnostics: uievidence.DiagnosticsSummary{ConsoleErrors: 1}, + wantStatus: uievidence.StatusFailed, wantStage: uievidence.FailureConsole}, + {name: "page failure", + diagnostics: uievidence.DiagnosticsSummary{PageErrors: 1}, + wantStatus: uievidence.StatusFailed, wantStage: uievidence.FailureConsole}, + {name: "request failure", + diagnostics: uievidence.DiagnosticsSummary{FailedRequests: 1}, + wantStatus: uievidence.StatusFailed, wantStage: uievidence.FailureNetwork}, + {name: "HTTP status failure", + diagnostics: uievidence.DiagnosticsSummary{HTTPFailures: 1}, + wantStatus: uievidence.StatusFailed, wantStage: uievidence.FailureNetwork}, + {name: "blocked request", + diagnostics: uievidence.DiagnosticsSummary{BlockedRequests: 1}, + wantStatus: uievidence.StatusFailed, wantStage: uievidence.FailureNetwork}, + } { + t.Run(test.name, func(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + driver := &fakeUIEvidenceDriver{diagnostics: test.diagnostics} + browsers := &fakeUIEvidenceBrowsers{driver: driver} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + service.now = monotonicUIEvidenceClock(time.Date(2026, 8, 20, 1, 0, 0, 0, time.UTC)) + request := validUIEvidenceServiceRequest(t, port) + attempt, err := service.Run(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if attempt.Status != test.wantStatus || attempt.FailureStage != test.wantStage || + !attempt.Cleanup.Complete() || !commands.killed || !browsers.closed { + t.Fatalf("attempt=%+v killed=%t browser_closed=%t", + attempt, commands.killed, browsers.closed) + } + if test.wantStatus == uievidence.StatusPassed && !attempt.Status.Passed() { + t.Fatal("clean real-readiness run did not produce pass") + } + if len(state.steps) != len(request.Steps) || len(state.artifacts) < 4 { + t.Fatalf("steps=%d artifacts=%d", len(state.steps), len(state.artifacts)) + } + if driver.diagnosticCalls != 1 { + t.Fatalf("diagnostic snapshots=%d, want exactly one", driver.diagnosticCalls) + } + if connection, dialErr := net.DialTimeout("tcp", + net.JoinHostPort("127.0.0.1", fmtInt(port)), 100*time.Millisecond); dialErr == nil { + _ = connection.Close() + t.Fatal("application port remained open") + } + }) + } +} + +func TestUIEvidenceRequestRejectsNetworkClientAndSecretInput(t *testing.T) { + request := validUIEvidenceServiceRequest(t, reserveUIEvidencePort(t)) + request.Start.Script = "curl https://example.invalid" + if request.Validate() == nil { + t.Fatal("network-client recipe was accepted") + } + request = validUIEvidenceServiceRequest(t, reserveUIEvidencePort(t)) + value := "token=abcdefghijklmnopqrstuvwxyz1234567890" + digest := sha256.Sum256([]byte(value)) + request.Steps = append(request.Steps, UIEvidenceRuntimeStep{Step: uievidence.Step{ + ID: "type-secret", Kind: uievidence.StepType, Selector: "input", + InputSHA256: hex.EncodeToString(digest[:])}, Input: value}) + if request.Validate() == nil { + t.Fatal("secret-like fixture input was accepted") + } + request = validUIEvidenceServiceRequest(t, reserveUIEvidencePort(t)) + request.Capture.Accessibility = false + if request.Validate() == nil { + t.Fatal("incomplete screenshot/DOM/a11y/diagnostic evidence policy was accepted") + } +} + +func TestUIEvidenceUsesBoundedAttemptIdentityForMaximumOperationKey(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + request := validUIEvidenceServiceRequest(t, port) + request.OperationKey = strings.Repeat("x", 1024) + if err := request.Validate(); err != nil { + t.Fatalf("maximum UI evidence operation key rejected: %v", err) + } + attempt, err := service.Run(t.Context(), request) + if err != nil || attempt.Status != uievidence.StatusPassed || + !attempt.Cleanup.Complete() { + t.Fatalf("maximum operation-key attempt=%+v err=%v", attempt, err) + } +} + +func TestUIEvidenceStepReceiptFailureRemainsAValidFailClosedTerminalOutcome(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + state.stepErr = errors.New("step ledger unavailable") + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureCapture || + attempt.FailureCode != "step_receipt_failed" || !attempt.Cleanup.Complete() { + t.Fatalf("receipt failure attempt=%+v err=%v", attempt, err) + } +} + +func TestUIEvidenceReadServiceKeepsHistoryVisibleWithoutExecutionAuthority(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + execution, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := execution.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusPassed { + t.Fatalf("execution attempt=%+v err=%v", attempt, err) + } + + readOnly, err := NewUIEvidenceReadService(state) + if err != nil { + t.Fatal(err) + } + values, err := readOnly.List(t.Context(), uievidence.ListFilter{ + RunID: attempt.Manifest.RunID, Limit: 10}) + if err != nil || len(values) != 1 || values[0].Manifest.Fingerprint != + attempt.Manifest.Fingerprint { + t.Fatalf("read-only list=%+v err=%v", values, err) + } + bundle, err := readOnly.Get(t.Context(), attempt.Manifest.AttemptID) + if err != nil || len(bundle.Steps) == 0 || len(bundle.Artifacts) == 0 { + t.Fatalf("read-only bundle=%+v err=%v", bundle, err) + } + artifact, err := readOnly.Artifact(t.Context(), attempt.Manifest.AttemptID, + bundle.Artifacts[0].ID) + if err != nil || artifact.Validate() != nil || !artifact.Metadata.Untrusted { + t.Fatalf("read-only artifact=%+v err=%v", artifact.Metadata, err) + } + if _, err := readOnly.Start(t.Context(), validUIEvidenceServiceRequest(t, + reserveUIEvidencePort(t))); apperror.CodeOf(err) != apperror.CodeFailedPrecondition { + t.Fatalf("read-only service started execution: %v", err) + } +} + +func TestUIEvidenceRejectsPreexistingServiceWithoutAdoptingOrStoppingIt(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureLaunch || + attempt.FailureCode != "preexisting_service" || !attempt.Cleanup.Complete() { + t.Fatalf("preexisting-service attempt=%+v err=%v", attempt, err) + } + if commands.killed || browsers.closed { + t.Fatalf("foreign service was treated as owned: killed=%t browser_closed=%t", + commands.killed, browsers.closed) + } + connection, err := net.DialTimeout("tcp", listener.Addr().String(), + 100*time.Millisecond) + if err != nil { + t.Fatalf("pre-existing listener was disturbed: %v", err) + } + _ = connection.Close() +} + +func TestUIEvidenceAsyncCancelReapsOwnedResourcesAndClosesExecution(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + driver := &fakeUIEvidenceDriver{blockNavigation: true, + navigationStarted: make(chan struct{})} + browsers := &fakeUIEvidenceBrowsers{driver: driver} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + request := validUIEvidenceServiceRequest(t, port) + created, err := service.Start(t.Context(), request) + if err != nil || created.Status != uievidence.StatusNotRun { + t.Fatalf("started=%+v err=%v", created, err) + } + select { + case <-driver.navigationStarted: + case <-time.After(5 * time.Second): + t.Fatal("asynchronous UI evidence did not reach real navigation") + } + cancelContext, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + completed, err := service.Cancel(cancelContext, created.Manifest.AttemptID) + if err != nil || completed.Status != uievidence.StatusCancelled || + completed.FailureStage != uievidence.FailureNavigation || + !completed.Cleanup.Complete() || !commands.killed || !browsers.closed { + t.Fatalf("cancelled=%+v killed=%t browser_closed=%t err=%v", completed, + commands.killed, browsers.closed, err) + } + if len(state.steps) != 1 || state.steps[0].Status != uievidence.StatusCancelled { + t.Fatalf("cancelled step receipts=%+v", state.steps) + } + if err := service.Close(cancelContext); err != nil { + t.Fatal(err) + } + request.OperationKey = "ui-evidence-after-close" + if _, err := service.Start(t.Context(), request); apperror.CodeOf(err) != + apperror.CodeFailedPrecondition { + t.Fatalf("closed UI evidence service accepted execution: %v", err) + } +} + +func TestUIEvidenceDeadlineReapsOwnedResourcesAndRecordsTimedOut(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + driver := &fakeUIEvidenceDriver{blockNavigation: true, + navigationStarted: make(chan struct{})} + browsers := &fakeUIEvidenceBrowsers{driver: driver} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + deadlineContext, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + attempt, err := service.Run(deadlineContext, + validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusTimedOut || + attempt.FailureStage != uievidence.FailureNavigation || + !attempt.Cleanup.Complete() || !commands.killed || !browsers.closed { + t.Fatalf("timed-out=%+v killed=%t browser_closed=%t err=%v", attempt, + commands.killed, browsers.closed, err) + } + if len(state.steps) != 1 || state.steps[0].Status != uievidence.StatusTimedOut { + t.Fatalf("timed-out step receipts=%+v", state.steps) + } +} + +func TestUIEvidenceDeadlineReapsBuildJobBeforeApplicationLaunch(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &blockingBuildUIEvidenceCommands{} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + request := validUIEvidenceServiceRequest(t, port) + build := request.Start + build.Purpose = "build deterministic fixture" + request.Build = &build + deadlineContext, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + attempt, err := service.Run(deadlineContext, request) + if err != nil || attempt.Status != uievidence.StatusTimedOut || + attempt.FailureStage != uievidence.FailureBuild || + attempt.FailureCode != "build_failed" || !attempt.Cleanup.Complete() || + !commands.Killed() || browsers.closed { + t.Fatalf("build timeout=%+v killed=%t browser_closed=%t err=%v", attempt, + commands.Killed(), browsers.closed, err) + } +} + +func TestUIEvidenceCloseCancelsAndWaitsForSynchronousRun(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + driver := &fakeUIEvidenceDriver{blockNavigation: true, + navigationStarted: make(chan struct{})} + browsers := &fakeUIEvidenceBrowsers{driver: driver} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + request := validUIEvidenceServiceRequest(t, port) + result := make(chan uievidence.Attempt, 1) + errors := make(chan error, 1) + go func() { + attempt, runErr := service.Run(context.Background(), request) + result <- attempt + errors <- runErr + }() + select { + case <-driver.navigationStarted: + case <-time.After(5 * time.Second): + t.Fatal("synchronous UI evidence did not reach navigation") + } + closeContext, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if err := service.Close(closeContext); err != nil { + t.Fatal(err) + } + attempt := <-result + if err := <-errors; err != nil || attempt.Status != uievidence.StatusCancelled || + !attempt.Cleanup.Complete() || !commands.killed || !browsers.closed { + t.Fatalf("closed synchronous attempt=%+v err=%v", attempt, err) + } +} + +func TestSafeWebUIEvidenceBrowserPrepareHonorsCancelledContext(t *testing.T) { + provider := &SafeWebUIEvidenceBrowserProvider{runtime: &BrowserRuntimeService{}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := provider.Prepare(ctx, UIEvidenceBrowserSelection{ + Product: browserruntime.BrowserProductEdge, Channel: browserruntime.BrowserChannelStable}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Prepare error = %v, want context cancellation", err) + } +} + +func TestUIEvidenceReadinessRejectsExpectedResponseFromTerminalApplicationJob(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port, terminalOnRead: true} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureReadiness || + attempt.FailureCode != "readiness_failed" || !attempt.Cleanup.Complete() { + t.Fatalf("terminal-readiness attempt=%+v err=%v", attempt, err) + } + if browsers.closed { + t.Fatal("browser was launched after the application Job became terminal") + } +} + +func TestUIEvidenceRefusesPassWhenCleanupCannotBeProven(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}, + incompleteCleanup: true} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), + validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureCleanup || + attempt.FailureCode != "cleanup_incomplete" || attempt.Status.Passed() { + t.Fatalf("cleanup-failure attempt=%+v err=%v", attempt, err) + } +} + +func TestUIEvidenceFailsWhenSourceChangesDuringRealPageEvidence(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + driver := &fakeUIEvidenceDriver{onNavigate: func() { + if err := os.WriteFile(filepath.Join(root, "fixture.txt"), + []byte("changed during UI evidence\n"), 0o600); err != nil { + t.Error(err) + } + }} + browsers := &fakeUIEvidenceBrowsers{driver: driver} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureAssertion || + attempt.FailureCode != "source_changed_during_evidence" || + !attempt.Cleanup.Complete() { + t.Fatalf("source-change attempt=%+v err=%v", attempt, err) + } +} + +func TestUIEvidenceFailsWhenSourceChangesDuringCleanupBeforeCompletion(t *testing.T) { + root := newUIEvidenceGitWorkspace(t) + port := reserveUIEvidencePort(t) + state := newFakeUIEvidenceStore(root) + commands := &fakeUIEvidenceCommands{port: port} + browsers := &fakeUIEvidenceBrowsers{driver: &fakeUIEvidenceDriver{}, + onClose: func() { + if err := os.WriteFile(filepath.Join(root, "fixture.txt"), + []byte("changed while owned resources were closing\n"), 0o600); err != nil { + t.Error(err) + } + }} + service, err := NewUIEvidenceService(state, commands, browsers, + filepath.Join(t.TempDir(), "profiles")) + if err != nil { + t.Fatal(err) + } + attempt, err := service.Run(t.Context(), validUIEvidenceServiceRequest(t, port)) + if err != nil || attempt.Status != uievidence.StatusFailed || + attempt.FailureStage != uievidence.FailureAssertion || + attempt.FailureCode != "source_changed_before_completion" || + !attempt.Cleanup.Complete() { + t.Fatalf("cleanup source-change attempt=%+v err=%v", attempt, err) + } +} + +type fakeUIEvidenceStore struct { + mu sync.Mutex + run domain.Run + mission domain.Mission + workspace session.WorkspaceRecord + root domain.AgentNode + lease domain.RunExecutionLease + attempts map[string]uievidence.Attempt + operations map[string]string + steps []uievidence.StepReceipt + artifacts []uievidence.Artifact + stepErr error +} + +func newFakeUIEvidenceStore(rootPath string) *fakeUIEvidenceStore { + now := time.Now().UTC() + return &fakeUIEvidenceStore{ + run: domain.Run{ID: "run-ui-evidence", MissionID: "mission-ui-evidence", + SessionID: "session-ui-evidence", Status: domain.RunRunning}, + mission: domain.Mission{ID: "mission-ui-evidence", WorkspaceID: "workspace-ui-evidence"}, + workspace: session.WorkspaceRecord{ID: "workspace-ui-evidence", Name: "ui", + RootPath: rootPath, CreatedAt: now}, + root: domain.AgentNode{ID: "agent-ui-root", RunID: "run-ui-evidence", + SessionID: "session-ui-evidence", Role: domain.AgentRoleRoot}, + lease: domain.RunExecutionLease{RunID: "run-ui-evidence", LeaseID: "lease-ui-evidence", + OwnerID: "agent-ui-root", Generation: 1, Status: domain.RunExecutionLeaseActive, + AcquiredAt: now, RenewedAt: now, ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)}, + attempts: make(map[string]uievidence.Attempt), operations: make(map[string]string)} +} + +func (s *fakeUIEvidenceStore) GetRun(context.Context, string) (domain.Run, error) { return s.run, nil } +func (s *fakeUIEvidenceStore) GetMission(context.Context, string) (domain.Mission, error) { + return s.mission, nil +} +func (s *fakeUIEvidenceStore) GetWorkspaceByID(context.Context, string) (session.WorkspaceRecord, error) { + return s.workspace, nil +} +func (s *fakeUIEvidenceStore) GetRootAgent(context.Context, string) (domain.AgentNode, bool, error) { + return s.root, true, nil +} +func (s *fakeUIEvidenceStore) GetRunExecutionLease(context.Context, string) (domain.RunExecutionLease, bool, error) { + return s.lease, true, nil +} +func (s *fakeUIEvidenceStore) CreateUIEvidenceAttempt(_ context.Context, attempt uievidence.Attempt) (uievidence.Attempt, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if id := s.operations[attempt.OperationDigest]; id != "" { + existing := s.attempts[id] + if existing.RequestFingerprint != attempt.RequestFingerprint { + return uievidence.Attempt{}, false, errors.New("operation conflict") + } + return existing, true, nil + } + s.attempts[attempt.Manifest.AttemptID] = attempt + s.operations[attempt.OperationDigest] = attempt.Manifest.AttemptID + return attempt, false, nil +} +func (s *fakeUIEvidenceStore) UpdateUIEvidenceAttempt(_ context.Context, attempt uievidence.Attempt, version int64) (uievidence.Attempt, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.attempts[attempt.Manifest.AttemptID].Version != version { + return uievidence.Attempt{}, errors.New("version conflict") + } + s.attempts[attempt.Manifest.AttemptID] = attempt + return attempt, nil +} +func (s *fakeUIEvidenceStore) GetUIEvidenceAttempt(_ context.Context, id string) (uievidence.Attempt, error) { + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.attempts[id] + if !ok { + return uievidence.Attempt{}, apperror.New(apperror.CodeNotFound, "not found") + } + return value, nil +} +func (s *fakeUIEvidenceStore) ListUIEvidenceAttempts(_ context.Context, + filter uievidence.ListFilter, +) ([]uievidence.Attempt, error) { + s.mu.Lock() + defer s.mu.Unlock() + values := make([]uievidence.Attempt, 0, len(s.attempts)) + for _, attempt := range s.attempts { + if filter.RunID != "" && attempt.Manifest.RunID != filter.RunID { + continue + } + if filter.Status != "" && attempt.Status != filter.Status { + continue + } + values = append(values, attempt) + } + return values, nil +} +func (s *fakeUIEvidenceStore) AddUIEvidenceStep(ctx context.Context, value uievidence.StepReceipt) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.stepErr != nil { + return s.stepErr + } + s.steps = append(s.steps, value) + return nil +} +func (s *fakeUIEvidenceStore) ListUIEvidenceSteps(context.Context, string) ([]uievidence.StepReceipt, error) { + return append([]uievidence.StepReceipt(nil), s.steps...), nil +} +func (s *fakeUIEvidenceStore) AddUIEvidenceArtifact(_ context.Context, value uievidence.Artifact) error { + s.mu.Lock() + defer s.mu.Unlock() + s.artifacts = append(s.artifacts, value) + return nil +} +func (s *fakeUIEvidenceStore) ListUIEvidenceArtifacts(context.Context, string) ([]uievidence.ArtifactMetadata, error) { + values := make([]uievidence.ArtifactMetadata, len(s.artifacts)) + for i := range s.artifacts { + values[i] = s.artifacts[i].Metadata + } + return values, nil +} +func (s *fakeUIEvidenceStore) GetUIEvidenceArtifact(_ context.Context, _, id string) (uievidence.Artifact, error) { + for _, value := range s.artifacts { + if value.Metadata.ID == id { + return value, nil + } + } + return uievidence.Artifact{}, errors.New("not found") +} +func (s *fakeUIEvidenceStore) UIEvidenceArtifactTotals(context.Context, string) (int, int64, error) { + var size int64 + for _, value := range s.artifacts { + size += int64(len(value.Content)) + } + return len(s.artifacts), size, nil +} +func (s *fakeUIEvidenceStore) ReconcileUIEvidenceAttempts(context.Context, time.Time) ([]uievidence.Attempt, error) { + return nil, nil +} + +type fakeUIEvidenceCommands struct { + mu sync.Mutex + port int + server *http.Server + listener net.Listener + killed bool + terminalOnRead bool +} + +type blockingBuildUIEvidenceCommands struct { + mu sync.Mutex + killed bool +} + +func (c *blockingBuildUIEvidenceCommands) ExecuteCommandRuntime(ctx context.Context, + scope toolgateway.CommandRuntimeContext, input toolgateway.CommandRuntimeInput, +) (toolgateway.CommandRuntimeExecutionResult, error) { + if err := scope.Validate(); err != nil { + return toolgateway.CommandRuntimeExecutionResult{}, err + } + result := toolgateway.CommandRuntimeExecutionResult{Backend: "fake-build", Action: input.Action} + job := runner.CommandRuntimeJobSnapshot{ID: "command-job-ui-build", + State: runner.CommandRuntimeJobRunning, Profile: inputProfile(input), + Network: runner.CommandRuntimeNetworkDisabled, + Credentials: runner.CommandRuntimeCredentialsNone, Version: 1, + CreatedAt: time.Now().UTC()} + switch input.Action { + case toolgateway.CommandRuntimeActionStart: + result.Jobs = []runner.CommandRuntimeJobSnapshot{job} + return result, nil + case toolgateway.CommandRuntimeActionWait: + <-ctx.Done() + return result, ctx.Err() + case toolgateway.CommandRuntimeActionKill: + c.mu.Lock() + c.killed = true + c.mu.Unlock() + job.State, job.TreeReaped = runner.CommandRuntimeJobKilled, true + result.Jobs = []runner.CommandRuntimeJobSnapshot{job} + return result, nil + default: + return result, errors.New("unexpected build command action") + } +} + +func (c *blockingBuildUIEvidenceCommands) cleanupUIEvidenceJob(_ context.Context, + _ uiEvidenceCommandCleanupBinding, +) (runner.CommandRuntimeJobSnapshot, error) { + c.mu.Lock() + c.killed = true + c.mu.Unlock() + return runner.CommandRuntimeJobSnapshot{ID: "command-job-ui-build", + State: runner.CommandRuntimeJobKilled, TreeReaped: true}, nil +} + +func (c *blockingBuildUIEvidenceCommands) Killed() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.killed +} + +func (c *fakeUIEvidenceCommands) ExecuteCommandRuntime(_ context.Context, + scope toolgateway.CommandRuntimeContext, input toolgateway.CommandRuntimeInput, +) (toolgateway.CommandRuntimeExecutionResult, error) { + if err := scope.Validate(); err != nil { + return toolgateway.CommandRuntimeExecutionResult{}, err + } + c.mu.Lock() + defer c.mu.Unlock() + result := toolgateway.CommandRuntimeExecutionResult{Backend: "fake", Action: input.Action} + job := runner.CommandRuntimeJobSnapshot{ID: "command-job-ui-application", + State: runner.CommandRuntimeJobRunning, Profile: inputProfile(input), + Network: runner.CommandRuntimeNetworkDisabled, + Credentials: runner.CommandRuntimeCredentialsNone, Version: 1, + CreatedAt: time.Now().UTC()} + switch input.Action { + case toolgateway.CommandRuntimeActionStart: + listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", fmtInt(c.port))) + if err != nil { + return result, err + } + c.listener = listener + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fixture")) }) + c.server = &http.Server{Handler: mux} + go func() { _ = c.server.Serve(listener) }() + case toolgateway.CommandRuntimeActionRead, toolgateway.CommandRuntimeActionWait: + if input.Action == toolgateway.CommandRuntimeActionRead && c.terminalOnRead { + job.State = runner.CommandRuntimeJobFailed + } + case toolgateway.CommandRuntimeActionKill: + if c.server != nil { + _ = c.server.Close() + } + if c.listener != nil { + _ = c.listener.Close() + } + c.killed = true + job.State, job.TreeReaped = runner.CommandRuntimeJobKilled, true + default: + return result, errors.New("unsupported fake command action") + } + result.Jobs = []runner.CommandRuntimeJobSnapshot{job} + return result, nil +} + +func (c *fakeUIEvidenceCommands) cleanupUIEvidenceJob(_ context.Context, + _ uiEvidenceCommandCleanupBinding, +) (runner.CommandRuntimeJobSnapshot, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.server != nil { + _ = c.server.Close() + } + if c.listener != nil { + _ = c.listener.Close() + } + c.killed = true + return runner.CommandRuntimeJobSnapshot{ID: "command-job-ui-application", + State: runner.CommandRuntimeJobKilled, TreeReaped: true}, nil +} + +func inputProfile(input toolgateway.CommandRuntimeInput) runner.CommandRuntimeProfile { + if len(input.Commands) == 1 { + return input.Commands[0].Profile + } + return runner.CommandRuntimeProcess +} + +type fakeUIEvidenceBrowsers struct { + driver *fakeUIEvidenceDriver + closed bool + incompleteCleanup bool + onClose func() +} + +func (b *fakeUIEvidenceBrowsers) Prepare(context.Context, UIEvidenceBrowserSelection) (UIEvidenceBrowserPreparation, error) { + return UIEvidenceBrowserPreparation{ManifestIdentity: uievidence.BrowserIdentity{ + Product: "edge", Version: "1.2.3", ExecutableSHA256: uiEvidenceTestDigest("browser"), + DriverProtocol: uievidence.DriverProtocolVersion, Headless: true, + TemporaryProfile: true}}, nil +} +func (b *fakeUIEvidenceBrowsers) Open(context.Context, UIEvidenceBrowserPreparation, BrowserRuntimeLaunchRequest) (*UIEvidenceBrowserRun, error) { + return &UIEvidenceBrowserRun{Driver: b.driver}, nil +} +func (b *fakeUIEvidenceBrowsers) Close(context.Context, *UIEvidenceBrowserRun) (browserruntime.BrowserRuntimeReceipt, error) { + b.closed = true + if b.onClose != nil { + b.onClose() + } + if b.incompleteCleanup { + return browserruntime.BrowserRuntimeReceipt{ProcessTreeQuiescent: true, + NetworkCleanupVerified: true, ProfileReleased: true}, nil + } + return browserruntime.BrowserRuntimeReceipt{ProcessTreeQuiescent: true, NetworkCleanupVerified: true, ProfileReleased: true, ProfileCleaned: true}, nil +} + +type fakeUIEvidenceDriver struct { + diagnostics uievidence.DiagnosticsSummary + diagnosticCalls int + environment uievidence.Environment + blockNavigation bool + navigationStarted chan struct{} + navigationOnce sync.Once + onNavigate func() +} + +func (d *fakeUIEvidenceDriver) ConfigureUIEvidence(_ context.Context, environment uievidence.Environment) error { + d.environment = environment + return nil +} + +func (d *fakeUIEvidenceDriver) Navigate(ctx context.Context, _ string) (browserruntime.RestrictedNavigationResult, error) { + if d.onNavigate != nil { + d.onNavigate() + } + if d.blockNavigation { + d.navigationOnce.Do(func() { close(d.navigationStarted) }) + <-ctx.Done() + return browserruntime.RestrictedNavigationResult{}, ctx.Err() + } + return browserruntime.RestrictedNavigationResult{}, nil +} +func (*fakeUIEvidenceDriver) ClickUIEvidence(context.Context, string) error { return nil } +func (*fakeUIEvidenceDriver) TypeUIEvidence(context.Context, string, string, string) error { + return nil +} +func (*fakeUIEvidenceDriver) AssertUIEvidenceSelector(context.Context, string, bool) error { + return nil +} +func (*fakeUIEvidenceDriver) DOMUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) { + return fakeTextCapture("
fixture
"), nil +} +func (*fakeUIEvidenceDriver) AccessibilityUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) { + return fakeTextCapture(`{"nodes":[]}`), nil +} +func (*fakeUIEvidenceDriver) PerformanceUIEvidence(context.Context) (browserruntime.UIEvidenceTextCapture, error) { + return fakeTextCapture(`{"metrics":[]}`), nil +} +func (d *fakeUIEvidenceDriver) DiagnosticsUIEvidence(context.Context) (browserruntime.UIEvidenceDiagnostics, browserruntime.UIEvidenceTextCapture, error) { + d.diagnosticCalls++ + return browserruntime.UIEvidenceDiagnostics{Summary: d.diagnostics, UntrustedEvidence: true, CapturedAt: time.Now().UTC()}, fakeTextCapture(`{"console":[],"network":[]}`), nil +} +func (d *fakeUIEvidenceDriver) ScreenshotUIEvidence(context.Context, []string, float64) (browserruntime.RestrictedScreenshot, int, int, error) { + value := uiEvidenceTestPNG() + width := int(math.Round(float64(d.environment.Viewport.Width) * d.environment.Viewport.DPR)) + height := int(math.Round(float64(d.environment.Viewport.Height) * d.environment.Viewport.DPR)) + return browserruntime.RestrictedScreenshot{MediaType: "image/png", PNG: value, CompletedAt: time.Now().UTC()}, width, height, nil +} + +func fakeTextCapture(value string) browserruntime.UIEvidenceTextCapture { + return browserruntime.UIEvidenceTextCapture{MIME: "application/json", Content: []byte(value), Redacted: true, CapturedAt: time.Now().UTC()} +} + +func validUIEvidenceServiceRequest(t *testing.T, port int) UIEvidenceStartRequest { + t.Helper() + spec := runner.CommandRuntimeSpec{Version: runner.CommandRuntimeProtocolVersion, + WorkingDirectory: ".", Environment: []runner.CommandRuntimeEnvironment{}, + StdinPolicy: runner.CommandRuntimeStdinClosed, CloseInitialStdin: true, + TimeoutMilliseconds: 60000, Output: runner.CommandRuntimeOutputPolicy{ + InlineBytes: 4096, ArtifactBytes: 4096}, + Network: runner.CommandRuntimeNetworkDisabled, + Credentials: runner.CommandRuntimeCredentialsNone, Purpose: "serve deterministic fixture"} + if runtime.GOOS == "windows" { + spec.Profile, spec.Script = runner.CommandRuntimePowerShell, "Write-Output fixture" + } else { + spec.Profile, spec.Script = runner.CommandRuntimeBash, "printf fixture" + } + base := "http://127.0.0.1:" + fmtInt(port) + return UIEvidenceStartRequest{RunID: "run-ui-evidence", + OperationKey: "ui-evidence-test-operation", Start: spec, + Readiness: uievidence.Readiness{URL: base + "/health", Method: "GET", + ExpectedStatus: []int{200}, TimeoutMilliseconds: 5000, + IntervalMilliseconds: 25}, URL: base + "/", Route: "/", + Browser: UIEvidenceBrowserSelection{Product: browserruntime.BrowserProductEdge, + Channel: browserruntime.BrowserChannelStable}, + Environment: uievidence.Environment{Viewport: uievidence.Viewport{ + Width: 1280, Height: 720, DPR: 1}, Locale: "en-US", + Theme: uievidence.ThemeLight, ReducedMotion: true}, + Fixture: uievidence.Fixture{Name: "deterministic fixture", Seed: "42", + PageState: "ready", DataSHA256: uiEvidenceTestDigest("fixture"), + Deterministic: true, Synthetic: true}, + Steps: []UIEvidenceRuntimeStep{{Step: uievidence.Step{ID: "navigate", + Kind: uievidence.StepNavigate}}}, + Capture: uievidence.CapturePolicy{Screenshot: true, DOM: true, + Accessibility: true, Console: true, Network: true, Performance: true, + MaskSelectors: []string{}}, + FailurePolicy: uievidence.FailurePolicy{FailOnConsoleError: true, + FailOnPageError: true, FailOnRequestError: true, FailOnHTTPStatus: true}} +} + +func newUIEvidenceGitWorkspace(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git unavailable") + } + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "fixture.txt"), []byte("fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"init", "-q"}, {"config", "user.email", "ui@example.invalid"}, + {"config", "user.name", "UI Test"}, {"add", "."}, {"commit", "-m", "fixture"}} { + command := exec.Command("git", append([]string{"-C", root}, args...)...) + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + return root +} + +func reserveUIEvidencePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + _ = listener.Close() + return port +} +func fmtInt(value int) string { + const digits = "0123456789" + if value == 0 { + return "0" + } + buffer := make([]byte, 0, 8) + for value > 0 { + buffer = append(buffer, digits[value%10]) + value /= 10 + } + for i, j := 0, len(buffer)-1; i < j; i, j = i+1, j-1 { + buffer[i], buffer[j] = buffer[j], buffer[i] + } + return string(buffer) +} +func uiEvidenceTestDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} +func uiEvidenceTestPNG() []byte { + canvas := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 2; x++ { + canvas.Set(x, y, color.White) + } + } + var output bytes.Buffer + _ = png.Encode(&output, canvas) + return output.Bytes() +} +func monotonicUIEvidenceClock(start time.Time) func() time.Time { + var mu sync.Mutex + current := start + return func() time.Time { + mu.Lock() + defer mu.Unlock() + current = current.Add(time.Millisecond) + return current + } +} + +var _ UIEvidenceStore = (*fakeUIEvidenceStore)(nil) +var _ toolgateway.CommandRuntimeExecutor = (*fakeUIEvidenceCommands)(nil) +var _ UIEvidenceBrowserProvider = (*fakeUIEvidenceBrowsers)(nil) +var _ UIEvidenceBrowserDriver = (*fakeUIEvidenceDriver)(nil) diff --git a/internal/browserruntime/browser_process.go b/internal/browserruntime/browser_process.go index 4b53f4f5..5216b493 100644 --- a/internal/browserruntime/browser_process.go +++ b/internal/browserruntime/browser_process.go @@ -381,7 +381,7 @@ func fixedRestrictedBrowserArguments(profilePath string) []string { "--metrics-recording-only", "--password-store=basic", "--no-proxy-server", - "--host-resolver-rules=MAP * ~NOTFOUND", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE ::1", "about:blank", } } diff --git a/internal/browserruntime/browser_process_windows.go b/internal/browserruntime/browser_process_windows.go index 36a6e54a..50cdc082 100644 --- a/internal/browserruntime/browser_process_windows.go +++ b/internal/browserruntime/browser_process_windows.go @@ -153,17 +153,21 @@ func (windowsBrowserProcessStarter) Start(ctx context.Context, windows.CloseHandle(job) return nil, browserProcessStartStageFailure("command_prepare", ErrBrowserRuntimeBoundary) } - environment, err := browserEnvironmentBlock(spec.ProfilePath) - if err != nil { - windows.CloseHandle(job) - return nil, browserProcessStartStageFailure("environment_prepare", err) - } launchAuthority, err := acquireWindowsBrowserLaunchAuthority() if err != nil { windows.CloseHandle(job) return nil, browserProcessStartStageFailure("authority_acquire", err) } defer launchAuthority.Close() + environmentToken := windows.GetCurrentProcessToken() + if launchAuthority.asUser { + environmentToken = launchAuthority.token + } + environment, err := browserEnvironmentBlock(spec.ProfilePath, environmentToken) + if err != nil { + windows.CloseHandle(job) + return nil, browserProcessStartStageFailure("environment_prepare", err) + } startup := windows.StartupInfoEx{ StartupInfo: windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfoEx{}))}, ProcThreadAttributeList: attributes.List(), @@ -643,25 +647,28 @@ func validateBrowserEnvironmentDirectories(profilePath string) error { return nil } -func browserEnvironmentBlock(profilePath string) ([]uint16, error) { +func browserEnvironmentBlock(profilePath string, token windows.Token) ([]uint16, error) { systemRoot := strings.TrimSpace(os.Getenv("SystemRoot")) if systemRoot == "" || !filepath.IsAbs(systemRoot) || !profilePathHasNoIndirection(systemRoot) { return nil, errors.New("windows system root is unavailable") } - values := []string{ - "APPDATA=" + filepath.Join(profilePath, "RoamingAppData"), - "HOME=" + profilePath, - "LOCALAPPDATA=" + filepath.Join(profilePath, "LocalAppData"), - "SystemRoot=" + systemRoot, - "TEMP=" + filepath.Join(profilePath, "Temp"), - "TMP=" + filepath.Join(profilePath, "Temp"), - "USERPROFILE=" + profilePath, - "WINDIR=" + systemRoot, + // Windows known-folder resolution depends on the environment associated + // with the launch token. Replacing USERPROFILE/APPDATA/LOCALAPPDATA with + // arbitrary directories makes PathService treat the user-data directory as + // unknown, which Chromium deliberately handles as the default profile and + // therefore refuses for remote debugging. Start from CreateEnvironmentBlock + // without inheriting the caller, then retain only non-secret structural + // variables. The browser data and temporary directories remain disposable. + tokenEnvironment, err := token.Environ(false) + if err != nil { + return nil, err } - sort.Slice(values, func(left int, right int) bool { - return strings.ToLower(values[left]) < strings.ToLower(values[right]) - }) - block := make([]uint16, 0, 1024) + values, err := restrictedBrowserEnvironmentValues(profilePath, systemRoot, + tokenEnvironment) + if err != nil { + return nil, err + } + block := make([]uint16, 0, 2048) for _, value := range values { block = append(block, utf16.Encode([]rune(value))...) block = append(block, 0) @@ -669,6 +676,74 @@ func browserEnvironmentBlock(profilePath string) ([]uint16, error) { return append(block, 0), nil } +func restrictedBrowserEnvironmentValues(profilePath string, systemRoot string, + tokenEnvironment []string, +) ([]string, error) { + valuesByName := make(map[string]string) + for _, entry := range tokenEnvironment { + separator := strings.IndexByte(entry, '=') + if separator <= 0 { + continue + } + name := strings.ToUpper(entry[:separator]) + if !browserStructuralEnvironmentNameAllowed(name) { + continue + } + value := entry[separator+1:] + if value == "" || strings.ContainsRune(value, 0) { + continue + } + if _, duplicate := valuesByName[name]; duplicate { + return nil, errors.New("windows user environment contains a duplicate structural variable") + } + valuesByName[name] = value + } + for _, name := range []string{"APPDATA", "LOCALAPPDATA", "USERPROFILE"} { + value := valuesByName[name] + if value == "" || !filepath.IsAbs(value) { + return nil, errors.New("windows user environment is missing a known-folder variable") + } + } + if systemRoot == "" || !filepath.IsAbs(systemRoot) || strings.ContainsRune(systemRoot, 0) || + profilePath == "" || !filepath.IsAbs(profilePath) || strings.ContainsRune(profilePath, 0) { + return nil, ErrBrowserRuntimeBoundary + } + system32 := filepath.Join(systemRoot, "System32") + temporaryDirectory := filepath.Join(profilePath, "Temp") + valuesByName["COMSPEC"] = filepath.Join(system32, "cmd.exe") + valuesByName["HOME"] = profilePath + valuesByName["PATH"] = system32 + ";" + systemRoot + valuesByName["PATHEXT"] = ".COM;.EXE;.BAT;.CMD" + valuesByName["SYSTEMROOT"] = systemRoot + valuesByName["TEMP"] = temporaryDirectory + valuesByName["TMP"] = temporaryDirectory + valuesByName["WINDIR"] = systemRoot + values := make([]string, 0, len(valuesByName)) + for name, value := range valuesByName { + values = append(values, name+"="+value) + } + sort.Slice(values, func(left int, right int) bool { + return strings.ToLower(values[left]) < strings.ToLower(values[right]) + }) + return values, nil +} + +func browserStructuralEnvironmentNameAllowed(name string) bool { + switch name { + case "ALLUSERSPROFILE", "APPDATA", "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", "COMMONPROGRAMW6432", "COMPUTERNAME", + "DRIVERDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", + "NUMBER_OF_PROCESSORS", "OS", "PROCESSOR_ARCHITECTURE", + "PROCESSOR_IDENTIFIER", "PROCESSOR_LEVEL", "PROCESSOR_REVISION", + "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", + "PUBLIC", "SYSTEMDRIVE", "USERDOMAIN", "USERDOMAIN_ROAMINGPROFILE", + "USERNAME", "USERPROFILE": + return true + default: + return false + } +} + func waitBrowserJobReaped(job windows.Handle, maximum time.Duration) bool { deadline := time.Now().Add(maximum) for time.Now().Before(deadline) { diff --git a/internal/browserruntime/browser_process_windows_test.go b/internal/browserruntime/browser_process_windows_test.go new file mode 100644 index 00000000..cb2abdec --- /dev/null +++ b/internal/browserruntime/browser_process_windows_test.go @@ -0,0 +1,88 @@ +//go:build windows + +package browserruntime + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRestrictedBrowserEnvironmentUsesKnownFoldersWithoutProcessSecrets( + t *testing.T, +) { + profilePath := filepath.Join(`C:\evidence`, "profile") + systemRoot := `C:\Windows` + values, err := restrictedBrowserEnvironmentValues(profilePath, systemRoot, + []string{ + `USERPROFILE=C:\Users\fixture`, + `LOCALAPPDATA=C:\Users\fixture\AppData\Local`, + `APPDATA=C:\Users\fixture\AppData\Roaming`, + `USERNAME=fixture`, + `USERDOMAIN=WORKSTATION`, + `HOMEDRIVE=C:`, + `HOMEPATH=\Users\fixture`, + `ProgramData=C:\ProgramData`, + `Path=C:\untrusted-bin`, + `PATHEXT=.UNTRUSTED`, + `SystemRoot=C:\tampered`, + `SECRET_TOKEN=must-not-reach-browser`, + }) + if err != nil { + t.Fatal(err) + } + environment := make(map[string]string, len(values)) + for index, entry := range values { + if index > 0 && strings.ToLower(values[index-1]) > strings.ToLower(entry) { + t.Fatal("browser environment block is not sorted case-insensitively") + } + separator := strings.IndexByte(entry, '=') + if separator <= 0 { + t.Fatalf("invalid browser environment entry %q", entry) + } + environment[entry[:separator]] = entry[separator+1:] + } + if _, present := environment["SECRET_TOKEN"]; present { + t.Fatal("process secret escaped into the browser environment") + } + for name, want := range map[string]string{ + "APPDATA": `C:\Users\fixture\AppData\Roaming`, + "LOCALAPPDATA": `C:\Users\fixture\AppData\Local`, + "USERPROFILE": `C:\Users\fixture`, + "HOME": profilePath, + "TEMP": filepath.Join(profilePath, "Temp"), + "TMP": filepath.Join(profilePath, "Temp"), + "SYSTEMROOT": systemRoot, + "WINDIR": systemRoot, + "PATH": filepath.Join(systemRoot, "System32") + ";" + systemRoot, + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + } { + if got := environment[name]; got != want { + t.Fatalf("browser environment %s=%q, want %q", name, got, want) + } + } +} + +func TestRestrictedBrowserEnvironmentRequiresTokenKnownFolders(t *testing.T) { + _, err := restrictedBrowserEnvironmentValues(`C:\evidence\profile`, + `C:\Windows`, []string{ + `USERPROFILE=C:\Users\fixture`, + `APPDATA=C:\Users\fixture\AppData\Roaming`, + }) + if err == nil { + t.Fatal("browser environment accepted a missing LOCALAPPDATA known folder") + } +} + +func TestRestrictedBrowserEnvironmentRejectsDuplicateStructuralVariable(t *testing.T) { + _, err := restrictedBrowserEnvironmentValues(`C:\evidence\profile`, + `C:\Windows`, []string{ + `USERPROFILE=C:\Users\fixture`, + `LOCALAPPDATA=C:\Users\fixture\AppData\Local`, + `APPDATA=C:\Users\fixture\AppData\Roaming`, + `appdata=C:\duplicate`, + }) + if err == nil { + t.Fatal("browser environment accepted a duplicate structural variable") + } +} diff --git a/internal/browserruntime/production_runtime_test.go b/internal/browserruntime/production_runtime_test.go index 71584e4f..3d65aaf8 100644 --- a/internal/browserruntime/production_runtime_test.go +++ b/internal/browserruntime/production_runtime_test.go @@ -288,6 +288,49 @@ func TestDisposableProfileMaterializeReleaseCleanupAndRecovery(t *testing.T) { } } +func TestFixedRestrictedBrowserArgumentsDefaultDenyExceptLiteralLoopback( + t *testing.T, +) { + arguments := fixedRestrictedBrowserArguments(filepath.Join("direct", "profile")) + want := "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE ::1" + count := 0 + for _, argument := range arguments { + if strings.HasPrefix(argument, "--host-resolver-rules=") { + count++ + if argument != want { + t.Fatalf("resolver rule=%q, want exact loopback-only rule", argument) + } + } + } + if count != 1 { + t.Fatalf("resolver rule count=%d, want 1", count) + } +} + +func TestRemoveProfileTreeBoundedRetriesTransientWindowsStyleSharingFailure(t *testing.T) { + profile := filepath.Join(t.TempDir(), "owned-profile") + if err := os.Mkdir(profile, 0o700); err != nil { + t.Fatal(err) + } + calls := 0 + remove := func(path string) error { + calls++ + if calls == 1 { + return &os.PathError{Op: "unlinkat", Path: path, Err: os.ErrPermission} + } + return os.RemoveAll(path) + } + if err := removeProfileTreeBounded(profile, time.Second, remove); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("bounded Profile cleanup calls=%d, want 2", calls) + } + if _, err := os.Lstat(profile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("bounded Profile cleanup left its exact target: %v", err) + } +} + func TestDisposableProfileRefusesForeignMarker(t *testing.T) { facts := newLoopbackBrowserRuntimeFacts(t) lease := facts.materialize(t) diff --git a/internal/browserruntime/profile_materializer.go b/internal/browserruntime/profile_materializer.go index f3bb1525..9d6d16f4 100644 --- a/internal/browserruntime/profile_materializer.go +++ b/internal/browserruntime/profile_materializer.go @@ -18,6 +18,8 @@ const ( ProfileMarkerProtocolVersion = "browser_profile_marker.v1" ProfileRuntimeLeaseProtocolVersion = "browser_profile_runtime_lease.v1" MaxProfileMarkerBytes = 16 * 1024 + profileCleanupRetryTimeout = 5 * time.Second + profileCleanupRetryInterval = 25 * time.Millisecond ) var profileEnvironmentDirectoryNames = [...]string{"Temp", "LocalAppData", "RoamingAppData"} @@ -260,15 +262,43 @@ func CleanupReleasedProfile(authorization BrowserStartAuthorization, _ = os.Rename(quarantinePath, ownership.DirectoryPath) return errors.New("renamed browser Profile became an indirect path") } - if err := os.RemoveAll(quarantinePath); err != nil { + if err := removeProfileTreeBounded(quarantinePath, profileCleanupRetryTimeout, + os.RemoveAll); err != nil { return fmt.Errorf("remove exact released browser Profile: %w", err) } - if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { - return errors.New("released browser Profile cleanup did not remove the exact directory") - } return nil } +func removeProfileTreeBounded(path string, timeout time.Duration, + remove func(string) error, +) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || filepath.Dir(path) == path || + timeout <= 0 || remove == nil { + return errors.New("browser Profile cleanup retry target is invalid") + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(profileCleanupRetryInterval) + defer ticker.Stop() + var lastErr error + for { + if err := remove(path); err != nil { + lastErr = err + } else if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + lastErr = err + } else { + lastErr = errors.New("browser Profile cleanup left the exact directory present") + } + select { + case <-deadline.C: + return errors.Join(errors.New("browser Profile cleanup retry limit exhausted"), lastErr) + case <-ticker.C: + } + } +} + func newProfileOwnerMarker(ownership ProfileOwnershipPlan, now time.Time) ProfileOwnerMarker { marker := ProfileOwnerMarker{ ProtocolVersion: ProfileMarkerProtocolVersion, diff --git a/internal/browserruntime/restricted_cdp_transport.go b/internal/browserruntime/restricted_cdp_transport.go index 2544593e..bc390a1d 100644 --- a/internal/browserruntime/restricted_cdp_transport.go +++ b/internal/browserruntime/restricted_cdp_transport.go @@ -66,6 +66,28 @@ var restrictedCDPMethods = map[string]restrictedCDPMethodScope{ "Page.captureScreenshot": restrictedCDPTargetMethod, } +// uiEvidenceCDPMethods is an additional closed set admitted only by an +// AuthorizeUIEvidenceCDP-derived session. In particular it contains no script +// evaluation, cookie, response-body, request mutation, or request replay API. +var uiEvidenceCDPMethods = map[string]restrictedCDPMethodScope{ + "Accessibility.enable": restrictedCDPTargetMethod, + "Accessibility.getFullAXTree": restrictedCDPTargetMethod, + "DOM.focus": restrictedCDPTargetMethod, + "DOM.getBoxModel": restrictedCDPTargetMethod, + "DOM.getOuterHTML": restrictedCDPTargetMethod, + "DOM.querySelector": restrictedCDPTargetMethod, + "DOM.scrollIntoViewIfNeeded": restrictedCDPTargetMethod, + "Emulation.setDeviceMetricsOverride": restrictedCDPTargetMethod, + "Emulation.setEmulatedMedia": restrictedCDPTargetMethod, + "Emulation.setLocaleOverride": restrictedCDPTargetMethod, + "Input.dispatchMouseEvent": restrictedCDPTargetMethod, + "Input.insertText": restrictedCDPTargetMethod, + "Log.enable": restrictedCDPTargetMethod, + "Performance.enable": restrictedCDPTargetMethod, + "Performance.getMetrics": restrictedCDPTargetMethod, + "Runtime.enable": restrictedCDPTargetMethod, +} + // fullCDPMethods is the additional, highly-sensitive CDP method set admitted // only under a confirmed FullCDPAuthorization. It never includes methods that // disable browser security. @@ -150,7 +172,12 @@ type restrictedCDPClient struct { blockedDocument bool budgetErr error capturedRequests []capturedRequestMetadata + consoleEntries []UIEvidenceConsoleEntry + pageErrors []UIEvidencePageError + networkEntries []UIEvidenceNetworkEntry + networkPending map[string]struct{} fullCDP bool + uiEvidence bool } // capturedRequestMetadata is bounded request metadata only. It never retains @@ -186,7 +213,7 @@ func OpenRestrictedBrowserSession(ctx context.Context, permission domain.RunBrowserCDPPermissionSnapshot, profileLease ProfileRuntimeLease, process *BrowserProcess, ) (*RestrictedBrowserSession, error) { - if err := ValidateRestrictedCDPAuthorization(authorization, start, session, + if err := validateRestrictedCDPSessionAuthorization(authorization, start, session, permission); err != nil { return nil, err } @@ -228,7 +255,7 @@ func OpenRestrictedBrowserSession(ctx context.Context, return nil, err } client := &restrictedCDPClient{conn: connection, scope: session.Scope, - maxRequests: attempt.MaxRequests} + maxRequests: attempt.MaxRequests, uiEvidence: authorization.UIEvidenceAuthorized} if err := client.initialize(openContext); err != nil { _ = connection.Close() return nil, err @@ -407,7 +434,7 @@ func (runtime *RestrictedBrowserSession) Close(ctx context.Context) error { func (runtime *RestrictedBrowserSession) beginOperation(ctx context.Context, ) (func(), context.Context, context.CancelFunc, error) { - if err := ValidateRestrictedCDPAuthorization(runtime.authorization, runtime.start, + if err := validateRestrictedCDPSessionAuthorization(runtime.authorization, runtime.start, runtime.session, runtime.permission); err != nil { return nil, nil, nil, err } @@ -438,6 +465,16 @@ func (runtime *RestrictedBrowserSession) beginOperation(ctx context.Context, return release, operationContext, cancel, nil } +func validateRestrictedCDPSessionAuthorization(authorization RestrictedCDPAuthorization, + start BrowserStartAuthorization, session SessionPlan, + permission domain.RunBrowserCDPPermissionSnapshot, +) error { + if authorization.UIEvidenceAuthorized { + return ValidateUIEvidenceCDPAuthorization(authorization, start, session, permission) + } + return ValidateRestrictedCDPAuthorization(authorization, start, session, permission) +} + func (runtime *RestrictedBrowserSession) closeWhenProcessExits() { select { case <-runtime.process.Done(): @@ -507,6 +544,15 @@ func (client *restrictedCDPClient) initialize(ctx context.Context) error { return err } } + if client.uiEvidence { + for _, method := range []string{"Runtime.enable", "Log.enable", + "Performance.enable", "Accessibility.enable"} { + if err := client.call(ctx, client.sessionID, method, map[string]any{}, + &struct{}{}); err != nil { + return err + } + } + } return nil } @@ -573,6 +619,9 @@ func (client *restrictedCDPClient) methodSessionAllowed(method string, sessionID string, ) bool { scope, ok := restrictedCDPMethods[method] + if !ok && client.uiEvidence { + scope, ok = uiEvidenceCDPMethods[method] + } if !ok { if client.fullCDP { scope, ok = fullCDPMethods[method] @@ -657,6 +706,20 @@ func (client *restrictedCDPClient) handleEvent(ctx context.Context, _, err := client.writeCommand(ctx, client.sessionID, "Fetch.failRequest", map[string]any{"requestId": paused.RequestID, "errorReason": "BlockedByClient"}) return err + case "Runtime.consoleAPICalled": + return client.captureConsoleAPICalled(message.Params) + case "Runtime.exceptionThrown": + return client.capturePageException(message.Params) + case "Log.entryAdded": + return client.captureLogEntry(message.Params) + case "Network.requestWillBeSent": + return client.captureNetworkRequest(message.Params) + case "Network.responseReceived": + return client.captureNetworkResponse(message.Params) + case "Network.loadingFailed": + return client.captureNetworkFailure(message.Params) + case "Network.loadingFinished": + return client.captureNetworkFinished(message.Params) default: return nil } @@ -786,20 +849,30 @@ func readDevToolsEndpoint(profilePath string) (*url.URL, bool, error) { return nil, true, nil } if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || - info.Size() <= 0 || info.Size() > MaxDevToolsActivePortBytes || + info.Size() < 0 || info.Size() > MaxDevToolsActivePortBytes || !profilePathHasNoIndirection(path) { return nil, false, errors.New("DevTools endpoint file is unavailable or indirect") } + // Chromium creates this direct regular file before publishing its two-line + // payload. Keep the bounded startup wait pending during that zero-length + // window; indirection, oversized data, malformed content, process exit, and + // the caller's deadline remain fail-closed. + if info.Size() == 0 { + return nil, true, nil + } file, err := os.Open(path) if err != nil { return nil, false, err } raw, readErr := io.ReadAll(io.LimitReader(file, MaxDevToolsActivePortBytes+1)) closeErr := file.Close() - if readErr != nil || closeErr != nil || len(raw) == 0 || + if readErr != nil || closeErr != nil || len(raw) > MaxDevToolsActivePortBytes || !utf8.Valid(raw) { return nil, false, errors.New("DevTools endpoint file is malformed") } + if len(raw) == 0 { + return nil, true, nil + } lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n") if len(lines) != 2 || strings.HasSuffix(lines[0], "\r") || strings.HasSuffix(lines[1], "\r") { diff --git a/internal/browserruntime/restricted_cdp_transport_test.go b/internal/browserruntime/restricted_cdp_transport_test.go index d5d22c20..201f6144 100644 --- a/internal/browserruntime/restricted_cdp_transport_test.go +++ b/internal/browserruntime/restricted_cdp_transport_test.go @@ -1,10 +1,14 @@ package browserruntime import ( + "bytes" "context" "encoding/base64" "encoding/json" "errors" + "image" + "image/color" + "image/png" "net" "net/http" "os" @@ -136,6 +140,23 @@ func TestRestrictedCDPRejectsMalformedEndpointFile(t *testing.T) { } } +func TestReadDevToolsEndpointTreatsEmptyDirectFileAsPending(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + profileLease := facts.materialize(t) + path := filepath.Join(profileLease.DirectoryPath, DevToolsActivePortFileName) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + endpoint, pending, err := readDevToolsEndpoint(profileLease.DirectoryPath) + if err != nil { + t.Fatal(err) + } + if endpoint != nil || !pending { + t.Fatalf("empty direct endpoint file returned endpoint=%v pending=%t", + endpoint, pending) + } +} + func TestRestrictedCDPRejectsIndirectEndpointFile(t *testing.T) { facts := newLoopbackBrowserRuntimeFacts(t) profileLease := facts.materialize(t) @@ -239,14 +260,15 @@ func writeDevToolsActivePort(t *testing.T, profilePath string, port int, } type scriptedCDPServer struct { - listener net.Listener - server *http.Server - port int - path string - url string - png []byte - mu sync.Mutex - methods []string + listener net.Listener + server *http.Server + port int + path string + url string + png []byte + mu sync.Mutex + methods []string + selectorQueries map[string]int } func newScriptedCDPServer(t *testing.T, allowedURL string) *scriptedCDPServer { @@ -256,7 +278,7 @@ func newScriptedCDPServer(t *testing.T, allowedURL string) *scriptedCDPServer { t.Fatal(err) } server := &scriptedCDPServer{listener: listener, path: "/devtools/browser/test", - url: allowedURL, png: []byte("\x89PNG\r\n\x1a\nrestricted-fixture")} + url: allowedURL, png: restrictedTestPNG(), selectorQueries: make(map[string]int)} server.port = listener.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() mux.HandleFunc(server.path, server.serveWebSocket) @@ -294,6 +316,7 @@ func (server *scriptedCDPServer) serveWebSocket(writer http.ResponseWriter, defer connection.Close() var pendingNavigationID int64 navigationStage := 0 + performanceCalls := 0 for { _, raw, err := connection.ReadMessage() if err != nil { @@ -344,6 +367,17 @@ func (server *scriptedCDPServer) serveWebSocket(writer http.ResponseWriter, map[string]any{"frameId": "frame-test"}) writeCDPEvent(connection, "session-test", "Page.loadEventFired", map[string]any{"timestamp": 1}) + writeCDPEvent(connection, "session-test", "Runtime.consoleAPICalled", + map[string]any{"type": "warning", "timestamp": 1, + "args": []map[string]any{{"type": "string", "value": "fixture warning"}}}) + writeCDPEvent(connection, "session-test", "Network.requestWillBeSent", + map[string]any{"requestId": "network-test", "type": "XHR", + "request": map[string]any{"url": server.url + "?token=hidden", "method": "GET"}}) + writeCDPEvent(connection, "session-test", "Network.responseReceived", + map[string]any{"requestId": "network-test", "response": map[string]any{ + "url": server.url + "?token=hidden", "status": 500, "mimeType": "application/json"}}) + writeCDPEvent(connection, "session-test", "Network.loadingFinished", + map[string]any{"requestId": "network-test"}) pendingNavigationID = 0 } case "DOM.getDocument": @@ -371,6 +405,40 @@ func (server *scriptedCDPServer) serveWebSocket(writer http.ResponseWriter, writeCDPResult(connection, command.ID, map[string]any{ "data": base64.StdEncoding.EncodeToString(server.png), }) + case "DOM.querySelector": + var params struct { + Selector string `json:"selector"` + } + _ = json.Unmarshal(command.Params, ¶ms) + server.mu.Lock() + server.selectorQueries[params.Selector]++ + queryCount := server.selectorQueries[params.Selector] + server.mu.Unlock() + nodeID := 0 + if params.Selector != "#absent" && + (params.Selector != "#eventual" || queryCount >= 3) { + nodeID = 7 + } + writeCDPResult(connection, command.ID, map[string]any{"nodeId": nodeID}) + case "DOM.getBoxModel": + writeCDPResult(connection, command.ID, map[string]any{"model": map[string]any{ + "border": []float64{2, 2, 6, 2, 6, 6, 2, 6}}}) + case "DOM.getOuterHTML": + writeCDPResult(connection, command.ID, map[string]any{ + "outerHTML": `
token=abcdefghijklmnopqrstuvwxyz1234567890
`, + }) + case "Accessibility.getFullAXTree": + writeCDPResult(connection, command.ID, map[string]any{"nodes": []map[string]any{{ + "nodeId": "ax-1", "name": map[string]any{"value": "fixture"}}}}) + case "Performance.getMetrics": + performanceCalls++ + if performanceCalls == 3 { + writeCDPEvent(connection, "session-test", "Runtime.consoleAPICalled", + map[string]any{"type": "warning", "timestamp": 2, + "args": []map[string]any{{"type": "string", "value": "delayed warning"}}}) + } + writeCDPResult(connection, command.ID, map[string]any{"metrics": []map[string]any{{ + "name": "LayoutCount", "value": 2}}}) case "Target.closeTarget": writeCDPResult(connection, command.ID, map[string]any{"success": true}) default: @@ -379,6 +447,19 @@ func (server *scriptedCDPServer) serveWebSocket(writer http.ResponseWriter, } } +func restrictedTestPNG() []byte { + canvas := image.NewRGBA(image.Rect(0, 0, 10, 10)) + drawColor := color.RGBA{R: 240, G: 240, B: 240, A: 255} + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + canvas.SetRGBA(x, y, drawColor) + } + } + var output bytes.Buffer + _ = png.Encode(&output, canvas) + return output.Bytes() +} + func writeCDPResult(connection *websocket.Conn, id int64, result map[string]any) { _ = connection.WriteJSON(map[string]any{"id": id, "result": result}) } diff --git a/internal/browserruntime/runtime_authorization.go b/internal/browserruntime/runtime_authorization.go index 7e550104..c9f26a00 100644 --- a/internal/browserruntime/runtime_authorization.go +++ b/internal/browserruntime/runtime_authorization.go @@ -73,9 +73,10 @@ type BrowserStartAuthorization struct { Fingerprint string `json:"fingerprint"` } -// RestrictedCDPAuthorization narrows a start authorization to three fixed -// operations. It does not authorize request capture, mutation, replay, -// cookies, arbitrary methods, or browser-content instructions. +// RestrictedCDPAuthorization narrows a start authorization to fixed, +// product-owned operations. UIEvidenceAuthorized may additionally enable the +// closed UI evidence method set; it still does not authorize arbitrary script, +// cookies, request mutation/replay, or browser-content instructions. type RestrictedCDPAuthorization struct { ProtocolVersion string `json:"protocol_version"` StartAuthorizationFingerprint string `json:"start_authorization_fingerprint"` @@ -85,6 +86,7 @@ type RestrictedCDPAuthorization struct { NavigateAuthorized bool `json:"navigate_authorized"` DOMMetadataAuthorized bool `json:"dom_metadata_authorized"` ScreenshotAuthorized bool `json:"screenshot_authorized"` + UIEvidenceAuthorized bool `json:"ui_evidence_authorized"` RequestCaptureAuthorized bool `json:"request_capture_authorized"` RequestMutationAuthorized bool `json:"request_mutation_authorized"` RequestReplayAuthorized bool `json:"request_replay_authorized"` @@ -96,6 +98,35 @@ type RestrictedCDPAuthorization struct { Fingerprint string `json:"fingerprint"` } +// AuthorizeUIEvidenceCDP derives the same short-lived restricted session as +// AuthorizeRestrictedCDP and narrows it to the fixed UI evidence driver. This +// is a process-local capability and cannot be reconstructed from persisted +// evidence. +func AuthorizeUIEvidenceCDP(start BrowserStartAuthorization, + session SessionPlan, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, ownership ProfileOwnershipPlan, + attempt BrowserLaunchAttempt, lease BrowserLaunchLease, review BrowserLaunchReview, + networkEvidence BrowserNetworkContainmentEvidence, + networkReview BrowserNetworkContainmentReview, + networkPlan BrowserNetworkContainmentPlan, + permission domain.RunBrowserCDPPermissionSnapshot, + runtimeCapabilities ProductionRuntimeCapabilities, now time.Time, +) (RestrictedCDPAuthorization, error) { + authorization, err := AuthorizeRestrictedCDP(start, session, identity, + acceptance, ownership, attempt, lease, review, networkEvidence, + networkReview, networkPlan, permission, runtimeCapabilities, now) + if err != nil { + return RestrictedCDPAuthorization{}, err + } + authorization.UIEvidenceAuthorized = true + authorization.Fingerprint = browserRuntimeFingerprint(authorization) + if err := ValidateUIEvidenceCDPAuthorization(authorization, start, session, + permission); err != nil { + return RestrictedCDPAuthorization{}, err + } + return authorization, nil +} + func AuthorizeSafeWebStart(session SessionPlan, identity BrowserExecutableIdentity, acceptance BrowserAcceptanceCandidate, ownership ProfileOwnershipPlan, attempt BrowserLaunchAttempt, lease BrowserLaunchLease, review BrowserLaunchReview, @@ -283,6 +314,22 @@ func AuthorizeRestrictedCDP(start BrowserStartAuthorization, func ValidateRestrictedCDPAuthorization(authorization RestrictedCDPAuthorization, start BrowserStartAuthorization, session SessionPlan, permission domain.RunBrowserCDPPermissionSnapshot, +) error { + return validateRestrictedCDPAuthorization(authorization, start, session, + permission, false) +} + +func ValidateUIEvidenceCDPAuthorization(authorization RestrictedCDPAuthorization, + start BrowserStartAuthorization, session SessionPlan, + permission domain.RunBrowserCDPPermissionSnapshot, +) error { + return validateRestrictedCDPAuthorization(authorization, start, session, + permission, true) +} + +func validateRestrictedCDPAuthorization(authorization RestrictedCDPAuthorization, + start BrowserStartAuthorization, session SessionPlan, + permission domain.RunBrowserCDPPermissionSnapshot, uiEvidence bool, ) error { if err := permission.Validate(); err != nil { return err @@ -297,7 +344,8 @@ func ValidateRestrictedCDPAuthorization(authorization RestrictedCDPAuthorization authorization.ScopeFingerprint != session.Scope.Fingerprint || permission.Mode != domain.RunBrowserCDPPermissionRestricted || !authorization.NavigateAuthorized || !authorization.DOMMetadataAuthorized || - !authorization.ScreenshotAuthorized || authorization.RequestCaptureAuthorized || + !authorization.ScreenshotAuthorized || authorization.UIEvidenceAuthorized != uiEvidence || + authorization.RequestCaptureAuthorized || authorization.RequestMutationAuthorized || authorization.RequestReplayAuthorized || authorization.CookieAccessAuthorized || authorization.ArbitraryMethodAuthorized || authorization.InstructionAuthorized || authorization.IssuedAt.IsZero() || diff --git a/internal/browserruntime/ui_evidence_cdp.go b/internal/browserruntime/ui_evidence_cdp.go new file mode 100644 index 00000000..8e203608 --- /dev/null +++ b/internal/browserruntime/ui_evidence_cdp.go @@ -0,0 +1,897 @@ +package browserruntime + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "net/url" + "sort" + "strings" + "time" + "unicode/utf8" + + "cyberagent-workbench/internal/outputsafe" + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/uievidence" +) + +const ( + MaxUIEvidenceTextArtifactBytes = 4 * 1024 * 1024 + MaxUIEvidenceDiagnosticTextBytes = 8 * 1024 + MaxUIEvidenceInputBytes = 64 * 1024 +) + +type UIEvidenceConsoleEntry struct { + Level string `json:"level"` + Source string `json:"source"` + Text string `json:"text"` + Timestamp time.Time `json:"timestamp"` +} + +type UIEvidencePageError struct { + Text string `json:"text"` + Timestamp time.Time `json:"timestamp"` +} + +type UIEvidenceNetworkEntry struct { + RequestID string `json:"request_id"` + URL string `json:"url"` + Method string `json:"method"` + ResourceType string `json:"resource_type"` + Status int `json:"status,omitempty"` + MIME string `json:"mime,omitempty"` + Failed bool `json:"failed"` + Cancelled bool `json:"cancelled"` + ErrorText string `json:"error_text,omitempty"` +} + +type UIEvidenceDiagnostics struct { + Console []UIEvidenceConsoleEntry `json:"console"` + PageErrors []UIEvidencePageError `json:"page_errors"` + Network []UIEvidenceNetworkEntry `json:"network"` + Summary uievidence.DiagnosticsSummary `json:"summary"` + UntrustedEvidence bool `json:"untrusted_evidence"` + CapturedAt time.Time `json:"captured_at"` +} + +type UIEvidenceTextCapture struct { + MIME string + Content []byte + Redacted bool + CapturedAt time.Time +} + +// ConfigureUIEvidence applies the exact viewport, DPR, locale, theme, and +// reduced-motion tuple from the manifest. It cannot change origin policy, +// certificates, downloads, cookies, or network scope. +func (runtime *RestrictedBrowserSession) ConfigureUIEvidence(ctx context.Context, + environment uievidence.Environment, +) error { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized || + environment.Validate() != nil { + return ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return err + } + defer release() + defer cancel() + viewport := environment.Viewport + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Emulation.setDeviceMetricsOverride", map[string]any{ + "width": viewport.Width, "height": viewport.Height, + "deviceScaleFactor": viewport.DPR, "mobile": false, + "screenWidth": viewport.Width, "screenHeight": viewport.Height, + }, &struct{}{}); err != nil { + return err + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Emulation.setLocaleOverride", map[string]any{"locale": environment.Locale}, + &struct{}{}); err != nil { + return err + } + features := []map[string]string{{"name": "prefers-color-scheme", + "value": string(environment.Theme)}} + motion := "no-preference" + if environment.ReducedMotion { + motion = "reduce" + } + features = append(features, map[string]string{"name": "prefers-reduced-motion", + "value": motion}) + return runtime.client.call(operationContext, runtime.client.sessionID, + "Emulation.setEmulatedMedia", map[string]any{"media": "screen", "features": features}, + &struct{}{}) +} + +func (runtime *RestrictedBrowserSession) ClickUIEvidence(ctx context.Context, + selector string, +) error { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized || + !validUIEvidenceSelector(selector) { + return ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return err + } + defer release() + defer cancel() + nodeID, found, err := runtime.client.querySelector(operationContext, selector) + if err != nil { + return err + } + if !found { + return errors.New("UI evidence selector did not match") + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "DOM.scrollIntoViewIfNeeded", map[string]any{"nodeId": nodeID}, &struct{}{}); err != nil { + return err + } + rectangle, err := runtime.client.nodeRectangle(operationContext, nodeID) + if err != nil { + return err + } + x := float64(rectangle.Min.X+rectangle.Max.X) / 2 + y := float64(rectangle.Min.Y+rectangle.Max.Y) / 2 + for _, eventType := range []string{"mousePressed", "mouseReleased"} { + params := map[string]any{"type": eventType, "x": x, "y": y, + "button": "left", "clickCount": 1} + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Input.dispatchMouseEvent", params, &struct{}{}); err != nil { + return err + } + } + return nil +} + +// TypeUIEvidence sends bounded fixture text to one selected node. The caller +// must provide the digest sealed in the manifest; raw input is never returned +// or persisted by this package. +func (runtime *RestrictedBrowserSession) TypeUIEvidence(ctx context.Context, + selector, value, expectedSHA256 string, +) error { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized || + !validUIEvidenceSelector(selector) || len([]byte(value)) > MaxUIEvidenceInputBytes || + !utf8.ValidString(value) || strings.ContainsRune(value, 0) || + redact.String(value) != value { + return ErrBrowserRuntimeBoundary + } + digest := sha256.Sum256([]byte(value)) + if hex.EncodeToString(digest[:]) != expectedSHA256 { + return errors.New("UI evidence input does not match the sealed digest") + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return err + } + defer release() + defer cancel() + nodeID, found, err := runtime.client.querySelector(operationContext, selector) + if err != nil { + return err + } + if !found { + return errors.New("UI evidence selector did not match") + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "DOM.focus", map[string]any{"nodeId": nodeID}, &struct{}{}); err != nil { + return err + } + return runtime.client.call(operationContext, runtime.client.sessionID, + "Input.insertText", map[string]any{"text": value}, &struct{}{}) +} + +func (runtime *RestrictedBrowserSession) AssertUIEvidenceSelector(ctx context.Context, + selector string, expectedPresent bool, +) error { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized || + !validUIEvidenceSelector(selector) { + return ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return err + } + defer release() + defer cancel() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + _, found, err := runtime.client.querySelector(operationContext, selector) + if err != nil { + return err + } + if found == expectedPresent { + return nil + } + select { + case <-operationContext.Done(): + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + if expectedPresent { + return errors.New("UI evidence assertion expected selector to be present") + } + return errors.New("UI evidence assertion expected selector to be absent") + case <-ticker.C: + } + } +} + +func (runtime *RestrictedBrowserSession) DOMUIEvidence(ctx context.Context) ( + UIEvidenceTextCapture, error, +) { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized { + return UIEvidenceTextCapture{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return UIEvidenceTextCapture{}, err + } + defer release() + defer cancel() + rootID, err := runtime.client.documentNodeID(operationContext) + if err != nil { + return UIEvidenceTextCapture{}, err + } + var result struct { + OuterHTML string `json:"outerHTML"` + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "DOM.getOuterHTML", map[string]any{"nodeId": rootID}, &result); err != nil { + return UIEvidenceTextCapture{}, err + } + content, err := sanitizeUIEvidenceText([]byte(result.OuterHTML)) + if err != nil { + return UIEvidenceTextCapture{}, err + } + return UIEvidenceTextCapture{MIME: "text/html; charset=utf-8", Content: content, + Redacted: true, CapturedAt: time.Now().UTC()}, nil +} + +func (runtime *RestrictedBrowserSession) AccessibilityUIEvidence(ctx context.Context) ( + UIEvidenceTextCapture, error, +) { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized { + return UIEvidenceTextCapture{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return UIEvidenceTextCapture{}, err + } + defer release() + defer cancel() + var tree struct { + Nodes []json.RawMessage `json:"nodes"` + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Accessibility.getFullAXTree", map[string]any{"depth": 64}, &tree); err != nil { + return UIEvidenceTextCapture{}, err + } + raw, err := json.Marshal(tree) + if err != nil { + return UIEvidenceTextCapture{}, err + } + content, err := sanitizeUIEvidenceText(raw) + if err != nil { + return UIEvidenceTextCapture{}, err + } + return UIEvidenceTextCapture{MIME: "application/json", Content: content, + Redacted: true, CapturedAt: time.Now().UTC()}, nil +} + +func (runtime *RestrictedBrowserSession) PerformanceUIEvidence(ctx context.Context) ( + UIEvidenceTextCapture, error, +) { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized { + return UIEvidenceTextCapture{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return UIEvidenceTextCapture{}, err + } + defer release() + defer cancel() + var metrics struct { + Metrics []struct { + Name string `json:"name"` + Value float64 `json:"value"` + } `json:"metrics"` + } + if err := runtime.client.call(operationContext, runtime.client.sessionID, + "Performance.getMetrics", map[string]any{}, &metrics); err != nil { + return UIEvidenceTextCapture{}, err + } + sort.Slice(metrics.Metrics, func(i, j int) bool { + return metrics.Metrics[i].Name < metrics.Metrics[j].Name + }) + raw, err := json.Marshal(metrics) + if err != nil { + return UIEvidenceTextCapture{}, err + } + content, err := sanitizeUIEvidenceText(raw) + if err != nil { + return UIEvidenceTextCapture{}, err + } + return UIEvidenceTextCapture{MIME: "application/json", Content: content, + Redacted: true, CapturedAt: time.Now().UTC()}, nil +} + +func (runtime *RestrictedBrowserSession) DiagnosticsUIEvidence(ctx context.Context) ( + UIEvidenceDiagnostics, UIEvidenceTextCapture, error, +) { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized { + return UIEvidenceDiagnostics{}, UIEvidenceTextCapture{}, ErrBrowserRuntimeBoundary + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return UIEvidenceDiagnostics{}, UIEvidenceTextCapture{}, err + } + defer release() + defer cancel() + // Fixed metrics calls drain queued protocol events until all observed + // requests have completed and the event stream has remained quiet. This + // prevents a delayed console/network failure from racing a green receipt. + if err := runtime.client.drainUIEvidenceEventsUntilIdle(operationContext); err != nil { + return UIEvidenceDiagnostics{}, UIEvidenceTextCapture{}, err + } + diagnostics := runtime.client.diagnostics() + raw, err := json.Marshal(diagnostics) + if err != nil { + return UIEvidenceDiagnostics{}, UIEvidenceTextCapture{}, err + } + content, err := sanitizeUIEvidenceText(raw) + if err != nil { + return UIEvidenceDiagnostics{}, UIEvidenceTextCapture{}, err + } + return diagnostics, UIEvidenceTextCapture{MIME: "application/json", Content: content, + Redacted: true, CapturedAt: diagnostics.CapturedAt}, nil +} + +func (runtime *RestrictedBrowserSession) ScreenshotUIEvidence(ctx context.Context, + maskSelectors []string, dpr float64, +) (RestrictedScreenshot, int, int, error) { + if runtime == nil || !runtime.authorization.UIEvidenceAuthorized || + len(maskSelectors) > uievidence.MaxMasks || dpr < .5 || dpr > 4 { + return RestrictedScreenshot{}, 0, 0, ErrBrowserRuntimeBoundary + } + for _, selector := range maskSelectors { + if !validUIEvidenceSelector(selector) { + return RestrictedScreenshot{}, 0, 0, ErrBrowserRuntimeBoundary + } + } + release, operationContext, cancel, err := runtime.beginOperation(ctx) + if err != nil { + return RestrictedScreenshot{}, 0, 0, err + } + defer release() + defer cancel() + rectangles := make([]image.Rectangle, 0, len(maskSelectors)) + for _, selector := range maskSelectors { + nodeID, found, err := runtime.client.querySelector(operationContext, selector) + if err != nil { + return RestrictedScreenshot{}, 0, 0, err + } + if !found { + return RestrictedScreenshot{}, 0, 0, + fmt.Errorf("UI evidence mask selector did not match") + } + rectangle, err := runtime.client.nodeRectangle(operationContext, nodeID) + if err != nil { + return RestrictedScreenshot{}, 0, 0, err + } + rectangles = append(rectangles, scaleRectangle(rectangle, dpr)) + } + screenshot, err := runtime.client.captureScreenshot(operationContext, + runtime.authorization.Fingerprint) + if err != nil { + return RestrictedScreenshot{}, 0, 0, err + } + decoded, err := decodeBoundedUIEvidencePNG(screenshot.PNG) + if err != nil { + return RestrictedScreenshot{}, 0, 0, + err + } + canvas := image.NewRGBA(decoded.Bounds()) + draw.Draw(canvas, canvas.Bounds(), decoded, decoded.Bounds().Min, draw.Src) + for _, rectangle := range rectangles { + draw.Draw(canvas, rectangle.Intersect(canvas.Bounds()), + &image.Uniform{C: color.RGBA{R: 20, G: 24, B: 32, A: 255}}, image.Point{}, draw.Src) + } + var output bytes.Buffer + if err := png.Encode(&output, canvas); err != nil || output.Len() > MaxScreenshotBytes { + return RestrictedScreenshot{}, 0, 0, errors.New("UI evidence screenshot exceeds its bound") + } + digest := sha256.Sum256(output.Bytes()) + screenshot.PNG = append([]byte(nil), output.Bytes()...) + screenshot.Bytes = len(screenshot.PNG) + screenshot.SHA256 = hex.EncodeToString(digest[:]) + screenshot.CompletedAt = time.Now().UTC() + screenshot.Fingerprint = browserRuntimeFingerprint(screenshot) + return screenshot, canvas.Bounds().Dx(), canvas.Bounds().Dy(), nil +} + +func decodeBoundedUIEvidencePNG(content []byte) (image.Image, error) { + configuration, err := png.DecodeConfig(bytes.NewReader(content)) + if err != nil || configuration.Width < 1 || configuration.Height < 1 || + configuration.Width > uievidence.MaxScreenshotWidth || + configuration.Height > uievidence.MaxScreenshotHeight { + return nil, errors.New("chromium returned an invalid or out-of-bounds PNG screenshot") + } + decoded, err := png.Decode(bytes.NewReader(content)) + if err != nil { + return nil, errors.New("chromium returned an invalid PNG screenshot") + } + return decoded, nil +} + +func (client *restrictedCDPClient) documentNodeID(ctx context.Context) (int64, error) { + var document struct { + Root struct { + NodeID int64 `json:"nodeId"` + DocumentURL string `json:"documentURL"` + } `json:"root"` + } + if err := client.call(ctx, client.sessionID, "DOM.getDocument", + map[string]any{"depth": 0, "pierce": false}, &document); err != nil { + return 0, err + } + decision := client.scope.AuthorizeNavigation(document.Root.DocumentURL) + if !decision.Allowed || document.Root.NodeID <= 0 { + return 0, errors.New("UI evidence document is outside the exact scope") + } + return document.Root.NodeID, nil +} + +func (client *restrictedCDPClient) querySelector(ctx context.Context, + selector string, +) (int64, bool, error) { + rootID, err := client.documentNodeID(ctx) + if err != nil { + return 0, false, err + } + var result struct { + NodeID int64 `json:"nodeId"` + } + if err := client.call(ctx, client.sessionID, "DOM.querySelector", + map[string]any{"nodeId": rootID, "selector": selector}, &result); err != nil { + return 0, false, err + } + if result.NodeID < 0 { + return 0, false, errors.New("chromium returned an invalid DOM node") + } + return result.NodeID, result.NodeID > 0, nil +} + +func (client *restrictedCDPClient) nodeRectangle(ctx context.Context, + nodeID int64, +) (image.Rectangle, error) { + var result struct { + Model struct { + Border []float64 `json:"border"` + Content []float64 `json:"content"` + } `json:"model"` + } + if err := client.call(ctx, client.sessionID, "DOM.getBoxModel", + map[string]any{"nodeId": nodeID}, &result); err != nil { + return image.Rectangle{}, err + } + quad := result.Model.Border + if len(quad) != 8 { + quad = result.Model.Content + } + if len(quad) != 8 { + return image.Rectangle{}, errors.New("UI evidence node has no bounded box") + } + minX, minY, maxX, maxY := quad[0], quad[1], quad[0], quad[1] + for index := 0; index < len(quad); index += 2 { + x, y := quad[index], quad[index+1] + if math.IsNaN(x) || math.IsInf(x, 0) || math.IsNaN(y) || math.IsInf(y, 0) || + x < -100000 || x > 100000 || y < -100000 || y > 100000 { + return image.Rectangle{}, errors.New("UI evidence node box is invalid") + } + minX, minY, maxX, maxY = math.Min(minX, x), math.Min(minY, y), + math.Max(maxX, x), math.Max(maxY, y) + } + if maxX <= minX || maxY <= minY { + return image.Rectangle{}, errors.New("UI evidence node is not visible") + } + return image.Rect(int(math.Floor(minX)), int(math.Floor(minY)), + int(math.Ceil(maxX)), int(math.Ceil(maxY))), nil +} + +func (client *restrictedCDPClient) captureScreenshot(ctx context.Context, + authorization string, +) (RestrictedScreenshot, error) { + canonicalURL, _, _, err := client.documentIdentity(ctx) + if err != nil { + return RestrictedScreenshot{}, err + } + var capture struct { + Data string `json:"data"` + } + if err := client.call(ctx, client.sessionID, "Page.captureScreenshot", + map[string]any{"format": "png", "fromSurface": true, + "captureBeyondViewport": false}, &capture); err != nil { + return RestrictedScreenshot{}, err + } + pngBytes, err := decodeRestrictedScreenshot(capture.Data, MaxScreenshotBytes) + if err != nil { + return RestrictedScreenshot{}, err + } + digest := sha256.Sum256(pngBytes) + result := RestrictedScreenshot{ProtocolVersion: RestrictedScreenshotProtocolVersion, + Authorization: authorization, CanonicalURL: canonicalURL, MediaType: "image/png", + Bytes: len(pngBytes), SHA256: hex.EncodeToString(digest[:]), + PNG: append([]byte(nil), pngBytes...), UntrustedEvidence: true, + CompletedAt: time.Now().UTC()} + result.Fingerprint = browserRuntimeFingerprint(result) + return result, nil +} + +func (client *restrictedCDPClient) captureConsoleAPICalled(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + Type string `json:"type"` + Timestamp float64 `json:"timestamp"` + Args []struct { + Type string `json:"type"` + Value any `json:"value"` + Description string `json:"description"` + } `json:"args"` + } + if json.Unmarshal(raw, &event) != nil { + return errors.New("UI evidence console event is malformed") + } + parts := make([]string, 0, len(event.Args)) + for _, argument := range event.Args { + value := argument.Description + if value == "" && argument.Value != nil { + encoded, _ := json.Marshal(argument.Value) + value = string(encoded) + } + parts = append(parts, value) + } + return client.appendConsole(UIEvidenceConsoleEntry{Level: normalizeConsoleLevel(event.Type), + Source: "console", Text: safeDiagnosticText(strings.Join(parts, " ")), + Timestamp: cdpTimestamp(event.Timestamp)}) +} + +func (client *restrictedCDPClient) captureLogEntry(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + Entry struct { + Source string `json:"source"` + Level string `json:"level"` + Text string `json:"text"` + Timestamp float64 `json:"timestamp"` + } `json:"entry"` + } + if json.Unmarshal(raw, &event) != nil { + return errors.New("UI evidence log event is malformed") + } + return client.appendConsole(UIEvidenceConsoleEntry{Level: normalizeConsoleLevel(event.Entry.Level), + Source: safeDiagnosticText(event.Entry.Source), Text: safeDiagnosticText(event.Entry.Text), + Timestamp: cdpTimestamp(event.Entry.Timestamp)}) +} + +func (client *restrictedCDPClient) capturePageException(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + Timestamp float64 `json:"timestamp"` + ExceptionDetails struct { + Text string `json:"text"` + Exception struct { + Description string `json:"description"` + } `json:"exception"` + } `json:"exceptionDetails"` + } + if json.Unmarshal(raw, &event) != nil { + return errors.New("UI evidence page error is malformed") + } + text := event.ExceptionDetails.Exception.Description + if text == "" { + text = event.ExceptionDetails.Text + } + return client.appendPageError(UIEvidencePageError{ + Text: safeDiagnosticText(text), Timestamp: cdpTimestamp(event.Timestamp)}) +} + +func (client *restrictedCDPClient) captureNetworkRequest(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + RequestID string `json:"requestId"` + Type string `json:"type"` + Request struct { + URL string `json:"url"` + Method string `json:"method"` + } `json:"request"` + } + if json.Unmarshal(raw, &event) != nil || !validRestrictedCDPToken(event.RequestID) { + return errors.New("UI evidence network request is malformed") + } + if client.networkPending == nil { + client.networkPending = make(map[string]struct{}) + } + if _, exists := client.networkPending[event.RequestID]; !exists { + if len(client.networkPending) >= client.maxRequests { + client.budgetErr = errors.New("UI evidence network event budget exhausted") + return client.budgetErr + } + client.networkPending[event.RequestID] = struct{}{} + } + if len(client.networkEntries) >= client.maxRequests { + return client.exhaustUIEvidenceDiagnosticBudget() + } + client.networkEntries = append(client.networkEntries, UIEvidenceNetworkEntry{ + RequestID: event.RequestID, URL: client.safeEvidenceURL(event.Request.URL), + Method: safeHTTPMethod(event.Request.Method), ResourceType: safeDiagnosticText(event.Type)}) + return nil +} + +func (client *restrictedCDPClient) captureNetworkResponse(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + RequestID string `json:"requestId"` + Response struct { + URL string `json:"url"` + Status float64 `json:"status"` + MimeType string `json:"mimeType"` + } `json:"response"` + } + if json.Unmarshal(raw, &event) != nil || !validRestrictedCDPToken(event.RequestID) || + math.IsNaN(event.Response.Status) || event.Response.Status < 0 || event.Response.Status > 999 { + return errors.New("UI evidence network response is malformed") + } + entry := client.networkEntry(event.RequestID) + if entry == nil { + if len(client.networkEntries) >= client.maxRequests { + return client.exhaustUIEvidenceDiagnosticBudget() + } + client.networkEntries = append(client.networkEntries, UIEvidenceNetworkEntry{ + RequestID: event.RequestID, URL: client.safeEvidenceURL(event.Response.URL)}) + entry = &client.networkEntries[len(client.networkEntries)-1] + } + if entry != nil { + entry.Status = int(math.Round(event.Response.Status)) + entry.MIME = safeDiagnosticText(event.Response.MimeType) + } + return nil +} + +func (client *restrictedCDPClient) captureNetworkFailure(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + RequestID string `json:"requestId"` + ErrorText string `json:"errorText"` + Canceled bool `json:"canceled"` + } + if json.Unmarshal(raw, &event) != nil || !validRestrictedCDPToken(event.RequestID) { + return errors.New("UI evidence network failure is malformed") + } + delete(client.networkPending, event.RequestID) + entry := client.networkEntry(event.RequestID) + if entry == nil { + if len(client.networkEntries) >= client.maxRequests { + return client.exhaustUIEvidenceDiagnosticBudget() + } + client.networkEntries = append(client.networkEntries, + UIEvidenceNetworkEntry{RequestID: event.RequestID}) + entry = &client.networkEntries[len(client.networkEntries)-1] + } + if entry != nil { + entry.Failed, entry.Cancelled = true, event.Canceled + entry.ErrorText = safeDiagnosticText(event.ErrorText) + } + return nil +} + +func (client *restrictedCDPClient) captureNetworkFinished(raw json.RawMessage) error { + if !client.uiEvidence { + return nil + } + var event struct { + RequestID string `json:"requestId"` + } + if json.Unmarshal(raw, &event) != nil || !validRestrictedCDPToken(event.RequestID) { + return errors.New("UI evidence network completion is malformed") + } + delete(client.networkPending, event.RequestID) + return nil +} + +func (client *restrictedCDPClient) drainUIEvidenceEventsUntilIdle(ctx context.Context) error { + const quietWindow = 100 * time.Millisecond + quietSince := time.Time{} + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + var ignored struct { + Metrics []json.RawMessage `json:"metrics"` + } + if err := client.call(ctx, client.sessionID, "Performance.getMetrics", + map[string]any{}, &ignored); err != nil { + return err + } + if client.budgetErr != nil { + return client.budgetErr + } + now := time.Now() + if len(client.networkPending) == 0 { + if quietSince.IsZero() { + quietSince = now + } else if now.Sub(quietSince) >= quietWindow { + return nil + } + } else { + quietSince = time.Time{} + } + select { + case <-ctx.Done(): + return errors.New("UI evidence diagnostics did not reach bounded network idle") + case <-ticker.C: + } + } +} + +func (client *restrictedCDPClient) appendConsole(entry UIEvidenceConsoleEntry) error { + if len(client.consoleEntries) >= client.maxRequests { + return client.exhaustUIEvidenceDiagnosticBudget() + } + client.consoleEntries = append(client.consoleEntries, entry) + return nil +} + +func (client *restrictedCDPClient) appendPageError(entry UIEvidencePageError) error { + if len(client.pageErrors) >= client.maxRequests { + return client.exhaustUIEvidenceDiagnosticBudget() + } + client.pageErrors = append(client.pageErrors, entry) + return nil +} + +func (client *restrictedCDPClient) exhaustUIEvidenceDiagnosticBudget() error { + if client.budgetErr == nil { + client.budgetErr = errors.New("UI evidence diagnostic event budget exhausted") + } + return client.budgetErr +} + +func (client *restrictedCDPClient) networkEntry(requestID string) *UIEvidenceNetworkEntry { + for index := len(client.networkEntries) - 1; index >= 0; index-- { + if client.networkEntries[index].RequestID == requestID { + return &client.networkEntries[index] + } + } + return nil +} + +func (client *restrictedCDPClient) diagnostics() UIEvidenceDiagnostics { + diagnostics := UIEvidenceDiagnostics{ + Console: append([]UIEvidenceConsoleEntry(nil), client.consoleEntries...), + PageErrors: append([]UIEvidencePageError(nil), client.pageErrors...), + Network: append([]UIEvidenceNetworkEntry(nil), client.networkEntries...), + UntrustedEvidence: true, CapturedAt: time.Now().UTC()} + diagnostics.Summary.AllowedRequests = client.allowedRequests + diagnostics.Summary.BlockedRequests = client.blockedRequests + for _, entry := range diagnostics.Console { + switch entry.Level { + case "error": + diagnostics.Summary.ConsoleErrors++ + case "warning": + diagnostics.Summary.ConsoleWarnings++ + } + } + diagnostics.Summary.PageErrors = len(diagnostics.PageErrors) + for _, entry := range diagnostics.Network { + if entry.Failed && !entry.Cancelled { + diagnostics.Summary.FailedRequests++ + } + if entry.Status >= 400 { + diagnostics.Summary.HTTPFailures++ + } + } + return diagnostics +} + +func validUIEvidenceSelector(value string) bool { + return value != "" && value == strings.TrimSpace(value) && + len([]byte(value)) <= uievidence.MaxSelectorBytes && utf8.ValidString(value) && + !strings.ContainsAny(value, "\x00\r\n") && redact.String(value) == value +} + +func safeDiagnosticText(value string) string { + value = strings.TrimSpace(outputsafe.Sanitize([]byte(value))) + for len([]byte(value)) > MaxUIEvidenceDiagnosticTextBytes { + _, size := utf8.DecodeLastRuneInString(value) + value = value[:len(value)-size] + } + return value +} + +func (client *restrictedCDPClient) safeEvidenceURL(value string) string { + decision := client.scope.AuthorizeNavigation(value) + if !decision.Allowed { + return "[blocked-url]" + } + parsed, err := url.Parse(decision.CanonicalURL) + if err != nil { + return "[blocked-url]" + } + parsed.User, parsed.RawQuery, parsed.Fragment = nil, "", "" + return safeDiagnosticText(parsed.String()) +} + +func safeHTTPMethod(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + for _, current := range value { + if current < 'A' || current > 'Z' { + return "UNKNOWN" + } + } + if value == "" || len(value) > 16 { + return "UNKNOWN" + } + return value +} + +func normalizeConsoleLevel(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "error", "assert": + return "error" + case "warning", "warn": + return "warning" + case "debug": + return "debug" + default: + return "info" + } +} + +func cdpTimestamp(value float64) time.Time { + if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 { + return time.Now().UTC() + } + seconds, fraction := math.Modf(value / 1000) + return time.Unix(int64(seconds), int64(fraction*float64(time.Second))).UTC() +} + +func sanitizeUIEvidenceText(raw []byte) ([]byte, error) { + if len(raw) == 0 || len(raw) > MaxUIEvidenceTextArtifactBytes { + return nil, errors.New("UI evidence text artifact is empty or exceeds its bound") + } + value := []byte(outputsafe.Sanitize(raw)) + if len(value) == 0 || len(value) > MaxUIEvidenceTextArtifactBytes { + return nil, errors.New("sanitized UI evidence text artifact exceeds its bound") + } + return value, nil +} + +func scaleRectangle(value image.Rectangle, dpr float64) image.Rectangle { + return image.Rect(int(math.Floor(float64(value.Min.X)*dpr)), + int(math.Floor(float64(value.Min.Y)*dpr)), + int(math.Ceil(float64(value.Max.X)*dpr)), + int(math.Ceil(float64(value.Max.Y)*dpr))) +} diff --git a/internal/browserruntime/ui_evidence_cdp_test.go b/internal/browserruntime/ui_evidence_cdp_test.go new file mode 100644 index 00000000..c7642d92 --- /dev/null +++ b/internal/browserruntime/ui_evidence_cdp_test.go @@ -0,0 +1,233 @@ +package browserruntime + +import ( + "bytes" + "context" + "encoding/binary" + "hash/crc32" + "image/png" + "strings" + "testing" + + "cyberagent-workbench/internal/uievidence" +) + +func TestUIEvidenceCDPUsesFixedActionsCapturesDiagnosticsAndMasksScreenshot(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + profileLease := facts.materialize(t) + allowedURL := "http://127.0.0.1:18080/page" + server := newScriptedCDPServer(t, allowedURL) + defer server.Close(t) + writeDevToolsActivePort(t, profileLease.DirectoryPath, server.port, server.path) + process := startFakeBrowserProcess(t, facts, profileLease) + defer func() { _ = process.Stop(context.Background()) }() + authorization, err := AuthorizeUIEvidenceCDP(facts.authorization, facts.session, + facts.identity, facts.acceptance, facts.ownership, facts.attempt, + facts.launchLease, facts.review, facts.networkEvidence, facts.networkReview, + facts.networkPlan, facts.permission, + ProductionRuntimeCapabilities{SafeWebStartEnabled: true, + DisposableProfileEnabled: true, NetworkContainmentEnabled: true, + RestrictedCDPEnabled: true}, facts.now) + if err != nil { + t.Fatal(err) + } + runtime, err := OpenRestrictedBrowserSession(t.Context(), authorization, + facts.authorization, facts.session, facts.identity, facts.acceptance, + facts.ownership, facts.attempt, facts.launchLease, facts.review, + facts.networkEvidence, facts.networkReview, facts.networkPlan, + facts.permission, profileLease, process) + if err != nil { + t.Fatal(err) + } + defer runtime.Close(context.Background()) + + environment := uievidence.Environment{Viewport: uievidence.Viewport{ + Width: 1280, Height: 720, DPR: 1}, Locale: "en-US", + Theme: uievidence.ThemeDark, ReducedMotion: true} + if err := runtime.ConfigureUIEvidence(t.Context(), environment); err != nil { + t.Fatal(err) + } + if _, err := runtime.Navigate(t.Context(), allowedURL); err != nil { + t.Fatal(err) + } + if err := runtime.AssertUIEvidenceSelector(t.Context(), "main", true); err != nil { + t.Fatal(err) + } + if err := runtime.AssertUIEvidenceSelector(t.Context(), "#absent", false); err != nil { + t.Fatal(err) + } + if err := runtime.AssertUIEvidenceSelector(t.Context(), "#eventual", true); err != nil { + t.Fatal(err) + } + if err := runtime.ClickUIEvidence(t.Context(), "button"); err != nil { + t.Fatal(err) + } + inputDigest, err := uievidence.InputSHA256("fixture input") + if err != nil { + t.Fatal(err) + } + if err := runtime.TypeUIEvidence(t.Context(), "input", "fixture input", inputDigest); err != nil { + t.Fatal(err) + } + + dom, err := runtime.DOMUIEvidence(t.Context()) + if err != nil { + t.Fatal(err) + } + if !dom.Redacted || bytes.Contains(dom.Content, []byte("abcdefghijklmnopqrstuvwxyz1234567890")) || + !bytes.Contains(dom.Content, []byte("[REDACTED:secret]")) { + t.Fatalf("DOM was not safely redacted: %s", dom.Content) + } + if _, err := runtime.AccessibilityUIEvidence(t.Context()); err != nil { + t.Fatal(err) + } + if _, err := runtime.PerformanceUIEvidence(t.Context()); err != nil { + t.Fatal(err) + } + diagnostics, diagnosticArtifact, err := runtime.DiagnosticsUIEvidence(t.Context()) + if err != nil { + t.Fatal(err) + } + if diagnostics.Summary.ConsoleWarnings != 2 || diagnostics.Summary.HTTPFailures != 1 || + diagnostics.Summary.ConsoleErrors != 0 || + bytes.Contains(diagnosticArtifact.Content, []byte("token=hidden")) { + t.Fatalf("unexpected diagnostics: %+v %s", diagnostics.Summary, diagnosticArtifact.Content) + } + screenshot, width, height, err := runtime.ScreenshotUIEvidence(t.Context(), + []string{"[data-dynamic]"}, 1) + if err != nil { + t.Fatal(err) + } + decoded, err := png.Decode(bytes.NewReader(screenshot.PNG)) + if err != nil || width != 10 || height != 10 { + t.Fatalf("screenshot=%dx%d err=%v", width, height, err) + } + r, g, b, _ := decoded.At(3, 3).RGBA() + if r > 0x2000 || g > 0x3000 || b > 0x4000 { + t.Fatalf("dynamic region was not masked: %x %x %x", r, g, b) + } + + methods := strings.Join(server.Methods(), "\n") + for _, forbidden := range []string{"Runtime.evaluate", "Runtime.callFunctionOn", + "Network.getAllCookies", "Network.getResponseBody", "Fetch.fulfillRequest"} { + if strings.Contains(methods, forbidden) { + t.Fatalf("UI evidence used forbidden CDP method %s", forbidden) + } + } +} + +func TestUIEvidenceCDPMethodSetRemainsClosed(t *testing.T) { + want := []string{"Accessibility.enable", "Accessibility.getFullAXTree", + "DOM.focus", "DOM.getBoxModel", "DOM.getOuterHTML", "DOM.querySelector", + "DOM.scrollIntoViewIfNeeded", "Emulation.setDeviceMetricsOverride", + "Emulation.setEmulatedMedia", "Emulation.setLocaleOverride", + "Input.dispatchMouseEvent", "Input.insertText", "Log.enable", + "Performance.enable", "Performance.getMetrics", "Runtime.enable"} + if len(uiEvidenceCDPMethods) != len(want) { + t.Fatalf("UI evidence CDP method set changed: %#v", uiEvidenceCDPMethods) + } + for _, method := range want { + if uiEvidenceCDPMethods[method] != restrictedCDPTargetMethod { + t.Fatalf("UI evidence CDP method %q is absent or mis-scoped", method) + } + } + for _, forbidden := range []string{"Runtime.evaluate", "Runtime.callFunctionOn", + "Network.getAllCookies", "Network.getResponseBody", "Fetch.fulfillRequest", + "Storage.getCookies"} { + if _, ok := uiEvidenceCDPMethods[forbidden]; ok { + t.Fatalf("forbidden method %q entered UI evidence allowlist", forbidden) + } + } +} + +func TestUIEvidenceCDPRequiresItsExactAuthorizationDerivation(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + capabilities := ProductionRuntimeCapabilities{SafeWebStartEnabled: true, + DisposableProfileEnabled: true, NetworkContainmentEnabled: true, + RestrictedCDPEnabled: true} + restricted, err := AuthorizeRestrictedCDP(facts.authorization, facts.session, + facts.identity, facts.acceptance, facts.ownership, facts.attempt, + facts.launchLease, facts.review, facts.networkEvidence, facts.networkReview, + facts.networkPlan, facts.permission, capabilities, facts.now) + if err != nil { + t.Fatal(err) + } + restricted.UIEvidenceAuthorized = true + restricted.Fingerprint = browserRuntimeFingerprint(restricted) + if ValidateRestrictedCDPAuthorization(restricted, facts.authorization, + facts.session, facts.permission) == nil { + t.Fatal("ordinary restricted CDP authorization widened to UI evidence") + } + + uiEvidence, err := AuthorizeUIEvidenceCDP(facts.authorization, facts.session, + facts.identity, facts.acceptance, facts.ownership, facts.attempt, + facts.launchLease, facts.review, facts.networkEvidence, facts.networkReview, + facts.networkPlan, facts.permission, capabilities, facts.now) + if err != nil { + t.Fatal(err) + } + uiEvidence.UIEvidenceAuthorized = false + uiEvidence.Fingerprint = browserRuntimeFingerprint(uiEvidence) + if ValidateUIEvidenceCDPAuthorization(uiEvidence, facts.authorization, + facts.session, facts.permission) == nil { + t.Fatal("UI evidence authorization lost its dedicated method-set bit") + } +} + +func TestUIEvidenceDiagnosticBudgetFailsClosedInsteadOfDroppingLateErrors(t *testing.T) { + client := &restrictedCDPClient{uiEvidence: true, maxRequests: 1} + if err := client.appendConsole(UIEvidenceConsoleEntry{Level: "info"}); err != nil { + t.Fatal(err) + } + if err := client.appendConsole(UIEvidenceConsoleEntry{Level: "error"}); err == nil || + client.budgetErr == nil { + t.Fatal("late console error was silently dropped after the diagnostic bound") + } + + client = &restrictedCDPClient{uiEvidence: true, maxRequests: 1} + if err := client.appendPageError(UIEvidencePageError{Text: "first"}); err != nil { + t.Fatal(err) + } + if err := client.appendPageError(UIEvidencePageError{Text: "late"}); err == nil || + client.budgetErr == nil { + t.Fatal("late page error was silently dropped after the diagnostic bound") + } +} + +func TestUIEvidenceDiagnosticURLsStayInsideScopeAndDropSecrets(t *testing.T) { + scope, err := NewTargetScope(ProfileSafeWeb, + []string{"http://127.0.0.1:18080"}) + if err != nil { + t.Fatal(err) + } + client := &restrictedCDPClient{scope: scope} + if got := client.safeEvidenceURL( + "http://127.0.0.1:18080/page?token=hidden"); got != "http://127.0.0.1:18080/page" { + t.Fatalf("allowed diagnostic URL=%q", got) + } + for _, rawURL := range []string{ + "data:text/plain,token=abcdefghijklmnopqrstuvwxyz1234567890", + "file:///C:/Users/example/private.txt", + "blob:http://127.0.0.1:18080/secret-identifier", + "http://127.0.0.1:18081/other-origin?token=hidden", + } { + if got := client.safeEvidenceURL(rawURL); got != "[blocked-url]" { + t.Fatalf("out-of-scope diagnostic URL %q persisted as %q", rawURL, got) + } + } +} + +func TestUIEvidencePNGDimensionsAreBoundedBeforeDecode(t *testing.T) { + content := append([]byte(nil), restrictedTestPNG()...) + // PNG IHDR stores width/height at byte offsets 16 and 20. Recompute the + // IHDR CRC so DecodeConfig observes a structurally valid oversized image. + binary.BigEndian.PutUint32(content[16:20], + uint32(uievidence.MaxScreenshotWidth+1)) + binary.BigEndian.PutUint32(content[29:33], crc32.ChecksumIEEE(content[12:29])) + if _, err := decodeBoundedUIEvidencePNG(content); err == nil { + t.Fatal("oversized PNG reached full screenshot decoding") + } + if _, err := decodeBoundedUIEvidencePNG(restrictedTestPNG()); err != nil { + t.Fatalf("bounded PNG rejected: %v", err) + } +} diff --git a/internal/browserruntime/ui_evidence_runtime_windows_test.go b/internal/browserruntime/ui_evidence_runtime_windows_test.go new file mode 100644 index 00000000..8a3db6ed --- /dev/null +++ b/internal/browserruntime/ui_evidence_runtime_windows_test.go @@ -0,0 +1,898 @@ +//go:build windows + +package browserruntime + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image/png" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + "unsafe" + + "cyberagent-workbench/internal/uievidence" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const uiEvidenceRuntimeSmokeEnvironment = "CYBERAGENT_UI_EVIDENCE_SMOKE" + +type uiEvidenceSmokeArtifact struct { + File string `json:"file"` + MIME string `json:"mime"` + SHA256 string `json:"sha256"` + Bytes int `json:"bytes"` + Width int `json:"width"` + Height int `json:"height"` + Matrix string `json:"matrix"` + Environment uievidence.Environment `json:"environment"` +} + +type uiEvidenceSmokeReceipt struct { + ProtocolVersion string `json:"protocol_version"` + SourceCommit string `json:"source_commit"` + DirtyDigest string `json:"dirty_digest"` + CleanCheckout bool `json:"clean_checkout"` + BrowserProduct BrowserProduct `json:"browser_product"` + BrowserChannel BrowserChannel `json:"browser_channel"` + BrowserVersion string `json:"browser_version"` + ExecutableSHA256 string `json:"executable_sha256"` + DriverProtocol string `json:"driver_protocol"` + OriginScope string `json:"origin_scope"` + TemporaryProfile bool `json:"temporary_profile"` + Headless bool `json:"headless"` + RetentionDays int `json:"retention_days"` + Routes []string `json:"routes"` + RegressionCaught bool `json:"regression_caught"` + BrowserTreeReaped bool `json:"browser_tree_reaped"` + BrowserPortFreed bool `json:"browser_port_released"` + ProfileRemoved bool `json:"profile_removed"` + FixtureReaped bool `json:"fixture_server_reaped"` + UntrustedEvidence bool `json:"untrusted_evidence"` + CapturedAt time.Time `json:"captured_at"` + Artifacts []uiEvidenceSmokeArtifact `json:"artifacts"` +} + +type uiEvidenceSmokeLaunchReceipt struct { + ProtocolVersion string `json:"protocol_version"` + SourceCommit string `json:"source_commit"` + DirtyDigest string `json:"dirty_digest"` + CleanCheckout bool `json:"clean_checkout"` + BrowserProduct BrowserProduct `json:"browser_product"` + BrowserChannel BrowserChannel `json:"browser_channel"` + BrowserVersion string `json:"browser_version"` + ExecutableSHA256 string `json:"executable_sha256"` + ProcessAdapter string `json:"process_adapter"` + TemporaryProfile bool `json:"temporary_profile"` + Headless bool `json:"headless"` + CapturedAt time.Time `json:"captured_at"` +} + +type uiEvidenceSmokeStartupDiagnostic struct { + ProtocolVersion string `json:"protocol_version"` + Reason string `json:"reason"` + ProcessAdapter string `json:"process_adapter"` + PID int `json:"pid"` + MainProcessActive bool `json:"main_process_active"` + MainProcessExitCode uint32 `json:"main_process_exit_code"` + JobTotalProcesses uint32 `json:"job_total_processes"` + JobActiveProcesses uint32 `json:"job_active_processes"` + JobTerminatedProcesses uint32 `json:"job_terminated_processes"` + ProcessQueryError string `json:"process_query_error,omitempty"` + JobQueryError string `json:"job_query_error,omitempty"` + ProfileEntries []string `json:"profile_entries"` + ProfileReadError string `json:"profile_read_error,omitempty"` + RemoteDebuggingPolicies map[string]string `json:"remote_debugging_policies"` + CapturedAt time.Time `json:"captured_at"` +} + +// TestInstalledEdgeUIEvidenceHeadlessMatrixAndRegression is opt-in because it +// starts the real, fixed-location Edge binary. Unit tests cover every +// authorization/lifecycle boundary; this test deliberately exercises the same +// closed CDP transport against a real engine and a deterministic loopback page. +func TestInstalledEdgeUIEvidenceHeadlessMatrixAndRegression(t *testing.T) { + if os.Getenv(uiEvidenceRuntimeSmokeEnvironment) != "1" { + t.Skip("set CYBERAGENT_UI_EVIDENCE_SMOKE=1 to exercise real Edge UI evidence") + } + + commit, dirtyDigest, clean := uiEvidenceSmokeSourceBinding(t) + identity := installedStableEdge(t) + artifactDirectory := uiEvidenceSmokeArtifactDirectory(t) + writeUIEvidenceSmokeLaunchReceipt(t, artifactDirectory, uiEvidenceSmokeLaunchReceipt{ + ProtocolVersion: "ui-evidence-ci-launch.v1", SourceCommit: commit, + DirtyDigest: dirtyDigest, CleanCheckout: clean, + BrowserProduct: identity.Product, BrowserChannel: identity.Channel, + BrowserVersion: identity.Version, ExecutableSHA256: identity.ExecutableSHA256, + ProcessAdapter: WindowsBrowserProcessAdapterName, + TemporaryProfile: true, Headless: true, CapturedAt: time.Now().UTC(), + }) + t.Logf("real Edge launch: commit=%s version=%s executable_sha256=%s adapter=%s", + commit, identity.Version, identity.ExecutableSHA256, + WindowsBrowserProcessAdapterName) + origin, closeFixture := startUIEvidenceSmokeFixture(t) + defer closeFixture() + + scope, err := NewTargetScope(ProfileSafeWeb, []string{origin}) + if err != nil { + t.Fatal(err) + } + profileRoot := t.TempDir() + profilePath := filepath.Join(profileRoot, "edge-profile") + if !pathWithinRoot(profileRoot, profilePath) { + t.Fatal("smoke browser profile escaped its temporary root") + } + if err := os.Mkdir(profilePath, 0o700); err != nil { + t.Fatal(err) + } + + process := startUIEvidenceSmokeEdge(t, identity, profilePath) + defer process.Stop(t) + + endpoint := waitForUIEvidenceSmokeEndpoint(t, profilePath, process, + artifactDirectory) + connection, err := dialRestrictedCDP(t.Context(), endpoint) + if err != nil { + t.Fatal(err) + } + client := &restrictedCDPClient{conn: connection, scope: scope, + maxRequests: 500, uiEvidence: true} + operationContext, cancel := context.WithTimeout(t.Context(), 45*time.Second) + defer cancel() + if err := client.initialize(operationContext); err != nil { + _ = connection.Close() + t.Fatalf("initialize real Edge restricted CDP: %v", err) + } + clientClosed := false + defer func() { + if !clientClosed { + _ = client.close(context.Background()) + } + }() + + receipt := uiEvidenceSmokeReceipt{ + ProtocolVersion: "ui-evidence-ci-smoke.v1", SourceCommit: commit, + DirtyDigest: dirtyDigest, CleanCheckout: clean, BrowserProduct: identity.Product, + BrowserChannel: identity.Channel, BrowserVersion: identity.Version, + ExecutableSHA256: identity.ExecutableSHA256, + DriverProtocol: uievidence.DriverProtocolVersion, OriginScope: origin, + TemporaryProfile: true, Headless: true, RetentionDays: 5, + Routes: []string{"/fixed", "/regression"}, + UntrustedEvidence: true, + CapturedAt: time.Now().UTC(), + } + + matrix := []struct { + name string + environment uievidence.Environment + }{ + {name: "desktop-light-en", environment: uievidence.Environment{ + Viewport: uievidence.Viewport{Width: 1440, Height: 900, DPR: 1}, + Locale: "en-US", Theme: uievidence.ThemeLight}}, + {name: "mobile-dark-zh-reduced", environment: uievidence.Environment{ + Viewport: uievidence.Viewport{Width: 390, Height: 844, DPR: 2}, + Locale: "zh-CN", Theme: uievidence.ThemeDark, ReducedMotion: true}}, + } + for _, item := range matrix { + t.Run(item.name, func(t *testing.T) { + configureUIEvidenceSmoke(t, client, item.environment) + navigateUIEvidenceSmoke(t, client, origin+"/fixed") + assertUIEvidenceSmokeSelector(t, client, + fmt.Sprintf(`body[data-theme=%q]`, item.environment.Theme), true) + motion := "full" + if item.environment.ReducedMotion { + motion = "reduced" + } + assertUIEvidenceSmokeSelector(t, client, + fmt.Sprintf(`body[data-motion=%q]`, motion), true) + assertUIEvidenceSmokeSelector(t, client, + fmt.Sprintf(`body[data-locale=%q]`, item.environment.Locale), true) + + clickUIEvidenceSmoke(t, client, "#repair") + waitForUIEvidenceSmokeSelector(t, client, `body[data-state="fixed"]`, true) + typeUIEvidenceSmoke(t, client, "#fixture-input", "fixture input") + waitForUIEvidenceSmokeSelector(t, client, `body[data-typed="true"]`, true) + verifyUIEvidenceSmokeTextCaptures(t, client) + + screenshot, err := client.captureScreenshot(t.Context(), "ci-smoke") + if err != nil { + t.Fatal(err) + } + configuration, err := png.DecodeConfig(bytes.NewReader(screenshot.PNG)) + if err != nil { + t.Fatalf("decode real Edge screenshot: %v", err) + } + wantWidth := int(float64(item.environment.Viewport.Width) * + item.environment.Viewport.DPR) + wantHeight := int(float64(item.environment.Viewport.Height) * + item.environment.Viewport.DPR) + if configuration.Width != wantWidth || configuration.Height != wantHeight { + t.Fatalf("screenshot size = %dx%d, want %dx%d", configuration.Width, + configuration.Height, wantWidth, wantHeight) + } + fileName := item.name + ".png" + writeUIEvidenceSmokeArtifact(t, artifactDirectory, fileName, screenshot.PNG) + receipt.Artifacts = append(receipt.Artifacts, uiEvidenceSmokeArtifact{ + File: fileName, MIME: "image/png", SHA256: screenshot.SHA256, + Bytes: len(screenshot.PNG), Width: configuration.Width, + Height: configuration.Height, Matrix: item.name, Environment: item.environment, + }) + }) + } + + // This route differs only in real page behavior: its button lacks the event + // handler. Source/build checks cannot make the post-click selector appear. + configureUIEvidenceSmoke(t, client, matrix[0].environment) + navigateUIEvidenceSmoke(t, client, origin+"/regression") + clickUIEvidenceSmoke(t, client, "#repair") + receipt.RegressionCaught = !uiEvidenceSmokeSelectorEventually( + t.Context(), client, `body[data-state="fixed"]`, true, 750*time.Millisecond) + if !receipt.RegressionCaught { + t.Fatal("real page assertion did not catch the deliberate interaction regression") + } + screenshot, err := client.captureScreenshot(t.Context(), "ci-smoke") + if err != nil { + t.Fatal(err) + } + configuration, err := png.DecodeConfig(bytes.NewReader(screenshot.PNG)) + if err != nil { + t.Fatal(err) + } + writeUIEvidenceSmokeArtifact(t, artifactDirectory, "regression-detected.png", + screenshot.PNG) + receipt.Artifacts = append(receipt.Artifacts, uiEvidenceSmokeArtifact{ + File: "regression-detected.png", MIME: "image/png", SHA256: screenshot.SHA256, + Bytes: len(screenshot.PNG), Width: configuration.Width, + Height: configuration.Height, Matrix: "regression-desktop", + Environment: matrix[0].environment, + }) + + diagnosticContext, diagnosticCancel := context.WithTimeout(t.Context(), 5*time.Second) + if err := client.drainUIEvidenceEventsUntilIdle(diagnosticContext); err != nil { + diagnosticCancel() + t.Fatalf("settle real Edge diagnostics: %v", err) + } + diagnosticCancel() + diagnostics := client.diagnostics() + if diagnostics.Summary.ConsoleErrors != 0 || diagnostics.Summary.PageErrors != 0 || + diagnostics.Summary.FailedRequests != 0 || diagnostics.Summary.HTTPFailures != 0 || + diagnostics.Summary.BlockedRequests != 0 { + t.Fatalf("real Edge diagnostics are not clean: %+v", diagnostics.Summary) + } + for _, request := range diagnostics.Network { + if request.URL == "" { + continue + } + parsed, parseErr := url.Parse(request.URL) + if parseErr != nil || parsed.Scheme+"://"+parsed.Host != origin { + t.Fatalf("real Edge request escaped the exact loopback origin: %q", request.URL) + } + } + closeContext, closeCancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := client.close(closeContext); err != nil { + closeCancel() + t.Fatalf("close real Edge restricted CDP: %v", err) + } + clientClosed = true + closeCancel() + process.Stop(t) + receipt.BrowserTreeReaped = process.reaped + receipt.BrowserPortFreed = uiEvidenceSmokePortReleased(t, endpoint) + if err := removeProfileTreeBounded(profilePath, profileCleanupRetryTimeout, + os.RemoveAll); err != nil { + t.Fatalf("remove dedicated UI evidence profile: %v", err) + } + if _, err := os.Lstat(profilePath); !os.IsNotExist(err) { + t.Fatalf("dedicated UI evidence profile remained after cleanup: %v", err) + } + receipt.ProfileRemoved = true + closeFixture() + receipt.FixtureReaped = true + if !receipt.BrowserTreeReaped || !receipt.BrowserPortFreed { + t.Fatal("real Edge cleanup receipt is incomplete") + } + writeUIEvidenceSmokeReceipt(t, artifactDirectory, receipt) + t.Logf("real Edge UI evidence: commit=%s version=%s executable_sha256=%s artifacts=%d regression_caught=%t", + commit, identity.Version, identity.ExecutableSHA256, len(receipt.Artifacts), + receipt.RegressionCaught) +} + +func installedStableEdge(t *testing.T) BrowserExecutableIdentity { + t.Helper() + identities, err := DiscoverInstalledBrowsers() + if err != nil { + t.Fatal(err) + } + for _, identity := range identities { + if identity.Product == BrowserProductEdge && identity.Channel == BrowserChannelStable && + identity.VersionVerified && identity.Version != "" { + return identity + } + } + t.Fatal("fixed-location stable Edge with a verified version is required") + return BrowserExecutableIdentity{} +} + +func uiEvidenceSmokeSourceBinding(t *testing.T) (string, string, bool) { + t.Helper() + rootRaw, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Fatalf("resolve UI evidence source root: %v", err) + } + root := strings.TrimSpace(string(rootRaw)) + commitRaw, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output() + if err != nil { + t.Fatalf("resolve UI evidence source commit: %v", err) + } + commit := strings.ToLower(strings.TrimSpace(string(commitRaw))) + decoded, err := hex.DecodeString(commit) + if err != nil || len(decoded) != 20 { + t.Fatalf("invalid UI evidence source commit %q", commit) + } + statusRaw, err := exec.Command("git", "-C", root, "status", "--porcelain=v1", + "--untracked-files=all").Output() + if err != nil { + t.Fatalf("inspect UI evidence source status: %v", err) + } + clean := len(bytes.TrimSpace(statusRaw)) == 0 + if os.Getenv("GITHUB_ACTIONS") == "true" && !clean { + t.Fatalf("real UI evidence CI must run from a clean fixed commit: %s", statusRaw) + } + digest := sha256.Sum256(statusRaw) + return commit, hex.EncodeToString(digest[:]), clean +} + +func startUIEvidenceSmokeFixture(t *testing.T) (string, func()) { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/fixed", func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(response, uiEvidenceSmokeHTML(true)) + }) + mux.HandleFunc("/regression", func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(response, uiEvidenceSmokeHTML(false)) + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: time.Second} + serveDone := make(chan struct{}) + go func() { + _ = server.Serve(listener) + close(serveDone) + }() + origin := "http://" + listener.Addr().String() + return origin, func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownContext) + <-serveDone + } +} + +func uiEvidenceSmokeHTML(fixed bool) string { + handler := "" + if fixed { + handler = `repair.addEventListener("click",()=>document.body.dataset.state="fixed");` + } + return ` + +UI evidence fixture + +

Runtime evidence

+ + +
synthetic changing content
` +} + +type uiEvidenceSmokeEdgeProcess struct { + platform browserPlatformProcess + stopped bool + reaped bool +} + +func startUIEvidenceSmokeEdge(t *testing.T, identity BrowserExecutableIdentity, + profilePath string, +) *uiEvidenceSmokeEdgeProcess { + t.Helper() + for _, name := range profileEnvironmentDirectoryNames { + path := filepath.Join(profilePath, name) + if !pathWithinRoot(profilePath, path) { + t.Fatal("smoke browser environment directory escaped the temporary profile") + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := validateBrowserEnvironmentDirectories(profilePath); err != nil { + t.Fatal(err) + } + arguments := fixedRestrictedBrowserArguments(profilePath) + now := time.Now().UTC() + spec := BrowserStartSpec{ + ProtocolVersion: BrowserStartSpecProtocolVersion, + ExecutableIdentityFingerprint: identity.Fingerprint, + ExecutablePath: identity.CanonicalPath, ExecutableSHA256: identity.ExecutableSHA256, + ProfilePath: profilePath, Arguments: arguments, InitialURL: "about:blank", + RemoteDebuggingAddress: "127.0.0.1", RemoteDebuggingPort: 0, + ActiveProcessLimit: MaxBrowserProcessCount, JobMemoryLimitBytes: MaxBrowserJobMemoryBytes, + LoopbackNavigationRequired: true, HostNameResolutionDisabled: true, + NetworkDefaultDeny: true, CreatedAt: now, RuntimeDeadline: now.Add(2 * time.Minute), + } + spec.Fingerprint = browserRuntimeFingerprint(spec) + platform, err := (windowsBrowserProcessStarter{}).Start(t.Context(), spec) + if err != nil { + t.Fatalf("start fixed-location Edge through the production process adapter: %v", err) + } + return &uiEvidenceSmokeEdgeProcess{platform: platform} +} + +func (process *uiEvidenceSmokeEdgeProcess) Active() bool { + if process == nil || process.platform == nil || process.stopped { + return false + } + select { + case <-process.platform.Done(): + return false + default: + return true + } +} + +func (process *uiEvidenceSmokeEdgeProcess) Stop(t *testing.T) { + t.Helper() + if process == nil || process.platform == nil || process.stopped { + return + } + process.stopped = true + stopContext, stopCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer stopCancel() + if err := process.platform.Stop(stopContext, false); err != nil { + t.Errorf("stop exact UI evidence smoke Job Object: %v", err) + } + exit, ok := process.platform.Exit() + process.reaped = ok && exit.TreeReaped + if !ok || !process.reaped { + t.Errorf("real Edge process tree did not exit after Job Object cleanup") + } +} + +func uiEvidenceSmokePortReleased(t *testing.T, endpoint *url.URL) bool { + t.Helper() + if endpoint == nil || endpoint.Port() == "" { + t.Fatal("real Edge DevTools endpoint did not contain a port") + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + address := net.JoinHostPort("127.0.0.1", endpoint.Port()) + for { + connection, err := net.DialTimeout("tcp4", address, 100*time.Millisecond) + if err != nil { + return true + } + _ = connection.Close() + select { + case <-deadline.C: + t.Fatalf("real Edge DevTools listener remained reachable after cleanup") + case <-ticker.C: + } + } +} + +func waitForUIEvidenceSmokeEndpoint(t *testing.T, profilePath string, + process *uiEvidenceSmokeEdgeProcess, artifactDirectory string, +) *url.URL { + t.Helper() + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + endpoint, pending, err := readDevToolsEndpoint(profilePath) + if err != nil { + t.Fatal(err) + } + if !pending { + return endpoint + } + if !process.Active() { + exit, _ := process.platform.Exit() + t.Fatalf("real Edge Job Object exited before DevTools was ready: %+v", exit) + } + select { + case <-deadline.C: + diagnostic := collectUIEvidenceSmokeStartupDiagnostic(profilePath, process) + raw, marshalErr := json.MarshalIndent(diagnostic, "", " ") + if marshalErr != nil { + t.Fatalf("marshal real Edge startup diagnostic: %v", marshalErr) + } + writeUIEvidenceSmokeArtifact(t, artifactDirectory, + "startup-failure.json", append(raw, '\n')) + t.Fatal("timed out waiting for real Edge DevTools endpoint") + case <-ticker.C: + } + } +} + +func collectUIEvidenceSmokeStartupDiagnostic(profilePath string, + process *uiEvidenceSmokeEdgeProcess, +) uiEvidenceSmokeStartupDiagnostic { + diagnostic := uiEvidenceSmokeStartupDiagnostic{ + ProtocolVersion: "ui-evidence-ci-startup-diagnostic.v1", + Reason: "devtools_endpoint_timeout", ProcessAdapter: WindowsBrowserProcessAdapterName, + RemoteDebuggingPolicies: readUIEvidenceSmokeRemoteDebuggingPolicies(), + CapturedAt: time.Now().UTC(), + } + if process != nil { + diagnostic.PID = process.platform.PID() + if platform, ok := process.platform.(*windowsBrowserProcess); ok { + platform.mu.Lock() + if platform.process != 0 { + var exitCode uint32 + if err := windows.GetExitCodeProcess(platform.process, &exitCode); err != nil { + diagnostic.ProcessQueryError = err.Error() + } else { + diagnostic.MainProcessExitCode = exitCode + diagnostic.MainProcessActive = exitCode == 259 + } + } + if platform.job != 0 { + accounting := struct { + TotalUserTime int64 + TotalKernelTime int64 + ThisPeriodTotalUserTime int64 + ThisPeriodTotalKernelTime int64 + TotalPageFaultCount uint32 + TotalProcesses uint32 + ActiveProcesses uint32 + TotalTerminatedProcesses uint32 + }{} + if err := windows.QueryInformationJobObject(platform.job, + windows.JobObjectBasicAccountingInformation, + uintptr(unsafe.Pointer(&accounting)), + uint32(unsafe.Sizeof(accounting)), nil); err != nil { + diagnostic.JobQueryError = err.Error() + } else { + diagnostic.JobTotalProcesses = accounting.TotalProcesses + diagnostic.JobActiveProcesses = accounting.ActiveProcesses + diagnostic.JobTerminatedProcesses = accounting.TotalTerminatedProcesses + } + } + platform.mu.Unlock() + } + } + diagnostic.ProfileEntries, diagnostic.ProfileReadError = + readUIEvidenceSmokeProfileEntries(profilePath) + return diagnostic +} + +func readUIEvidenceSmokeProfileEntries(profilePath string) ([]string, string) { + entries, err := os.ReadDir(profilePath) + if err != nil { + return nil, err.Error() + } + result := make([]string, 0, 64) + appendEntry := func(prefix string, entry os.DirEntry) { + if len(result) >= 64 { + return + } + kind := "file" + if entry.IsDir() { + kind = "directory" + } + size := int64(0) + if info, infoErr := entry.Info(); infoErr == nil && !entry.IsDir() { + size = info.Size() + } + result = append(result, fmt.Sprintf("%s:%s%s:%d", kind, prefix, + entry.Name(), size)) + } + for _, entry := range entries { + appendEntry("", entry) + if !entry.IsDir() || len(result) >= 64 { + continue + } + children, childErr := os.ReadDir(filepath.Join(profilePath, entry.Name())) + if childErr != nil { + result = append(result, fmt.Sprintf("error:%s:%v", entry.Name(), childErr)) + continue + } + for _, child := range children { + appendEntry(entry.Name()+"/", child) + } + } + return result, "" +} + +func readUIEvidenceSmokeRemoteDebuggingPolicies() map[string]string { + result := map[string]string{} + for _, item := range []struct { + name string + root registry.Key + view uint32 + }{ + {name: "hklm_64", root: registry.LOCAL_MACHINE, view: registry.WOW64_64KEY}, + {name: "hklm_32", root: registry.LOCAL_MACHINE, view: registry.WOW64_32KEY}, + {name: "hkcu_64", root: registry.CURRENT_USER, view: registry.WOW64_64KEY}, + {name: "hkcu_32", root: registry.CURRENT_USER, view: registry.WOW64_32KEY}, + } { + key, err := registry.OpenKey(item.root, `SOFTWARE\Policies\Microsoft\Edge`, + registry.QUERY_VALUE|item.view) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + result[item.name] = "unset" + } else { + result[item.name] = "unreadable:" + err.Error() + } + continue + } + value, _, valueErr := key.GetIntegerValue("RemoteDebuggingAllowed") + _ = key.Close() + if errors.Is(valueErr, windows.ERROR_FILE_NOT_FOUND) { + result[item.name] = "unset" + } else if valueErr != nil { + result[item.name] = "unreadable:" + valueErr.Error() + } else { + result[item.name] = fmt.Sprintf("%d", value) + } + } + return result +} + +func configureUIEvidenceSmoke(t *testing.T, client *restrictedCDPClient, + environment uievidence.Environment, +) { + t.Helper() + if err := environment.Validate(); err != nil { + t.Fatal(err) + } + if err := client.call(t.Context(), client.sessionID, + "Emulation.setDeviceMetricsOverride", map[string]any{ + "width": environment.Viewport.Width, "height": environment.Viewport.Height, + "deviceScaleFactor": environment.Viewport.DPR, "mobile": false, + "screenWidth": environment.Viewport.Width, + "screenHeight": environment.Viewport.Height, + }, &struct{}{}); err != nil { + t.Fatal(err) + } + if err := client.call(t.Context(), client.sessionID, "Emulation.setLocaleOverride", + map[string]any{"locale": environment.Locale}, &struct{}{}); err != nil { + t.Fatal(err) + } + motion := "no-preference" + if environment.ReducedMotion { + motion = "reduce" + } + features := []map[string]string{ + {"name": "prefers-color-scheme", "value": string(environment.Theme)}, + {"name": "prefers-reduced-motion", "value": motion}, + } + if err := client.call(t.Context(), client.sessionID, "Emulation.setEmulatedMedia", + map[string]any{"media": "screen", "features": features}, &struct{}{}); err != nil { + t.Fatal(err) + } +} + +func navigateUIEvidenceSmoke(t *testing.T, client *restrictedCDPClient, target string) { + t.Helper() + decision := client.scope.AuthorizeNavigation(target) + if !decision.Allowed { + t.Fatalf("smoke target is outside exact scope: %s", target) + } + client.pageLoaded = false + client.blockedDocument = false + var navigation struct { + FrameID string `json:"frameId"` + ErrorText string `json:"errorText"` + } + if err := client.call(t.Context(), client.sessionID, "Page.navigate", + map[string]any{"url": decision.CanonicalURL}, &navigation); err != nil { + t.Fatal(err) + } + if navigation.ErrorText != "" || client.blockedDocument { + t.Fatalf("real Edge navigation was blocked: %s", navigation.ErrorText) + } + if err := client.waitForPageLoad(t.Context()); err != nil { + t.Fatal(err) + } + finalURL, _, _, err := client.documentIdentity(t.Context()) + if err != nil || finalURL != decision.CanonicalURL { + t.Fatalf("real Edge final URL = %q, want %q, err=%v", finalURL, + decision.CanonicalURL, err) + } +} + +func clickUIEvidenceSmoke(t *testing.T, client *restrictedCDPClient, selector string) { + t.Helper() + nodeID, found, err := client.querySelector(t.Context(), selector) + if err != nil || !found { + t.Fatalf("real Edge click selector %q: found=%t err=%v", selector, found, err) + } + if err := client.call(t.Context(), client.sessionID, "DOM.scrollIntoViewIfNeeded", + map[string]any{"nodeId": nodeID}, &struct{}{}); err != nil { + t.Fatal(err) + } + rectangle, err := client.nodeRectangle(t.Context(), nodeID) + if err != nil { + t.Fatal(err) + } + x := float64(rectangle.Min.X+rectangle.Max.X) / 2 + y := float64(rectangle.Min.Y+rectangle.Max.Y) / 2 + for _, eventType := range []string{"mousePressed", "mouseReleased"} { + if err := client.call(t.Context(), client.sessionID, "Input.dispatchMouseEvent", + map[string]any{"type": eventType, "x": x, "y": y, "button": "left", + "clickCount": 1}, &struct{}{}); err != nil { + t.Fatal(err) + } + } +} + +func typeUIEvidenceSmoke(t *testing.T, client *restrictedCDPClient, + selector, value string, +) { + t.Helper() + nodeID, found, err := client.querySelector(t.Context(), selector) + if err != nil || !found { + t.Fatalf("real Edge type selector %q: found=%t err=%v", selector, found, err) + } + if err := client.call(t.Context(), client.sessionID, "DOM.focus", + map[string]any{"nodeId": nodeID}, &struct{}{}); err != nil { + t.Fatal(err) + } + if err := client.call(t.Context(), client.sessionID, "Input.insertText", + map[string]any{"text": value}, &struct{}{}); err != nil { + t.Fatal(err) + } +} + +func assertUIEvidenceSmokeSelector(t *testing.T, client *restrictedCDPClient, + selector string, expected bool, +) { + t.Helper() + _, found, err := client.querySelector(t.Context(), selector) + if err != nil || found != expected { + t.Fatalf("real Edge selector %q: found=%t want=%t err=%v", selector, found, + expected, err) + } +} + +func waitForUIEvidenceSmokeSelector(t *testing.T, client *restrictedCDPClient, + selector string, expected bool, +) { + t.Helper() + if !uiEvidenceSmokeSelectorEventually(t.Context(), client, selector, expected, + 2*time.Second) { + t.Fatalf("real Edge selector %q did not reach present=%t", selector, expected) + } +} + +func uiEvidenceSmokeSelectorEventually(ctx context.Context, client *restrictedCDPClient, + selector string, expected bool, timeout time.Duration, +) bool { + deadline := time.Now().Add(timeout) + for { + _, found, err := client.querySelector(ctx, selector) + if err == nil && found == expected { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(25 * time.Millisecond) + } +} + +func verifyUIEvidenceSmokeTextCaptures(t *testing.T, client *restrictedCDPClient) { + t.Helper() + rootID, err := client.documentNodeID(t.Context()) + if err != nil { + t.Fatal(err) + } + var document struct { + OuterHTML string `json:"outerHTML"` + } + if err := client.call(t.Context(), client.sessionID, "DOM.getOuterHTML", + map[string]any{"nodeId": rootID}, &document); err != nil { + t.Fatal(err) + } + if !strings.Contains(document.OuterHTML, `data-state="fixed"`) { + t.Fatal("real Edge DOM capture missed the post-interaction state") + } + var accessibility struct { + Nodes []json.RawMessage `json:"nodes"` + } + if err := client.call(t.Context(), client.sessionID, + "Accessibility.getFullAXTree", map[string]any{"depth": 64}, + &accessibility); err != nil || len(accessibility.Nodes) == 0 { + t.Fatalf("real Edge accessibility capture is empty: %v", err) + } + var performance struct { + Metrics []json.RawMessage `json:"metrics"` + } + if err := client.call(t.Context(), client.sessionID, "Performance.getMetrics", + map[string]any{}, &performance); err != nil || len(performance.Metrics) == 0 { + t.Fatalf("real Edge performance capture is empty: %v", err) + } +} + +func uiEvidenceSmokeArtifactDirectory(t *testing.T) string { + t.Helper() + directory := strings.TrimSpace(os.Getenv("CYBERAGENT_UI_EVIDENCE_ARTIFACT_DIR")) + if directory == "" { + return t.TempDir() + } + if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory { + t.Fatalf("UI evidence artifact directory must be an absolute clean path") + } + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + return directory +} + +func writeUIEvidenceSmokeArtifact(t *testing.T, directory, name string, content []byte) { + t.Helper() + path := filepath.Join(directory, name) + if !pathWithinRoot(directory, path) { + t.Fatal("UI evidence smoke artifact escaped its exact directory") + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } +} + +func writeUIEvidenceSmokeReceipt(t *testing.T, directory string, + receipt uiEvidenceSmokeReceipt, +) { + t.Helper() + raw, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + writeUIEvidenceSmokeArtifact(t, directory, "receipt.json", raw) + digest := sha256.Sum256(raw) + t.Logf("UI evidence smoke receipt sha256=%s directory=%s", + hex.EncodeToString(digest[:]), directory) +} + +func writeUIEvidenceSmokeLaunchReceipt(t *testing.T, directory string, + receipt uiEvidenceSmokeLaunchReceipt, +) { + t.Helper() + raw, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + writeUIEvidenceSmokeArtifact(t, directory, "launch.json", raw) +} diff --git a/internal/desktop/bridge.go b/internal/desktop/bridge.go index 1b6442ca..64204589 100644 --- a/internal/desktop/bridge.go +++ b/internal/desktop/bridge.go @@ -74,6 +74,7 @@ type ConnectionBootstrap struct { WorkspaceCheckpointControlEnabled bool `json:"workspace_checkpoint_control_enabled"` BatchDeliveryControlEnabled bool `json:"batch_delivery_control_enabled"` BatchDeliveryHostValidationEnabled bool `json:"batch_delivery_host_validation_enabled"` + UIEvidenceControlEnabled bool `json:"ui_evidence_control_enabled"` UserTerminalEnabled bool `json:"user_terminal_enabled"` AgentTerminalInputDefault bool `json:"agent_terminal_input_default"` WorkspaceOpenEnabled bool `json:"workspace_open_enabled"` @@ -170,6 +171,7 @@ type DesktopBridgeConfig struct { EmbeddedAnalyzerExecutionEnabled bool BatchDeliveryControlEnabled bool BatchDeliveryHostValidationEnabled bool + UIEvidenceControlEnabled bool UserTerminalEnabled bool DockerExecutionEnabled bool APIVersion string @@ -232,7 +234,8 @@ func NewDesktopBridge(config DesktopBridgeConfig) (*DesktopBridge, error) { config.SkillInstallationEnabled || config.EvidenceAttachmentEnabled || config.VerificationEvidenceEnabled || config.EmbeddedAnalyzerExecutionEnabled || - config.BatchDeliveryControlEnabled || config.ControlToken != "" || + config.BatchDeliveryControlEnabled || config.UIEvidenceControlEnabled || + config.ControlToken != "" || config.UserTerminalEnabled || config.DockerExecutionEnabled if controlEnabled && config.ControlToken == "" { return nil, apperror.New(apperror.CodeInvalidArgument, @@ -307,6 +310,11 @@ func NewDesktopBridge(config DesktopBridgeConfig) (*DesktopBridge, error) { return nil, apperror.New(apperror.CodeInvalidArgument, "desktop full CDP debug requires maximum Debug execution capability") } + if config.UIEvidenceControlEnabled && + (!commandRuntimeEnabled || !config.BrowserCDPPermissionControlEnabled) { + return nil, apperror.New(apperror.CodeInvalidArgument, + "desktop UI evidence requires command runtime and restricted browser CDP control") + } readHash := sha256.Sum256([]byte(config.ReadToken)) controlHash := sha256.Sum256([]byte(config.ControlToken)) if config.ControlToken != "" && subtle.ConstantTimeCompare(readHash[:], controlHash[:]) == 1 { @@ -373,6 +381,7 @@ func NewDesktopBridge(config DesktopBridgeConfig) (*DesktopBridge, error) { WorkspaceCheckpointControlEnabled: config.ControlToken != "", BatchDeliveryControlEnabled: config.BatchDeliveryControlEnabled, BatchDeliveryHostValidationEnabled: config.BatchDeliveryHostValidationEnabled, + UIEvidenceControlEnabled: config.UIEvidenceControlEnabled, UserTerminalEnabled: config.UserTerminalEnabled, AgentTerminalInputDefault: false, WorkspaceOpenEnabled: config.WorkspaceResolver != nil, diff --git a/internal/desktop/bridge_test.go b/internal/desktop/bridge_test.go index 299d5bbe..f64a564d 100644 --- a/internal/desktop/bridge_test.go +++ b/internal/desktop/bridge_test.go @@ -187,6 +187,7 @@ func TestDesktopBridgeBootstrapsMemoryOnlyClosedAuthority(t *testing.T) { !bootstrap.WorkspaceCheckpointControlEnabled || bootstrap.BatchDeliveryControlEnabled || bootstrap.BatchDeliveryHostValidationEnabled || + bootstrap.UIEvidenceControlEnabled || bootstrap.WorkspaceOpenEnabled || bootstrap.WorkspaceImportEnabled || bootstrap.RendererPathInputSupported { @@ -220,6 +221,7 @@ func TestDesktopBridgeBootstrapsMemoryOnlyClosedAuthority(t *testing.T) { "agent_terminal_input_default", "ui_digest", "workspace_checkpoint_control_enabled", "batch_delivery_control_enabled", "batch_delivery_host_validation_enabled", + "ui_evidence_control_enabled", "workspace_import_enabled", "workspace_open_enabled", }) diff --git a/internal/desktop/control_plane.go b/internal/desktop/control_plane.go index 6d1e9b1c..99c63698 100644 --- a/internal/desktop/control_plane.go +++ b/internal/desktop/control_plane.go @@ -12,6 +12,7 @@ import ( "cyberagent-workbench/internal/apperror" "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/browserruntime" "cyberagent-workbench/internal/credential" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/executionauth" @@ -42,6 +43,7 @@ type ControlPlane struct { userTerminal *desktopUserTerminalService debugAgentInput application.DebugTerminalAgentInputController commandRuntime *application.CommandRuntimeService + uiEvidence *application.UIEvidenceService commandRuntimeManager *runner.CommandRuntimeManager terminalManager *terminalruntime.Manager boundaryMonitor *terminalruntime.HostBoundaryMonitor @@ -88,6 +90,8 @@ type ControlPlaneConfig struct { EmbeddedAnalyzerExecutionEnabled bool BatchDeliveryControlEnabled bool BatchDeliveryHostValidationEnabled bool + UIEvidenceControlEnabled bool + BrowserRuntimeCapabilities browserruntime.ProductionRuntimeCapabilities UserTerminalEnabled bool DockerExecutionEnabled bool AppVersion string @@ -115,6 +119,20 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { return nil, apperror.New(apperror.CodeInvalidArgument, "desktop batch validation requires control, permission control, and danger-full-access") } + if config.UIEvidenceControlEnabled { + capabilities := config.BrowserRuntimeCapabilities + if !config.RunExecutionEnabled || + !config.ExecutionPermissionCapabilities.Allows( + domain.RunExecutionPermissionFullAccess) || + !config.BrowserCDPPermissionControlEnabled || + !config.BrowserCDPPermissionCapabilities.ControlEnabled || + capabilities.Validate() != nil || !capabilities.SafeWebStartEnabled || + !capabilities.DisposableProfileEnabled || + !capabilities.NetworkContainmentEnabled || !capabilities.RestrictedCDPEnabled { + return nil, apperror.New(apperror.CodeInvalidArgument, + "desktop UI evidence requires full-access command runtime and restricted Safe Web") + } + } stateStore, err := store.Open(config.DatabasePath) if err != nil { return nil, err @@ -212,6 +230,53 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { } executionControl.WithCommandRuntime(commandRuntime) } + uiEvidence, err := application.NewUIEvidenceReadService(stateStore) + if err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = commandManager.Shutdown(shutdownCtx) + cancel() + _ = stateStore.Close() + return nil, err + } + if _, err := uiEvidence.Reconcile(context.Background()); err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = commandManager.Shutdown(shutdownCtx) + cancel() + _ = stateStore.Close() + return nil, apperror.Wrap(apperror.CodeUnavailable, + "desktop UI evidence startup reconciliation failed", err) + } + if config.UIEvidenceControlEnabled { + browserController, controllerErr := browserruntime.NewPlatformBrowserProcessController() + if controllerErr != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = commandManager.Shutdown(shutdownCtx) + cancel() + _ = stateStore.Close() + return nil, controllerErr + } + browserService := application.NewBrowserRuntimeService(stateStore, + browserController, config.BrowserRuntimeCapabilities, + config.BrowserCDPPermissionCapabilities) + browserProvider, providerErr := + application.NewSafeWebUIEvidenceBrowserProvider(browserService) + if providerErr != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = commandManager.Shutdown(shutdownCtx) + cancel() + _ = stateStore.Close() + return nil, providerErr + } + uiEvidence, err = application.NewUIEvidenceService(stateStore, commandRuntime, + browserProvider, filepath.Join(home, "runtime", "ui-evidence-profiles")) + if err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = commandManager.Shutdown(shutdownCtx) + cancel() + _ = stateStore.Close() + return nil, err + } + } dockerProposalExecutor, err := application.NewDockerSandboxProposalExecutor( dockerSandbox) if err != nil { @@ -362,6 +427,7 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { WorkspaceCheckpointControlEnabled: config.ControlToken != "", BatchDeliveryControlEnabled: config.BatchDeliveryControlEnabled, BatchDeliveryHostValidationEnabled: config.BatchDeliveryHostValidationEnabled, + UIEvidenceControlEnabled: config.UIEvidenceControlEnabled, RunLifecycleController: lifecycleControl, RunExecutionController: executionControl, PublicModelStreamSource: executionControl, @@ -385,6 +451,7 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { EmbeddedAnalyzerExecutionController: embeddedAnalyzerExecution, WorkspaceCheckpointController: workspaceCheckpoints, BatchDeliveryController: batchDelivery, + UIEvidenceController: uiEvidence, DockerSandboxController: dockerSandbox, ModelRegistry: models, AppVersion: config.AppVersion, UIHandler: config.UIHandler, @@ -401,8 +468,9 @@ func OpenControlPlane(config ControlPlaneConfig) (*ControlPlane, error) { skillInstaller: skillInstaller, dockerSandbox: dockerSandbox, userTerminal: userTerminal, debugAgentInput: debugAgentInput, - commandRuntime: commandRuntime, commandRuntimeManager: commandManager, - terminalManager: terminalManager, boundaryMonitor: boundaryMonitor, + commandRuntime: commandRuntime, uiEvidence: uiEvidence, + commandRuntimeManager: commandManager, + terminalManager: terminalManager, boundaryMonitor: boundaryMonitor, wakeWorker: wakeWorker}, nil } @@ -634,6 +702,12 @@ func (c *ControlPlane) Close() error { c.debugAgentInput.Shutdown(shutdownContext) shutdownCancel() } + if c.uiEvidence != nil { + shutdownContext, shutdownCancel := context.WithTimeout( + context.Background(), 35*time.Second) + c.closeErr = errors.Join(c.closeErr, c.uiEvidence.Close(shutdownContext)) + shutdownCancel() + } if c.commandRuntimeManager != nil { shutdownContext, shutdownCancel := context.WithTimeout( context.Background(), 7*time.Second) diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 54b4aa5c..ec442274 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -25,10 +25,12 @@ import ( "cyberagent-workbench/internal/operatoraction" "cyberagent-workbench/internal/repository" "cyberagent-workbench/internal/runactivity" + "cyberagent-workbench/internal/runner" "cyberagent-workbench/internal/sandbox" "cyberagent-workbench/internal/session" "cyberagent-workbench/internal/skills" "cyberagent-workbench/internal/toolgateway" + "cyberagent-workbench/internal/uievidence" "cyberagent-workbench/internal/verification" "cyberagent-workbench/internal/workspace" "cyberagent-workbench/internal/workspacecheckpoint" @@ -143,6 +145,7 @@ type openAPIOperationSpec struct { Paginated bool NotFound bool RawDocument bool + RawArtifact bool Streaming bool Parameters []openAPIParameter RequestType reflect.Type @@ -212,6 +215,7 @@ func GenerateOpenAPI() ([]byte, error) { {Name: "Workspaces", Description: "Registered Workspace identities without local root paths."}, {Name: "Memory", Description: "Structured WorkItems and Notes."}, {Name: "Artifacts", Description: "Content-free Artifact descriptors."}, + {Name: "UI Evidence", Description: "Source-bound real-browser manifests, fail-closed receipts, and untrusted captured artifacts."}, {Name: "Sandbox", Description: "Docker Sandbox readiness, admission, bounded execution, cancellation, and status."}, {Name: "Control", Description: "Separately authorized, audit-first control operations."}, }, @@ -255,6 +259,7 @@ func openAPIOperationSpecs() []openAPIOperationSpec { workItemID := pathIdentityParameter("work_item_id", "WorkItem identity") noteID := pathIdentityParameter("note_id", "Note identity") artifactID := pathIdentityParameter("artifact_id", "Artifact identity") + uiEvidenceAttemptID := pathIdentityParameter("attempt_id", "UI evidence attempt identity") memoryID := pathIdentityParameter("memory_id", "Long-term memory identity") continuityNodeID := pathIdentityParameter("node_id", "Continuity checkpoint identity") reportID := pathIdentityParameter("report_id", "Finding Report identity") @@ -385,6 +390,35 @@ func openAPIOperationSpecs() []openAPIOperationSpec { DataType: reflect.TypeOf(workspaceCheckpointForkResultView{}), RequestType: reflect.TypeOf(workspaceCheckpointForkView{}), Control: true, NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusCreated}, + {Path: "/api/v1/runs/{run_id}/ui-evidence", OperationID: "listRunUIEvidence", + Summary: "List source-bound UI evidence attempts", Tag: "UI Evidence", + Description: "Returns immutable manifests and fail-closed outcomes. not_run is unknown and never a passing result.", + DataType: reflect.TypeOf(uievidence.Attempt{}), Collection: true, NotFound: true, + Parameters: []openAPIParameter{runID, + stringQueryParameter("status", "Optional exact attempt status", []string{ + "not_run", "running", "passed", "failed", "cancelled", "timed_out", "interrupted"}), + {Name: "limit", In: "query", Description: "Maximum attempts", Schema: map[string]any{ + "type": "integer", "minimum": 1, "maximum": 500, "default": 100}}}}, + {Path: "/api/v1/runs/{run_id}/ui-evidence", Method: http.MethodPost, + OperationID: "startRunUIEvidence", Summary: "Start real-browser UI evidence", + Tag: "UI Evidence", Description: "Persists not_run before asynchronously executing the exact source-bound build/start recipe in a Run-owned process tree, a temporary browser Profile, and a loopback-only reviewed Safe Web runtime.", + DataType: reflect.TypeOf(uievidence.Attempt{}), RequestType: reflect.TypeOf(uiEvidenceStartView{}), + Control: true, NotFound: true, Parameters: []openAPIParameter{runID}, SuccessStatus: http.StatusAccepted}, + {Path: "/api/v1/ui-evidence/{attempt_id}", OperationID: "getUIEvidence", + Summary: "Inspect one UI evidence bundle", Tag: "UI Evidence", + Description: "Returns the exact manifest, step receipts, and artifact metadata without binary content.", + DataType: reflect.TypeOf(application.UIEvidenceBundle{}), NotFound: true, + Parameters: []openAPIParameter{uiEvidenceAttemptID}}, + {Path: "/api/v1/ui-evidence/{attempt_id}/cancel", Method: http.MethodPost, + OperationID: "cancelUIEvidence", Summary: "Cancel one UI evidence attempt", + Tag: "UI Evidence", Description: "Cancels the service-owned context and waits for bounded process-tree, Profile, network, and port cleanup.", + DataType: reflect.TypeOf(uievidence.Attempt{}), RequestType: reflect.TypeOf(uiEvidenceCancelView{}), + Control: true, NotFound: true, Parameters: []openAPIParameter{uiEvidenceAttemptID}, SuccessStatus: http.StatusOK}, + {Path: "/api/v1/ui-evidence/{attempt_id}/artifacts/{artifact_id}", + OperationID: "downloadUIEvidenceArtifact", Summary: "Download one verified UI evidence artifact", + Tag: "UI Evidence", Description: "Returns exact hash-verified untrusted bytes with MIME, ETag, source digest, and no-store headers.", + RawArtifact: true, NotFound: true, + Parameters: []openAPIParameter{uiEvidenceAttemptID, artifactID}}, {Path: "/api/v1/continuity-nodes/{node_id}/fork", Method: http.MethodPost, OperationID: "forkContinuityNode", Summary: "Fork a new Run from a checkpoint", Tag: "Control", Description: "Creates a new Run and Session with the exact bounded context snapshot while resetting approvals, capabilities, credentials, processes, leases, network authorization, and execution profiles.", @@ -1340,6 +1374,12 @@ func buildOpenAPIOperation(spec openAPIOperationSpec, registry *openAPISchemaReg responses[successStatus] = openAPIResponse{Description: "OpenAPI 3.1 document", Content: map[string]openAPIMediaType{ openAPIContentType: {Schema: map[string]any{"type": "object", "additionalProperties": true}}, }} + } else if spec.RawArtifact { + responses[successStatus] = openAPIResponse{Description: "Hash-verified untrusted evidence bytes", Content: map[string]openAPIMediaType{ + "application/octet-stream": {Schema: map[string]any{"type": "string", "format": "binary"}}, + "image/png": {Schema: map[string]any{"type": "string", "format": "binary"}}, + "application/json": {Schema: map[string]any{"type": "string", "format": "binary"}}, + }} } else { if spec.DataType == nil { return openAPIOperation{}, fmt.Errorf("OpenAPI path %q has no response DTO", spec.Path) @@ -1524,7 +1564,11 @@ func (r *openAPISchemaRegistry) ref(valueType reflect.Type) map[string]any { if valueType.Kind() != reflect.Struct { return r.schema(valueType) } - return r.refNamed(valueType.Name(), valueType) + name := valueType.Name() + if strings.HasSuffix(valueType.PkgPath(), "/uievidence") { + name = "UIEvidence" + name + } + return r.refNamed(name, valueType) } func (r *openAPISchemaRegistry) refNamed(name string, valueType reflect.Type) map[string]any { @@ -2047,6 +2091,28 @@ var openAPIFieldEnums = map[string][]string{ "NoteView.status": noteStatusesOpenAPI(), "ArtifactView.stream": artifactStreams(), "ArtifactView.encoding": {artifact.EncodingUTF8}, + "Manifest.protocol_version": {uievidence.ProtocolVersion}, + "SourceBinding.repository_kind": {"git", "non_git"}, + "CommandRecipe.protocol_version": {runner.CommandRuntimeProtocolVersion}, + "CommandRecipe.profile": {string(runner.CommandRuntimePowerShell), string(runner.CommandRuntimeBash), string(runner.CommandRuntimeProcess)}, + "CommandRecipe.network": {string(runner.CommandRuntimeNetworkDisabled)}, + "CommandRecipe.credentials": {string(runner.CommandRuntimeCredentialsNone)}, + "Step.kind": uiEvidenceStepKinds(), + "Attempt.protocol_version": {uievidence.AttemptProtocolVersion}, + "Attempt.status": {string(uievidence.StatusNotRun), string(uievidence.StatusRunning), string(uievidence.StatusPassed), string(uievidence.StatusFailed), string(uievidence.StatusCancelled), string(uievidence.StatusTimedOut), string(uievidence.StatusInterrupted)}, + "Attempt.failure_stage": uiEvidenceFailureStages(), + "StepReceipt.protocol_version": {uievidence.StepProtocolVersion}, + "StepReceipt.kind": uiEvidenceStepKinds(), + "StepReceipt.status": {string(uievidence.StatusPassed), string(uievidence.StatusFailed), string(uievidence.StatusCancelled), string(uievidence.StatusTimedOut)}, + "StepReceipt.failure_stage": uiEvidenceFailureStages(), + "ArtifactMetadata.protocol_version": {uievidence.ArtifactProtocolVersion}, + "ArtifactMetadata.kind": uiEvidenceArtifactKinds(), + "ArtifactMetadata.retention_policy": {string(uievidence.ArtifactRetentionRunHistory)}, + "BrowserIdentity.driver_protocol": {uievidence.DriverProtocolVersion}, + "BrowserIdentity.product": {string(browserruntime.BrowserProductChrome), string(browserruntime.BrowserProductEdge)}, + "UIEvidenceBrowserSelection.product": {string(browserruntime.BrowserProductChrome), string(browserruntime.BrowserProductEdge)}, + "UIEvidenceBrowserSelection.channel": {string(browserruntime.BrowserChannelStable), string(browserruntime.BrowserChannelBeta), string(browserruntime.BrowserChannelDev), string(browserruntime.BrowserChannelCanary)}, + "Environment.theme": {string(uievidence.ThemeLight), string(uievidence.ThemeDark)}, "SupervisorToolCallView.status": {"pending", "completed", "denied", "failed"}, "RunEventStreamView.version": {RunEventStreamVersion}, "RunEventPollView.version": {RunEventPollVersion}, @@ -2573,6 +2639,27 @@ func artifactStreams() []string { return []string{string(artifact.StreamStdout), string(artifact.StreamStderr)} } +func uiEvidenceFailureStages() []string { + return []string{string(uievidence.FailureNone), string(uievidence.FailureBuild), + string(uievidence.FailureLaunch), string(uievidence.FailureReadiness), + string(uievidence.FailureNavigation), string(uievidence.FailureSelector), + string(uievidence.FailureAssertion), string(uievidence.FailureConsole), + string(uievidence.FailureNetwork), string(uievidence.FailureCapture), + string(uievidence.FailureCleanup)} +} + +func uiEvidenceStepKinds() []string { + return []string{string(uievidence.StepNavigate), string(uievidence.StepClick), + string(uievidence.StepType), string(uievidence.StepAssertPresent), + string(uievidence.StepAssertAbsent), string(uievidence.StepCapture)} +} + +func uiEvidenceArtifactKinds() []string { + return []string{string(uievidence.ArtifactScreenshot), string(uievidence.ArtifactDOM), + string(uievidence.ArtifactAccessibility), string(uievidence.ArtifactConsole), + string(uievidence.ArtifactNetwork), string(uievidence.ArtifactPerformance)} +} + func sortedOpenAPIPaths() []string { seen := map[string]struct{}{} paths := make([]string, 0, len(openAPIOperationSpecs())) diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index 72a2396c..453708c4 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -31,6 +31,7 @@ import ( "cyberagent-workbench/internal/runner" "cyberagent-workbench/internal/skills" "cyberagent-workbench/internal/toolgateway" + "cyberagent-workbench/internal/uievidence" "cyberagent-workbench/internal/verification" ) @@ -515,6 +516,28 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { application.NewEmbeddedAnalyzerExecutionService(fixture.store) fixture.api.workspaceCheckpointControlEnabled = true fixture.api.workspaceCheckpointController = &workspaceCheckpointControllerStub{} + uiAttemptID := "ui-attempt-openapi-0001" + uiArtifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: fixture.artifactID, AttemptID: uiAttemptID, RunID: fixture.run.ID, + StepID: "navigate", Kind: uievidence.ArtifactDOM, MIME: "application/octet-stream", + Viewport: uievidence.Viewport{Width: 1440, Height: 900, DPR: 1}, + SourceCommit: "non-git", RetentionPolicy: uievidence.ArtifactRetentionRunHistory, + Untrusted: true, CreatedAt: time.Now().UTC(), + }, []byte("evidence")) + if err != nil { + t.Fatal(err) + } + fixture.api.uiEvidenceControlEnabled = true + fixture.api.uiEvidenceController = &uiEvidenceControllerStub{ + attempt: uievidence.Attempt{ProtocolVersion: uievidence.AttemptProtocolVersion, + Manifest: uievidence.Manifest{AttemptID: uiAttemptID, RunID: fixture.run.ID}, + Status: uievidence.StatusNotRun, FailureStage: uievidence.FailureNone}, + bundle: application.UIEvidenceBundle{Steps: []uievidence.StepReceipt{}, + Artifacts: []uievidence.ArtifactMetadata{}}, + artifact: uiArtifact, + } + uiStub := fixture.api.uiEvidenceController.(*uiEvidenceControllerStub) + uiStub.bundle.Attempt = uiStub.attempt fixture.api.skillInstallationController = application.NewSkillPackageRegistryService( fixture.store, objects, builtins) steering, err := fixture.store.EnqueueOperatorSteering(t.Context(), @@ -617,6 +640,7 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { "{approval_id}": approvalRecord.ID, "{proposal_id}": "controlled-command-proposal-openapi", "{batch_delivery_id}": "batch-openapi-missing-0001", + "{attempt_id}": uiAttemptID, "{edit_id}": fileEditRecord.ID, "{object_id}": strings.Repeat("a", 40), "{plan_id}": verificationPlan.Plan.ID, @@ -737,6 +761,10 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { } else if spec.OperationID == "createWorkspaceCheckpoint" { body = `{"operation_key":"openapi-workspace-checkpoint-create-0001",` + `"title":"OpenAPI Workspace checkpoint"}` + } else if spec.OperationID == "startRunUIEvidence" { + body = `{}` + } else if spec.OperationID == "cancelUIEvidence" { + body = `{"confirm":true}` } else if spec.OperationID == "previewWorkspaceRewind" { body = `{"target_checkpoint_id":"checkpoint-target",` + `"expected_current_checkpoint_id":"checkpoint-current"}` @@ -981,6 +1009,12 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { !bytes.Contains(response.Body.Bytes(), []byte(`"openapi": "3.1.0"`)) { t.Fatalf("raw OpenAPI response is invalid: content-type=%q body=%s", contentType, response.Body.String()) } + } else if spec.RawArtifact { + if !strings.HasPrefix(contentType, "application/octet-stream") || + response.Body.String() != "evidence" { + t.Fatalf("raw UI evidence artifact is invalid: content-type=%q body=%q", + contentType, response.Body.String()) + } } else if !strings.HasPrefix(contentType, "application/json") || !json.Valid(response.Body.Bytes()) { t.Fatalf("API envelope has wrong content type %q", contentType) } diff --git a/internal/httpapi/runtime_capabilities.go b/internal/httpapi/runtime_capabilities.go index eaab70e2..ebab1d00 100644 --- a/internal/httpapi/runtime_capabilities.go +++ b/internal/httpapi/runtime_capabilities.go @@ -60,6 +60,7 @@ type RuntimeCapabilitiesView struct { WorkspaceCheckpointControlEnabled bool `json:"workspace_checkpoint_control_enabled"` BatchDeliveryControlEnabled bool `json:"batch_delivery_control_enabled"` BatchDeliveryHostValidationEnabled bool `json:"batch_delivery_host_validation_enabled"` + UIEvidenceControlEnabled bool `json:"ui_evidence_control_enabled"` ProcessExecutionEnabled bool `json:"process_execution_enabled"` ShellExecutionEnabled bool `json:"shell_execution_enabled"` DockerExecutionEnabled bool `json:"docker_execution_enabled"` @@ -133,6 +134,7 @@ func (a *API) runtimeCapabilities(request *http.Request) (any, *Page, error) { WorkspaceCheckpointControlEnabled: a.workspaceCheckpointControlEnabled, BatchDeliveryControlEnabled: a.batchDeliveryControlEnabled, BatchDeliveryHostValidationEnabled: a.batchDeliveryHostValidationEnabled, + UIEvidenceControlEnabled: a.uiEvidenceControlEnabled, ProcessExecutionEnabled: commandRuntimeEnabled, ShellExecutionEnabled: commandRuntimeEnabled, DockerExecutionEnabled: a.dockerExecutionEnabled, AgentCodeToolsEnabled: true, diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 77765d3c..1af54161 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -253,6 +253,7 @@ type Config struct { WorkspaceCheckpointControlEnabled bool BatchDeliveryControlEnabled bool BatchDeliveryHostValidationEnabled bool + UIEvidenceControlEnabled bool ExecutionPermissionCapabilities domain.ExecutionPermissionRuntimeCapabilities BrowserCDPPermissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities RunLifecycleController RunLifecycleController @@ -277,6 +278,7 @@ type Config struct { EmbeddedAnalyzerExecutionController EmbeddedAnalyzerExecutionController WorkspaceCheckpointController WorkspaceCheckpointController BatchDeliveryController BatchDeliveryController + UIEvidenceController UIEvidenceController DockerSandboxController DockerSandboxController ModelRegistry *modelregistry.Registry AppVersion string @@ -315,6 +317,7 @@ type API struct { workspaceCheckpointControlEnabled bool batchDeliveryControlEnabled bool batchDeliveryHostValidationEnabled bool + uiEvidenceControlEnabled bool dockerSandboxControlEnabled bool dockerExecutionEnabled bool executionPermissionCapabilities domain.ExecutionPermissionRuntimeCapabilities @@ -341,6 +344,7 @@ type API struct { embeddedAnalyzerExecutionController EmbeddedAnalyzerExecutionController workspaceCheckpointController WorkspaceCheckpointController batchDeliveryController BatchDeliveryController + uiEvidenceController UIEvidenceController dockerSandboxController DockerSandboxController modelRegistry *modelregistry.Registry appVersion string @@ -387,7 +391,8 @@ func New(store Store, config Config) (*API, error) { config.RunWakeWorkerEnabled || config.SkillInstallationEnabled || config.EvidenceAttachmentEnabled || config.VerificationEvidenceEnabled || config.EmbeddedAnalyzerExecutionEnabled || - config.WorkspaceCheckpointControlEnabled || config.BatchDeliveryControlEnabled) && + config.WorkspaceCheckpointControlEnabled || config.BatchDeliveryControlEnabled || + config.UIEvidenceControlEnabled) && !controlTokenPresent { return nil, apperror.New(apperror.CodeInvalidArgument, "HTTP API control capabilities require a control token") @@ -480,6 +485,10 @@ func New(store Store, config Config) (*API, error) { return nil, apperror.New(apperror.CodeInvalidArgument, "HTTP API batch delivery host validation requires batch control, permission control, operator approval, and danger-full-access") } + if config.UIEvidenceControlEnabled && config.UIEvidenceController == nil { + return nil, apperror.New(apperror.CodeInvalidArgument, + "HTTP API UI evidence controller is required when enabled") + } dockerExecutionEnabled := false if config.DockerSandboxController != nil { capabilities, epochFingerprint, err := @@ -574,6 +583,7 @@ func New(store Store, config Config) (*API, error) { batchDeliveryControlEnabled: controlTokenPresent && config.BatchDeliveryControlEnabled, batchDeliveryHostValidationEnabled: controlTokenPresent && config.BatchDeliveryHostValidationEnabled, + uiEvidenceControlEnabled: controlTokenPresent && config.UIEvidenceControlEnabled, dockerSandboxControlEnabled: config.DockerSandboxController != nil && controlTokenPresent && config.ExecutionPermissionControlEnabled, dockerExecutionEnabled: dockerExecutionEnabled, @@ -601,6 +611,7 @@ func New(store Store, config Config) (*API, error) { embeddedAnalyzerExecutionController: config.EmbeddedAnalyzerExecutionController, workspaceCheckpointController: config.WorkspaceCheckpointController, batchDeliveryController: config.BatchDeliveryController, + uiEvidenceController: config.UIEvidenceController, dockerSandboxController: config.DockerSandboxController, modelRegistry: modelRegistry, openAPI: document, eventStream: eventStream, @@ -720,6 +731,10 @@ func (a *API) ServeHTTP(writer http.ResponseWriter, request *http.Request) { a.serveDockerSandbox(tracked, request, requestID) return } + if route, matched := matchUIEvidencePath(request.URL.Path); matched { + a.serveUIEvidence(tracked, request, requestID, route) + return + } if runID, action, matched := matchWorkspaceCheckpointPath(request.URL.Path); matched { a.serveWorkspaceCheckpoint(tracked, request, requestID, runID, action) return diff --git a/internal/httpapi/ui_evidence.go b/internal/httpapi/ui_evidence.go new file mode 100644 index 00000000..2ade6ad3 --- /dev/null +++ b/internal/httpapi/ui_evidence.go @@ -0,0 +1,298 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/uievidence" +) + +const MaxUIEvidenceRequestBodyBytes = 512 * 1024 + +type UIEvidenceController interface { + Start(context.Context, application.UIEvidenceStartRequest) (uievidence.Attempt, error) + Cancel(context.Context, string) (uievidence.Attempt, error) + Get(context.Context, string) (application.UIEvidenceBundle, error) + List(context.Context, uievidence.ListFilter) ([]uievidence.Attempt, error) + Artifact(context.Context, string, string) (uievidence.Artifact, error) +} + +type uiEvidenceStartView struct { + OperationKey string `json:"operation_key"` + Build *runner.CommandRuntimeSpec `json:"build,omitempty"` + Start runner.CommandRuntimeSpec `json:"start"` + Readiness uievidence.Readiness `json:"readiness"` + URL string `json:"url"` + Route string `json:"route"` + Browser application.UIEvidenceBrowserSelection `json:"browser"` + Environment uievidence.Environment `json:"environment"` + Fixture uievidence.Fixture `json:"fixture"` + Steps []application.UIEvidenceRuntimeStep `json:"steps"` + Capture uievidence.CapturePolicy `json:"capture"` + FailurePolicy uievidence.FailurePolicy `json:"failure_policy"` +} + +type uiEvidenceCancelView struct { + Confirm *bool `json:"confirm"` +} + +type uiEvidenceRoute struct { + runID string + attemptID string + artifactID string + action string +} + +func matchUIEvidencePath(value string) (uiEvidenceRoute, bool) { + segments := strings.Split(strings.TrimPrefix(value, "/api/v1/"), "/") + if len(segments) == 3 && segments[0] == "runs" && segments[1] != "" && + segments[2] == "ui-evidence" { + return uiEvidenceRoute{runID: segments[1], action: "collection"}, true + } + if len(segments) == 2 && segments[0] == "ui-evidence" && segments[1] != "" { + return uiEvidenceRoute{attemptID: segments[1], action: "attempt"}, true + } + if len(segments) == 3 && segments[0] == "ui-evidence" && segments[1] != "" && + segments[2] == "cancel" { + return uiEvidenceRoute{attemptID: segments[1], action: "cancel"}, true + } + if len(segments) == 4 && segments[0] == "ui-evidence" && segments[1] != "" && + segments[2] == "artifacts" && segments[3] != "" { + return uiEvidenceRoute{attemptID: segments[1], artifactID: segments[3], + action: "artifact"}, true + } + return uiEvidenceRoute{}, false +} + +func (a *API) serveUIEvidence(writer http.ResponseWriter, request *http.Request, + requestID string, route uiEvidenceRoute, +) { + if a.uiEvidenceController == nil { + a.writeError(writer, requestID, + apperror.New(apperror.CodeNotFound, "HTTP API endpoint was not found"), + http.StatusNotFound) + return + } + if route.runID != "" { + if err := validatePathIdentity(route.runID); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + } + if route.attemptID != "" { + if err := validatePathIdentity(route.attemptID); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + } + if route.artifactID != "" { + if err := validatePathIdentity(route.artifactID); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + } + if (route.action == "collection" && request.Method == http.MethodPost) || + route.action == "cancel" { + a.serveUIEvidenceMutation(writer, request, requestID, route) + return + } + a.serveUIEvidenceRead(writer, request, requestID, route) +} + +func (a *API) serveUIEvidenceRead(writer http.ResponseWriter, request *http.Request, + requestID string, route uiEvidenceRoute, +) { + if !a.authorized(request, a.tokenHash) { + writer.Header().Set("WWW-Authenticate", `Bearer realm="CyberAgent API"`) + a.writeError(writer, requestID, apperror.New(apperror.CodePolicyDenied, + "valid bearer authorization is required"), http.StatusUnauthorized) + return + } + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "UI evidence read only supports GET"), http.StatusMethodNotAllowed) + return + } + if request.ContentLength != 0 || len(request.TransferEncoding) != 0 { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "read-only HTTP API requests cannot contain a body"), 0) + return + } + switch route.action { + case "collection": + a.serveUIEvidenceList(writer, request, requestID, route.runID) + case "attempt": + if err := rejectQuery(request.URL.Query()); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + value, err := a.uiEvidenceController.Get(request.Context(), route.attemptID) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) + case "artifact": + if err := rejectQuery(request.URL.Query()); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + value, err := a.uiEvidenceController.Artifact(request.Context(), + route.attemptID, route.artifactID) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := value.Validate(); err != nil || + value.Metadata.AttemptID != route.attemptID || value.Metadata.ID != route.artifactID { + a.writeError(writer, requestID, apperror.Wrap(apperror.CodeUnavailable, + "UI evidence artifact failed integrity verification", err), 0) + return + } + writeUIEvidenceArtifact(writer, value) + default: + a.writeError(writer, requestID, + apperror.New(apperror.CodeNotFound, "HTTP API endpoint was not found"), + http.StatusNotFound) + } +} + +func (a *API) serveUIEvidenceList(writer http.ResponseWriter, request *http.Request, + requestID, runID string, +) { + if err := validateSingleQueryValues(request.URL.Query(), "status", "limit"); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + filter := uievidence.ListFilter{RunID: runID, Limit: 100} + if raw := request.URL.Query().Get("status"); raw != "" { + filter.Status = uievidence.Status(raw) + } + if raw := request.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "UI evidence limit must be an integer"), 0) + return + } + filter.Limit = parsed + } + if err := filter.Validate(); err != nil { + a.writeError(writer, requestID, apperror.Wrap(apperror.CodeInvalidArgument, + "UI evidence list filter is invalid", err), 0) + return + } + values, err := a.uiEvidenceController.List(request.Context(), filter) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if values == nil { + values = []uievidence.Attempt{} + } + a.writeSuccess(writer, requestID, values, nil) +} + +func (a *API) serveUIEvidenceMutation(writer http.ResponseWriter, request *http.Request, + requestID string, route uiEvidenceRoute, +) { + if !a.uiEvidenceControlEnabled { + a.writeError(writer, requestID, + apperror.New(apperror.CodeNotFound, "HTTP API endpoint was not found"), + http.StatusNotFound) + return + } + if !a.authorized(request, a.controlTokenHash) { + writer.Header().Set("WWW-Authenticate", `Bearer realm="CyberAgent Control API"`) + a.writeError(writer, requestID, apperror.New(apperror.CodePolicyDenied, + "valid control bearer authorization is required"), http.StatusUnauthorized) + return + } + if request.Method != http.MethodPost { + writer.Header().Set("Allow", http.MethodPost) + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "UI evidence mutation only supports POST"), http.StatusMethodNotAllowed) + return + } + if err := rejectQuery(request.URL.Query()); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := validateJSONContentType(request.Header); err != nil { + a.writeError(writer, requestID, err, http.StatusUnsupportedMediaType) + return + } + body, err := readBoundedRequestBody(request, MaxUIEvidenceRequestBodyBytes) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if err := rejectDuplicateJSONObjectFields(body, "UI evidence"); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + decode := func(destination any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return apperror.Wrap(apperror.CodeInvalidArgument, + "UI evidence body must be one JSON object", err) + } + return ensureJSONEOF(decoder) + } + if route.action == "collection" { + var view uiEvidenceStartView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + value, err := a.uiEvidenceController.Start(request.Context(), + application.UIEvidenceStartRequest{RunID: route.runID, + OperationKey: view.OperationKey, Build: view.Build, Start: view.Start, + Readiness: view.Readiness, URL: view.URL, Route: view.Route, + Browser: view.Browser, Environment: view.Environment, Fixture: view.Fixture, + Steps: view.Steps, Capture: view.Capture, FailurePolicy: view.FailurePolicy}) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccessStatus(writer, requestID, value, nil, http.StatusAccepted) + return + } + var view uiEvidenceCancelView + if err := decode(&view); err != nil { + a.writeError(writer, requestID, err, 0) + return + } + if view.Confirm == nil || !*view.Confirm { + a.writeError(writer, requestID, apperror.New(apperror.CodeInvalidArgument, + "UI evidence cancellation requires confirm=true"), 0) + return + } + value, err := a.uiEvidenceController.Cancel(request.Context(), route.attemptID) + if err != nil { + a.writeError(writer, requestID, err, 0) + return + } + a.writeSuccess(writer, requestID, value, nil) +} + +func writeUIEvidenceArtifact(writer http.ResponseWriter, artifact uievidence.Artifact) { + metadata := artifact.Metadata + writer.Header().Set("Content-Type", metadata.MIME) + writer.Header().Set("Content-Length", strconv.FormatInt(metadata.Bytes, 10)) + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("ETag", `"`+metadata.SHA256+`"`) + writer.Header().Set("X-CyberAgent-Content-SHA256", metadata.SHA256) + writer.Header().Set("X-CyberAgent-Evidence-Untrusted", "true") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(artifact.Content) +} diff --git a/internal/httpapi/ui_evidence_test.go b/internal/httpapi/ui_evidence_test.go new file mode 100644 index 00000000..da56a97c --- /dev/null +++ b/internal/httpapi/ui_evidence_test.go @@ -0,0 +1,183 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/uievidence" +) + +type uiEvidenceControllerStub struct { + attempt uievidence.Attempt + bundle application.UIEvidenceBundle + artifact uievidence.Artifact + startRequest application.UIEvidenceStartRequest + listFilter uievidence.ListFilter + cancelledID string +} + +func (stub *uiEvidenceControllerStub) Start(_ context.Context, + request application.UIEvidenceStartRequest, +) (uievidence.Attempt, error) { + stub.startRequest = request + return stub.attempt, nil +} + +func (stub *uiEvidenceControllerStub) Cancel(_ context.Context, attemptID string) ( + uievidence.Attempt, error, +) { + stub.cancelledID = attemptID + return stub.attempt, nil +} + +func (stub *uiEvidenceControllerStub) Get(context.Context, string) ( + application.UIEvidenceBundle, error, +) { + return stub.bundle, nil +} + +func (stub *uiEvidenceControllerStub) List(_ context.Context, + filter uievidence.ListFilter, +) ([]uievidence.Attempt, error) { + stub.listFilter = filter + return []uievidence.Attempt{stub.attempt}, nil +} + +func (stub *uiEvidenceControllerStub) Artifact(context.Context, string, string) ( + uievidence.Artifact, error, +) { + return stub.artifact, nil +} + +func TestUIEvidenceHTTPPreservesNotRunAndRequiresSeparateControlAuthority(t *testing.T) { + fixture := newAPIFixture(t) + stub := &uiEvidenceControllerStub{attempt: uievidence.Attempt{ + ProtocolVersion: uievidence.AttemptProtocolVersion, + Manifest: uievidence.Manifest{AttemptID: "ui-attempt-http-0001", + RunID: fixture.run.ID}, + Status: uievidence.StatusNotRun, FailureStage: uievidence.FailureNone, + }} + stub.bundle = application.UIEvidenceBundle{Attempt: stub.attempt, + Steps: []uievidence.StepReceipt{}, Artifacts: []uievidence.ArtifactMetadata{}} + api, err := New(fixture.store, Config{AccessToken: testAccessToken, + ControlToken: testControlToken, UIEvidenceControlEnabled: true, + UIEvidenceController: stub, AppVersion: "ui-evidence-http-test"}) + if err != nil { + t.Fatal(err) + } + listPath := "/api/v1/runs/" + fixture.run.ID + "/ui-evidence?status=not_run&limit=7" + unauthorized := performRequest(t, api, http.MethodGet, listPath, "", + "127.0.0.1:8765", "127.0.0.1:45000", nil) + assertAPIError(t, unauthorized, http.StatusUnauthorized, "POLICY_DENIED") + + response := performRequest(t, api, http.MethodGet, listPath, testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", nil) + var attempts []uievidence.Attempt + decodeData(t, response, &attempts) + if len(attempts) != 1 || attempts[0].Status != uievidence.StatusNotRun || + attempts[0].Status.Passed() || stub.listFilter.RunID != fixture.run.ID || + stub.listFilter.Status != uievidence.StatusNotRun || stub.listFilter.Limit != 7 { + t.Fatalf("not-run UI evidence projection changed meaning: attempts=%#v filter=%#v", + attempts, stub.listFilter) + } + + startPath := "/api/v1/runs/" + fixture.run.ID + "/ui-evidence" + readToken := uiEvidenceJSONRequest(t, api, startPath, testAccessToken, `{}`) + assertAPIError(t, readToken, http.StatusUnauthorized, "POLICY_DENIED") + unknown := uiEvidenceJSONRequest(t, api, startPath, testControlToken, + `{"operation_key":"http-ui-evidence-0001","unexpected":true}`) + assertAPIError(t, unknown, http.StatusBadRequest, "INVALID_ARGUMENT") +} + +func TestUIEvidenceHTTPDownloadsExactUntrustedArtifact(t *testing.T) { + fixture := newAPIFixture(t) + content := []byte("synthetic evidence") + artifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: "ui-artifact-http-0001", AttemptID: "ui-attempt-http-0001", + RunID: fixture.run.ID, StepID: "capture-final", Kind: uievidence.ArtifactDOM, + MIME: "application/json", Viewport: uievidence.Viewport{Width: 1280, Height: 720, DPR: 1}, + SourceCommit: strings.Repeat("1", 40), + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, + Redacted: true, Untrusted: true, + CreatedAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC), + }, content) + if err != nil { + t.Fatal(err) + } + stub := &uiEvidenceControllerStub{artifact: artifact} + api, err := New(fixture.store, Config{AccessToken: testAccessToken, + UIEvidenceController: stub, AppVersion: "ui-evidence-artifact-test"}) + if err != nil { + t.Fatal(err) + } + requestPath := "/api/v1/ui-evidence/ui-attempt-http-0001/artifacts/ui-artifact-http-0001" + response := performRequest(t, api, http.MethodGet, requestPath, testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", nil) + if response.Code != http.StatusOK || response.Body.String() != string(content) || + response.Header().Get("Content-Type") != "application/json" || + response.Header().Get("ETag") != `"`+artifact.Metadata.SHA256+`"` || + response.Header().Get("X-CyberAgent-Evidence-Untrusted") != "true" || + response.Header().Get("X-CyberAgent-Content-SHA256") != artifact.Metadata.SHA256 { + t.Fatalf("artifact response lost integrity metadata: status=%d headers=%#v body=%q", + response.Code, response.Header(), response.Body.String()) + } +} + +func TestUIEvidenceHTTPRejectsCorruptArtifactContent(t *testing.T) { + fixture := newAPIFixture(t) + artifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: "ui-artifact-http-corrupt-0001", AttemptID: "ui-attempt-http-corrupt-0001", + RunID: fixture.run.ID, StepID: "capture-final", Kind: uievidence.ArtifactDOM, + MIME: "application/json", Viewport: uievidence.Viewport{Width: 1280, Height: 720, DPR: 1}, + SourceCommit: strings.Repeat("1", 40), + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, + Redacted: true, Untrusted: true, + CreatedAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC), + }, []byte("sealed evidence")) + if err != nil { + t.Fatal(err) + } + artifact.Content[0] ^= 0xff + stub := &uiEvidenceControllerStub{artifact: artifact} + api, err := New(fixture.store, Config{AccessToken: testAccessToken, + UIEvidenceController: stub, AppVersion: "ui-evidence-artifact-corrupt-test"}) + if err != nil { + t.Fatal(err) + } + requestPath := "/api/v1/ui-evidence/ui-attempt-http-corrupt-0001/artifacts/" + + "ui-artifact-http-corrupt-0001" + response := performRequest(t, api, http.MethodGet, requestPath, testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", nil) + assertAPIError(t, response, http.StatusServiceUnavailable, "UNAVAILABLE") +} + +func TestUIEvidenceHTTPRejectsInvalidEnablement(t *testing.T) { + fixture := newAPIFixture(t) + if _, err := New(fixture.store, Config{AccessToken: testAccessToken, + ControlToken: testControlToken, UIEvidenceControlEnabled: true}); err == nil { + t.Fatal("enabled UI evidence without its controller was accepted") + } + if _, err := New(fixture.store, Config{AccessToken: testAccessToken, + UIEvidenceControlEnabled: true, + UIEvidenceController: &uiEvidenceControllerStub{}}); err == nil { + t.Fatal("enabled UI evidence without a control token was accepted") + } +} + +func uiEvidenceJSONRequest(t *testing.T, api *API, requestPath, token, body string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1"+requestPath, + strings.NewReader(body)) + request.Host = "127.0.0.1:8765" + request.RemoteAddr = "127.0.0.1:45000" + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + return response +} diff --git a/internal/skills/archives/1.0.0/run-verify/SKILL.md b/internal/skills/archives/1.0.0/run-verify/SKILL.md new file mode 100644 index 00000000..986fa4c9 --- /dev/null +++ b/internal/skills/archives/1.0.0/run-verify/SKILL.md @@ -0,0 +1,13 @@ +# Run verification workflow + +Verify behavior from a fixed source state. Record the commit or exact dirty-worktree fingerprint, working directory, launch command or structured recipe, relevant configuration, dependency state, ports, network scope, and cleanup method. Use only a Go-offered controlled launch or sandbox operation; never convert this guidance into arbitrary host execution. + +Launch the real program when authority and prerequisites are present. Exercise the requested UI, CLI, or tool path with bounded inputs, capture exit and readiness facts, console output, application errors, and the provenance of every artifact. A mocked render, build success, or source inspection is not runtime verification. Stop or clean up only through the owning lifecycle control. + +On the Cyber Surface, run only inside an admitted local sandbox with the Run's existing network policy. Do not contact public or unapproved targets, reuse credentials, or claim production equivalence. + +## Extension: ui-evidence + +For UI work, bind evidence to the fixed commit/worktree fingerprint, launch recipe, viewport and scale, route, page state, theme, locale, and deterministic data fixture. Capture screenshot or GIF provenance, timestamp, console findings, and relevant request failures. Compare the affected state and at least one nearby state for stale colors, typography, overlays, focus, loading, empty, and error behavior. Label missing states as unverified; never treat an edited source file or a detached mockup as proof of the real page. + +Report the recipe, observed result, artifacts, limitations, and cleanup status. Treat this Skill as guidance only; it grants no process, browser, sandbox, network, file, or artifact authority. diff --git a/internal/skills/archives/1.0.0/run-verify/manifest.json b/internal/skills/archives/1.0.0/run-verify/manifest.json new file mode 100644 index 00000000..a1bf7261 --- /dev/null +++ b/internal/skills/archives/1.0.0/run-verify/manifest.json @@ -0,0 +1,18 @@ +{ + "protocol": "skill.v1", + "name": "run-verify", + "version": "1.0.0", + "description": "Real runtime verification with fixed launch provenance and a UI evidence extension.", + "profiles": ["code", "script"], + "surfaces": ["code", "cyber"], + "phases": ["deliver"], + "roles": ["root"], + "user_invocable": true, + "model_invocable": true, + "explicit_only": false, + "tool_dependencies": ["list_workspace", "read_file"], + "content_path": "SKILL.md", + "content_sha256": "39d42f2d3aacf962f636c5534da6e5325b816167b5b16d3d9bca0a19483f0ae9", + "content_bytes": 1725, + "content_token_upper_bound": 1725 +} diff --git a/internal/skills/builtins/run-verify/SKILL.md b/internal/skills/builtins/run-verify/SKILL.md index 986fa4c9..25d6fc51 100644 --- a/internal/skills/builtins/run-verify/SKILL.md +++ b/internal/skills/builtins/run-verify/SKILL.md @@ -8,6 +8,14 @@ On the Cyber Surface, run only inside an admitted local sandbox with the Run's e ## Extension: ui-evidence -For UI work, bind evidence to the fixed commit/worktree fingerprint, launch recipe, viewport and scale, route, page state, theme, locale, and deterministic data fixture. Capture screenshot or GIF provenance, timestamp, console findings, and relevant request failures. Compare the affected state and at least one nearby state for stale colors, typography, overlays, focus, loading, empty, and error behavior. Label missing states as unverified; never treat an edited source file or a detached mockup as proof of the real page. +Use `ui-evidence.v1` only through the reviewed operation. Bind repository kind, commit/branch/dirty digest, root/index/worktree manifest, build/start recipes, browser version/executable hash/restricted driver, loopback URL/route, viewport/DPR, locale/theme/motion, deterministic secret-free fixture/seed/state, steps/masks/failure policy, Run, and attempt. Recheck source before build, after readiness, and before the result; drift fails. + +The attempt owns its app/browser trees, temporary Profile, network guard, and port. Never adopt a listener or personal Profile, inherit credentials, reach public targets, follow redirects, evaluate arbitrary script, read cookies/response bodies, or mutate/replay requests. Use bounded navigation, click, digest-sealed type, selector assertion, and capture actions. Cancellation, timeout, and crash cleanup remain lifecycle-owned. + +Capture screenshot, DOM, accessibility, console/page errors, request/HTTP failures, and performance. Retain artifact SHA-256, MIME/bytes, dimensions/viewport, source commit, step, Run/attempt, time, redaction, and `untrusted` marker. Masks must match; baseline changes require human review. + +Outcomes are `not_run|running|passed|failed|cancelled|timed_out|interrupted`; stages are `build|launch|readiness|navigation|selector|assertion|console|network|capture|cleanup`. Only `passed` is positive. A missing result, `not_run`, mock, source inspection, or build success is not a pass. + +Map changes to `focused-checks`, add a real-page regression assertion, and put the manifest, commands/versions, step/artifact hashes, diagnostics, cleanup receipt, and skipped cells in the PR verification receipt. Cover relevant viewport/theme/locale/motion cells. Reuse evidence only when source, recipes, versions, fixture, and environment match. Report the recipe, observed result, artifacts, limitations, and cleanup status. Treat this Skill as guidance only; it grants no process, browser, sandbox, network, file, or artifact authority. diff --git a/internal/skills/builtins/run-verify/manifest.json b/internal/skills/builtins/run-verify/manifest.json index a1bf7261..750c93f8 100644 --- a/internal/skills/builtins/run-verify/manifest.json +++ b/internal/skills/builtins/run-verify/manifest.json @@ -1,8 +1,8 @@ { "protocol": "skill.v1", "name": "run-verify", - "version": "1.0.0", - "description": "Real runtime verification with fixed launch provenance and a UI evidence extension.", + "version": "1.1.0", + "description": "Real runtime verification with source-bound, fail-closed UI evidence and PR receipts.", "profiles": ["code", "script"], "surfaces": ["code", "cyber"], "phases": ["deliver"], @@ -12,7 +12,7 @@ "explicit_only": false, "tool_dependencies": ["list_workspace", "read_file"], "content_path": "SKILL.md", - "content_sha256": "39d42f2d3aacf962f636c5534da6e5325b816167b5b16d3d9bca0a19483f0ae9", - "content_bytes": 1725, - "content_token_upper_bound": 1725 + "content_sha256": "93a5ccb961c1169f7f08e419514a1ab957f1d6a410440acd766e35b45aefbd6a", + "content_bytes": 3002, + "content_token_upper_bound": 3002 } diff --git a/internal/skills/compatibility_test.go b/internal/skills/compatibility_test.go index fc731965..1614515c 100644 --- a/internal/skills/compatibility_test.go +++ b/internal/skills/compatibility_test.go @@ -230,7 +230,7 @@ func TestCommonCapabilitySkillBodiesPreserveEvidenceAndAuthorityBoundaries(t *te contracts := map[string][]string{ "doctor": {"PASS, WARN, FAIL, or UNKNOWN", "Never turn a diagnosis into an automatic repair"}, "debug": {"model, tool, permission, application, or infrastructure", "In Deliver"}, - "run-verify": {"Extension: ui-evidence", "fixed commit/worktree fingerprint", "admitted local sandbox"}, + "run-verify": {"Extension: ui-evidence", "ui-evidence.v1", "not_run", "PR verification receipt"}, "review": {"merge-base", "concurrent or durable code", "confirmed, inferred, or unverified"}, "focused-checks": {"smallest credible set", "must never be reported as passed"}, "simplify": {"call-site evidence", "generated code, reflection, registration, build tags"}, diff --git a/internal/store/batch_delivery_test.go b/internal/store/batch_delivery_test.go index fadb4fd3..122f31b3 100644 --- a/internal/store/batch_delivery_test.go +++ b/internal/store/batch_delivery_test.go @@ -17,7 +17,7 @@ import ( ) func removeSchemaV118ForTestStatements() []string { - return []string{ + return append(removeSchemaV119ForTestStatements(), []string{ `DROP TABLE batch_delivery_merge_steps`, `DROP TABLE batch_delivery_merge_queues`, `DROP TABLE batch_delivery_reviews`, @@ -26,7 +26,7 @@ func removeSchemaV118ForTestStatements() []string { `DROP TABLE batch_delivery_workspaces`, `DROP TABLE batch_delivery_plans`, `DELETE FROM schema_migrations WHERE version = 118`, - } + }...) } func TestSchemaV118UpgradesV117Database(t *testing.T) { diff --git a/internal/store/migration_v119.go b/internal/store/migration_v119.go new file mode 100644 index 00000000..be830ee0 --- /dev/null +++ b/internal/store/migration_v119.go @@ -0,0 +1,330 @@ +package store + +// uiEvidenceStatements adds the ui-evidence.v1 manifest, attempt, fixed-step, +// and content-addressed artifact ledgers. Manifests and artifacts are +// immutable; only the bounded not_run -> running -> terminal attempt state +// machine can update an attempt row. +var uiEvidenceStatements = []string{ + `CREATE TABLE ui_evidence_attempts ( + id TEXT PRIMARY KEY, + protocol_version TEXT NOT NULL, + operation_digest TEXT NOT NULL UNIQUE, + request_fingerprint TEXT NOT NULL, + run_id TEXT NOT NULL, + mission_id TEXT NOT NULL, + session_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + manifest_fingerprint TEXT NOT NULL, + source_commit TEXT NOT NULL, + dirty_digest TEXT NOT NULL, + status TEXT NOT NULL, + failure_stage TEXT NOT NULL, + artifact_count INTEGER NOT NULL, + artifact_bytes INTEGER NOT NULL, + version INTEGER NOT NULL, + manifest_json TEXT NOT NULL, + attempt_json TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE RESTRICT, + FOREIGN KEY(mission_id) REFERENCES missions(id) ON DELETE RESTRICT, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE RESTRICT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE RESTRICT, + CHECK(protocol_version = 'ui-evidence-attempt.v1'), + CHECK(length(operation_digest) = 64 AND operation_digest = lower(operation_digest) + AND operation_digest NOT GLOB '*[^0-9a-f]*'), + CHECK(length(request_fingerprint) = 64 AND request_fingerprint = lower(request_fingerprint) + AND request_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(manifest_fingerprint) = 64 AND manifest_fingerprint = lower(manifest_fingerprint) + AND manifest_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(dirty_digest) = 64 AND dirty_digest = lower(dirty_digest) + AND dirty_digest NOT GLOB '*[^0-9a-f]*'), + CHECK(status IN ('not_run', 'running', 'passed', 'failed', 'cancelled', + 'timed_out', 'interrupted')), + CHECK(failure_stage IN ('none', 'build', 'launch', 'readiness', 'navigation', + 'selector', 'assertion', 'console', 'network', 'capture', 'cleanup')), + CHECK(artifact_count BETWEEN 0 AND 10000), + CHECK(artifact_bytes BETWEEN 0 AND 134217728), + CHECK((artifact_count = 0) = (artifact_bytes = 0)), + CHECK(status != 'passed' OR (artifact_count > 0 AND artifact_bytes > 0)), + CHECK(version >= 1), + CHECK((status = 'not_run' AND version = 1) OR + (status = 'running' AND version = 2) OR + (status IN ('passed', 'failed', 'cancelled', 'timed_out', 'interrupted') + AND version = 3)), + CHECK(json_valid(manifest_json) AND json_valid(attempt_json)), + CHECK(json_extract(manifest_json, '$.protocol_version') IS 'ui-evidence.v1'), + CHECK(json_extract(manifest_json, '$.attempt_id') IS id), + CHECK(json_extract(manifest_json, '$.run_id') IS run_id), + CHECK(json_extract(manifest_json, '$.mission_id') IS mission_id), + CHECK(json_extract(manifest_json, '$.session_id') IS session_id), + CHECK(json_extract(manifest_json, '$.workspace_id') IS workspace_id), + CHECK(json_extract(manifest_json, '$.fingerprint') IS manifest_fingerprint), + CHECK(json_extract(manifest_json, '$.created_at') IS created_at), + CHECK(json_extract(manifest_json, '$.source.commit') IS source_commit), + CHECK(json_extract(manifest_json, '$.source.dirty_digest') IS dirty_digest), + CHECK(json_type(manifest_json, '$.steps') IS 'array' + AND json_array_length(manifest_json, '$.steps') BETWEEN 1 AND 128), + CHECK(json_extract(manifest_json, '$.steps[0].kind') IS 'navigate'), + CHECK(json_extract(manifest_json, '$.capture.screenshot') IS 1), + CHECK(json_extract(manifest_json, '$.capture.dom') IS 1), + CHECK(json_extract(manifest_json, '$.capture.accessibility') IS 1), + CHECK(json_extract(manifest_json, '$.capture.console') IS 1), + CHECK(json_extract(manifest_json, '$.capture.network') IS 1), + CHECK(json_extract(manifest_json, '$.capture.performance') IS 1), + CHECK(json_extract(manifest_json, '$.capture.video') IS 0), + CHECK(json_extract(manifest_json, '$.failure_policy.fail_on_console_error') IS 1), + CHECK(json_extract(manifest_json, '$.failure_policy.fail_on_page_error') IS 1), + CHECK(json_extract(manifest_json, '$.failure_policy.fail_on_request_error') IS 1), + CHECK(json_extract(manifest_json, '$.failure_policy.fail_on_http_status') IS 1), + CHECK(json_extract(manifest_json, '$.authority.process_start') IS 0), + CHECK(json_extract(manifest_json, '$.authority.network_access') IS 0), + CHECK(json_extract(manifest_json, '$.authority.credential_access') IS 0), + CHECK(json_extract(manifest_json, '$.authority.personal_profile') IS 0), + CHECK(json_extract(manifest_json, '$.authority.request_mutation') IS 0), + CHECK(json_extract(manifest_json, '$.authority.verification_pass') IS 0), + CHECK(json_extract(attempt_json, '$.protocol_version') IS protocol_version), + CHECK(json_extract(attempt_json, '$.manifest.attempt_id') IS id), + CHECK(json_extract(attempt_json, '$.manifest.fingerprint') IS manifest_fingerprint), + CHECK(json_extract(attempt_json, '$.operation_digest') IS operation_digest), + CHECK(json_extract(attempt_json, '$.request_fingerprint') IS request_fingerprint), + CHECK(json_extract(attempt_json, '$.status') IS status), + CHECK(json_extract(attempt_json, '$.failure_stage') IS failure_stage), + CHECK(json_extract(attempt_json, '$.artifact_count') IS artifact_count), + CHECK(json_extract(attempt_json, '$.artifact_bytes') IS artifact_bytes), + CHECK(json_extract(attempt_json, '$.version') IS version), + CHECK(json_extract(attempt_json, '$.created_at') IS created_at), + CHECK(json_extract(attempt_json, '$.started_at') IS started_at), + CHECK(json_extract(attempt_json, '$.completed_at') IS completed_at), + CHECK(json_extract(attempt_json, '$.updated_at') IS updated_at), + CHECK(status != 'passed' OR ( + json_extract(attempt_json, '$.cleanup.browser_tree_reaped') IS 1 + AND json_extract(attempt_json, '$.cleanup.application_tree_reaped') IS 1 + AND json_extract(attempt_json, '$.cleanup.profile_removed') IS 1 + AND json_extract(attempt_json, '$.cleanup.network_released') IS 1 + AND json_extract(attempt_json, '$.cleanup.port_released') IS 1 + AND json_extract(attempt_json, '$.diagnostics.console_errors') IS 0 + AND json_extract(attempt_json, '$.diagnostics.page_errors') IS 0 + AND json_extract(attempt_json, '$.diagnostics.failed_requests') IS 0 + AND json_extract(attempt_json, '$.diagnostics.http_failures') IS 0 + AND json_extract(attempt_json, '$.diagnostics.blocked_requests') IS 0)), + CHECK(julianday(created_at) IS NOT NULL AND julianday(updated_at) IS NOT NULL), + CHECK((status = 'not_run' AND started_at IS NULL AND completed_at IS NULL + AND failure_stage = 'none') OR + (status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL + AND failure_stage = 'none') OR + (status IN ('passed', 'failed', 'cancelled', 'timed_out', 'interrupted') + AND started_at IS NOT NULL AND completed_at IS NOT NULL)), + CHECK(status != 'passed' OR failure_stage = 'none'), + CHECK(status NOT IN ('failed', 'cancelled', 'timed_out', 'interrupted') + OR failure_stage != 'none') + );`, + `CREATE INDEX idx_ui_evidence_attempts_run_created + ON ui_evidence_attempts(run_id, created_at DESC, id DESC);`, + `CREATE INDEX idx_ui_evidence_attempts_status_updated + ON ui_evidence_attempts(status, updated_at, id);`, + `CREATE TRIGGER trg_ui_evidence_attempt_run_binding + BEFORE INSERT ON ui_evidence_attempts + WHEN NOT EXISTS (SELECT 1 FROM runs run + JOIN missions mission ON mission.id = run.mission_id + WHERE run.id = NEW.run_id AND run.mission_id = NEW.mission_id + AND run.session_id = NEW.session_id + AND mission.workspace_id = NEW.workspace_id) + BEGIN SELECT RAISE(ABORT, + 'UI evidence manifest does not match its exact Run binding'); END;`, + `CREATE TRIGGER trg_ui_evidence_attempt_identity_immutable + BEFORE UPDATE ON ui_evidence_attempts + WHEN NEW.id != OLD.id OR NEW.operation_digest != OLD.operation_digest + OR NEW.request_fingerprint != OLD.request_fingerprint + OR NEW.run_id != OLD.run_id OR NEW.mission_id != OLD.mission_id + OR NEW.session_id != OLD.session_id OR NEW.workspace_id != OLD.workspace_id + OR NEW.manifest_fingerprint != OLD.manifest_fingerprint + OR NEW.source_commit != OLD.source_commit OR NEW.dirty_digest != OLD.dirty_digest + OR NEW.manifest_json != OLD.manifest_json OR NEW.created_at != OLD.created_at + BEGIN SELECT RAISE(ABORT, 'UI evidence manifest identity is immutable'); END;`, + `CREATE TRIGGER trg_ui_evidence_attempt_transition + BEFORE UPDATE ON ui_evidence_attempts + WHEN NEW.version != OLD.version + 1 OR NOT ( + (OLD.status = 'not_run' AND NEW.status = 'running') OR + (OLD.status = 'running' AND NEW.status IN + ('passed', 'failed', 'cancelled', 'timed_out', 'interrupted'))) + BEGIN SELECT RAISE(ABORT, 'UI evidence attempt transition is invalid'); END;`, + `CREATE TRIGGER trg_ui_evidence_attempt_delete_immutable + BEFORE DELETE ON ui_evidence_attempts + BEGIN SELECT RAISE(ABORT, 'UI evidence attempt cannot be deleted'); END;`, + `CREATE TABLE ui_evidence_steps ( + attempt_id TEXT NOT NULL, + step_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + failure_stage TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT NOT NULL, + PRIMARY KEY(attempt_id, step_id), + UNIQUE(attempt_id, sequence), + FOREIGN KEY(attempt_id) REFERENCES ui_evidence_attempts(id) ON DELETE RESTRICT, + CHECK(sequence BETWEEN 1 AND 128), + CHECK(kind IN ('navigate', 'click', 'type', 'assert_present', 'assert_absent', 'capture')), + CHECK(status IN ('passed', 'failed', 'cancelled', 'timed_out')), + CHECK(failure_stage IN ('none', 'build', 'launch', 'readiness', 'navigation', + 'selector', 'assertion', 'console', 'network', 'capture', 'cleanup')), + CHECK((status = 'passed') = (failure_stage = 'none')), + CHECK(length(fingerprint) = 64 AND fingerprint = lower(fingerprint) + AND fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(json_valid(payload_json)), + CHECK(json_extract(payload_json, '$.protocol_version') IS 'ui-evidence-step.v1'), + CHECK(json_extract(payload_json, '$.attempt_id') IS attempt_id), + CHECK(json_extract(payload_json, '$.step_id') IS step_id), + CHECK(json_extract(payload_json, '$.sequence') IS sequence), + CHECK(json_extract(payload_json, '$.kind') IS kind), + CHECK(json_extract(payload_json, '$.status') IS status), + CHECK(json_extract(payload_json, '$.failure_stage') IS failure_stage), + CHECK(json_extract(payload_json, '$.fingerprint') IS fingerprint), + CHECK(json_extract(payload_json, '$.started_at') IS started_at), + CHECK(json_extract(payload_json, '$.completed_at') IS completed_at), + CHECK(julianday(started_at) IS NOT NULL AND julianday(completed_at) IS NOT NULL + AND julianday(completed_at) >= julianday(started_at)) + ) WITHOUT ROWID;`, + `CREATE TRIGGER trg_ui_evidence_step_insert_running + BEFORE INSERT ON ui_evidence_steps + WHEN NOT EXISTS (SELECT 1 FROM ui_evidence_attempts + WHERE id = NEW.attempt_id AND status = 'running' + AND json_extract(manifest_json, + '$.steps[' || (NEW.sequence - 1) || '].id') IS NEW.step_id + AND json_extract(manifest_json, + '$.steps[' || (NEW.sequence - 1) || '].kind') IS NEW.kind + AND julianday(NEW.started_at) >= julianday(started_at)) + BEGIN SELECT RAISE(ABORT, + 'UI evidence step must match the exact running manifest'); END;`, + `CREATE TRIGGER trg_ui_evidence_step_update_immutable + BEFORE UPDATE ON ui_evidence_steps + BEGIN SELECT RAISE(ABORT, 'UI evidence step cannot be updated'); END;`, + `CREATE TRIGGER trg_ui_evidence_step_delete_immutable + BEFORE DELETE ON ui_evidence_steps + BEGIN SELECT RAISE(ABORT, 'UI evidence step cannot be deleted'); END;`, + `CREATE TABLE ui_evidence_artifacts ( + id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL, + run_id TEXT NOT NULL, + step_id TEXT NOT NULL, + kind TEXT NOT NULL, + mime TEXT NOT NULL, + sha256 TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + source_commit TEXT NOT NULL, + redacted INTEGER NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + metadata_json TEXT NOT NULL, + content BLOB NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(attempt_id) REFERENCES ui_evidence_attempts(id) ON DELETE RESTRICT, + FOREIGN KEY(attempt_id, step_id) REFERENCES ui_evidence_steps(attempt_id, step_id) + ON DELETE RESTRICT, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE RESTRICT, + CHECK(kind IN ('screenshot', 'dom', 'accessibility', 'console', 'network', + 'performance')), + CHECK(size_bytes BETWEEN 1 AND 33554432 AND length(content) = size_bytes), + CHECK(width BETWEEN 0 AND 7680 AND height BETWEEN 0 AND 4320), + CHECK((kind = 'screenshot' AND mime = 'image/png' AND width > 0 AND height > 0) + OR (kind != 'screenshot' AND width = 0 AND height = 0)), + CHECK(length(sha256) = 64 AND sha256 = lower(sha256) + AND sha256 NOT GLOB '*[^0-9a-f]*'), + CHECK(redacted IN (0, 1)), + CHECK(length(fingerprint) = 64 AND fingerprint = lower(fingerprint) + AND fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(json_valid(metadata_json)), + CHECK(json_extract(metadata_json, '$.protocol_version') IS 'ui-evidence-artifact.v1'), + CHECK(json_extract(metadata_json, '$.id') IS id), + CHECK(json_extract(metadata_json, '$.attempt_id') IS attempt_id), + CHECK(json_extract(metadata_json, '$.run_id') IS run_id), + CHECK(json_extract(metadata_json, '$.step_id') IS step_id), + CHECK(json_extract(metadata_json, '$.kind') IS kind), + CHECK(json_extract(metadata_json, '$.mime') IS mime), + CHECK(json_extract(metadata_json, '$.sha256') IS sha256), + CHECK(json_extract(metadata_json, '$.bytes') IS size_bytes), + CHECK(json_extract(metadata_json, '$.source_commit') IS source_commit), + CHECK(json_extract(metadata_json, '$.retention_policy') IS 'run_history'), + CHECK(json_extract(metadata_json, '$.redacted') IS redacted), + CHECK(json_extract(metadata_json, '$.untrusted') IS 1), + CHECK(json_extract(metadata_json, '$.fingerprint') IS fingerprint), + CHECK(json_extract(metadata_json, '$.created_at') IS created_at), + CHECK(julianday(created_at) IS NOT NULL) + );`, + `CREATE INDEX idx_ui_evidence_artifacts_attempt_created + ON ui_evidence_artifacts(attempt_id, created_at, id);`, + `CREATE TRIGGER trg_ui_evidence_attempt_artifact_totals + BEFORE UPDATE ON ui_evidence_attempts + WHEN NEW.artifact_count != (SELECT COUNT(*) FROM ui_evidence_artifacts + WHERE attempt_id = NEW.id) + OR NEW.artifact_bytes != (SELECT COALESCE(SUM(size_bytes), 0) + FROM ui_evidence_artifacts WHERE attempt_id = NEW.id) + BEGIN SELECT RAISE(ABORT, + 'UI evidence attempt artifact totals do not match immutable artifacts'); END;`, + `CREATE TRIGGER trg_ui_evidence_attempt_pass_complete + BEFORE UPDATE ON ui_evidence_attempts + WHEN NEW.status = 'passed' AND ( + (SELECT COUNT(*) FROM ui_evidence_steps WHERE attempt_id = NEW.id) + != json_array_length(NEW.manifest_json, '$.steps') + OR EXISTS (SELECT 1 FROM ui_evidence_steps + WHERE attempt_id = NEW.id AND status != 'passed') + OR (SELECT COUNT(DISTINCT kind) FROM ui_evidence_artifacts + WHERE attempt_id = NEW.id AND kind IN ('screenshot', 'dom', + 'accessibility', 'console', 'network', 'performance')) != 6) + BEGIN SELECT RAISE(ABORT, + 'passed UI evidence requires every manifest step and core artifact'); END;`, + `CREATE TRIGGER trg_ui_evidence_attempt_terminal_time + BEFORE UPDATE ON ui_evidence_attempts + WHEN NEW.status IN ('passed', 'failed', 'cancelled', 'timed_out', 'interrupted') + AND (EXISTS (SELECT 1 FROM ui_evidence_steps + WHERE attempt_id = NEW.id + AND julianday(completed_at) > julianday(NEW.completed_at)) + OR EXISTS (SELECT 1 FROM ui_evidence_artifacts + WHERE attempt_id = NEW.id + AND julianday(created_at) > julianday(NEW.completed_at))) + BEGIN SELECT RAISE(ABORT, + 'UI evidence completion precedes its immutable evidence'); END;`, + `CREATE TRIGGER trg_ui_evidence_artifact_insert_running + BEFORE INSERT ON ui_evidence_artifacts + WHEN NOT EXISTS (SELECT 1 FROM ui_evidence_attempts + WHERE id = NEW.attempt_id AND run_id = NEW.run_id + AND source_commit = NEW.source_commit AND status = 'running' + AND NEW.kind != 'video' + AND json_extract(NEW.metadata_json, '$.viewport.width') + IS json_extract(manifest_json, '$.environment.viewport.width') + AND json_extract(NEW.metadata_json, '$.viewport.height') + IS json_extract(manifest_json, '$.environment.viewport.height') + AND json_extract(NEW.metadata_json, '$.viewport.dpr') + IS json_extract(manifest_json, '$.environment.viewport.dpr') + AND (NEW.kind != 'screenshot' OR ( + ABS(NEW.width - json_extract(manifest_json, + '$.environment.viewport.width') * json_extract(manifest_json, + '$.environment.viewport.dpr')) <= 1 + AND ABS(NEW.height - json_extract(manifest_json, + '$.environment.viewport.height') * json_extract(manifest_json, + '$.environment.viewport.dpr')) <= 1)) + AND julianday(NEW.created_at) >= julianday(started_at)) + BEGIN SELECT RAISE(ABORT, 'UI evidence artifacts require the exact running attempt'); END;`, + `CREATE TRIGGER trg_ui_evidence_artifact_attempt_quota + BEFORE INSERT ON ui_evidence_artifacts + WHEN (SELECT COALESCE(SUM(size_bytes), 0) FROM ui_evidence_artifacts + WHERE attempt_id = NEW.attempt_id) + NEW.size_bytes > 134217728 + BEGIN SELECT RAISE(ABORT, 'UI evidence attempt artifact quota exceeded'); END;`, + `CREATE TRIGGER trg_ui_evidence_artifact_store_quota + BEFORE INSERT ON ui_evidence_artifacts + WHEN (SELECT COALESCE(SUM(size_bytes), 0) FROM ui_evidence_artifacts) + + NEW.size_bytes > 2147483648 + BEGIN SELECT RAISE(ABORT, 'UI evidence artifact store quota exceeded'); END;`, + `CREATE TRIGGER trg_ui_evidence_artifact_update_immutable + BEFORE UPDATE ON ui_evidence_artifacts + BEGIN SELECT RAISE(ABORT, 'UI evidence artifact cannot be updated'); END;`, + `CREATE TRIGGER trg_ui_evidence_artifact_delete_immutable + BEFORE DELETE ON ui_evidence_artifacts + BEGIN SELECT RAISE(ABORT, 'UI evidence artifact cannot be deleted'); END;`, +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index d9ac772a..fabdf47c 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -11,7 +11,7 @@ import ( "time" ) -const LatestSchemaVersion = 118 +const LatestSchemaVersion = 119 type migration struct { Version int diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 20f28ca8..fd4635cb 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -345,6 +345,7 @@ func migrationPlan() []migration { {Version: 116, Name: "Run-owned command runtime jobs", Statements: commandRuntimeStatements}, {Version: 117, Name: "content-addressed reversible Workspace checkpoints", Statements: workspaceCheckpointStatements}, {Version: 118, Name: "isolated child worktrees and reviewed batch delivery", Statements: batchDeliveryStatements}, + {Version: 119, Name: "source-bound real-browser UI evidence", Statements: uiEvidenceStatements}, } } diff --git a/internal/store/ui_evidence.go b/internal/store/ui_evidence.go new file mode 100644 index 00000000..9a9934d3 --- /dev/null +++ b/internal/store/ui_evidence.go @@ -0,0 +1,418 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/uievidence" +) + +func (s *SQLiteStore) CreateUIEvidenceAttempt(ctx context.Context, + attempt uievidence.Attempt, +) (uievidence.Attempt, bool, error) { + if s == nil || s.db == nil { + return uievidence.Attempt{}, false, apperror.New( + apperror.CodeFailedPrecondition, "UI evidence store is unavailable") + } + if attempt.Status != uievidence.StatusNotRun || attempt.Version != 1 || + attempt.Validate() != nil { + return uievidence.Attempt{}, false, apperror.New( + apperror.CodeInvalidArgument, "UI evidence attempt is invalid") + } + manifestJSON, err := json.Marshal(attempt.Manifest) + if err != nil { + return uievidence.Attempt{}, false, err + } + attemptJSON, err := json.Marshal(attempt) + if err != nil { + return uievidence.Attempt{}, false, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return uievidence.Attempt{}, false, err + } + defer func() { _ = tx.Rollback() }() + existing, found, err := getUIEvidenceAttemptByOperation(ctx, tx, + attempt.OperationDigest) + if err != nil { + return uievidence.Attempt{}, false, err + } + if found { + if existing.Manifest.AttemptID != attempt.Manifest.AttemptID || + existing.RequestFingerprint != attempt.RequestFingerprint { + return uievidence.Attempt{}, false, apperror.New( + apperror.CodeConflict, "UI evidence operation key was reused") + } + if err := tx.Commit(); err != nil { + return uievidence.Attempt{}, false, err + } + return existing, true, nil + } + _, err = tx.ExecContext(ctx, `INSERT INTO ui_evidence_attempts ( + id, protocol_version, operation_digest, request_fingerprint, run_id, + mission_id, session_id, workspace_id, manifest_fingerprint, source_commit, + dirty_digest, status, failure_stage, artifact_count, artifact_bytes, version, + manifest_json, attempt_json, created_at, started_at, completed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + attempt.Manifest.AttemptID, attempt.ProtocolVersion, attempt.OperationDigest, + attempt.RequestFingerprint, attempt.Manifest.RunID, attempt.Manifest.MissionID, + attempt.Manifest.SessionID, attempt.Manifest.WorkspaceID, + attempt.Manifest.Fingerprint, attempt.Manifest.Source.Commit, + attempt.Manifest.Source.DirtyDigest, attempt.Status, attempt.FailureStage, + attempt.ArtifactCount, attempt.ArtifactBytes, attempt.Version, + string(manifestJSON), string(attemptJSON), ts(attempt.CreatedAt), + nullableTS(attempt.StartedAt), nullableTS(attempt.CompletedAt), ts(attempt.UpdatedAt)) + if err != nil { + return uievidence.Attempt{}, false, apperror.Wrap( + apperror.CodeConflict, "UI evidence manifest was rejected", err) + } + stored, err := getUIEvidenceAttempt(ctx, tx, attempt.Manifest.AttemptID) + if err != nil { + return uievidence.Attempt{}, false, err + } + if err := tx.Commit(); err != nil { + return uievidence.Attempt{}, false, err + } + return stored, false, nil +} + +func (s *SQLiteStore) UpdateUIEvidenceAttempt(ctx context.Context, + attempt uievidence.Attempt, expectedVersion int64, +) (uievidence.Attempt, error) { + if s == nil || s.db == nil { + return uievidence.Attempt{}, apperror.New( + apperror.CodeFailedPrecondition, "UI evidence store is unavailable") + } + if expectedVersion < 1 || attempt.Version != expectedVersion+1 || + attempt.Validate() != nil { + return uievidence.Attempt{}, apperror.New( + apperror.CodeInvalidArgument, "UI evidence transition is invalid") + } + attemptJSON, err := json.Marshal(attempt) + if err != nil { + return uievidence.Attempt{}, err + } + result, err := s.db.ExecContext(ctx, `UPDATE ui_evidence_attempts SET + status = ?, failure_stage = ?, artifact_count = ?, artifact_bytes = ?, + version = ?, attempt_json = ?, started_at = ?, completed_at = ?, updated_at = ? + WHERE id = ? AND version = ?`, attempt.Status, attempt.FailureStage, + attempt.ArtifactCount, attempt.ArtifactBytes, attempt.Version, string(attemptJSON), + nullableTS(attempt.StartedAt), nullableTS(attempt.CompletedAt), ts(attempt.UpdatedAt), + attempt.Manifest.AttemptID, expectedVersion) + if err != nil { + return uievidence.Attempt{}, apperror.Wrap( + apperror.CodeConflict, "UI evidence transition was rejected", err) + } + changed, err := result.RowsAffected() + if err != nil { + return uievidence.Attempt{}, err + } + if changed != 1 { + if _, getErr := s.GetUIEvidenceAttempt(ctx, attempt.Manifest.AttemptID); getErr != nil { + return uievidence.Attempt{}, getErr + } + return uievidence.Attempt{}, apperror.New( + apperror.CodeConflict, "UI evidence attempt version changed") + } + return s.GetUIEvidenceAttempt(ctx, attempt.Manifest.AttemptID) +} + +func (s *SQLiteStore) GetUIEvidenceAttempt(ctx context.Context, + attemptID string, +) (uievidence.Attempt, error) { + attemptID = strings.TrimSpace(attemptID) + if s == nil || s.db == nil || !domain.ValidAgentID(attemptID) { + return uievidence.Attempt{}, apperror.New( + apperror.CodeInvalidArgument, "UI evidence attempt id is invalid") + } + return getUIEvidenceAttempt(ctx, s.db, attemptID) +} + +func (s *SQLiteStore) ListUIEvidenceAttempts(ctx context.Context, + filter uievidence.ListFilter, +) ([]uievidence.Attempt, error) { + if s == nil || s.db == nil || filter.Validate() != nil { + return nil, apperror.New(apperror.CodeInvalidArgument, + "UI evidence list filter is invalid") + } + if filter.Limit == 0 { + filter.Limit = 100 + } + query := `SELECT attempt_json FROM ui_evidence_attempts WHERE 1 = 1` + arguments := make([]any, 0, 3) + if filter.RunID != "" { + query += ` AND run_id = ?` + arguments = append(arguments, filter.RunID) + } + if filter.Status != "" { + query += ` AND status = ?` + arguments = append(arguments, filter.Status) + } + query += ` ORDER BY created_at DESC, id DESC LIMIT ?` + arguments = append(arguments, filter.Limit) + rows, err := s.db.QueryContext(ctx, query, arguments...) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]uievidence.Attempt, 0, filter.Limit) + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, err + } + attempt, err := decodeUIEvidenceAttempt(raw) + if err != nil { + return nil, err + } + values = append(values, attempt) + } + return values, rows.Err() +} + +func (s *SQLiteStore) AddUIEvidenceStep(ctx context.Context, + receipt uievidence.StepReceipt, +) error { + if s == nil || s.db == nil { + return apperror.New(apperror.CodeFailedPrecondition, + "UI evidence store is unavailable") + } + if err := receipt.Validate(); err != nil { + return apperror.New(apperror.CodeInvalidArgument, + "UI evidence step receipt is invalid") + } + raw, err := json.Marshal(receipt) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO ui_evidence_steps ( + attempt_id, step_id, sequence, kind, status, failure_stage, fingerprint, + payload_json, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + receipt.AttemptID, receipt.StepID, receipt.Sequence, receipt.Kind, + receipt.Status, receipt.FailureStage, receipt.Fingerprint, string(raw), + ts(receipt.StartedAt), ts(receipt.CompletedAt)) + if err != nil { + return apperror.Wrap(apperror.CodeConflict, + "UI evidence step receipt was rejected", err) + } + return nil +} + +func (s *SQLiteStore) ListUIEvidenceSteps(ctx context.Context, + attemptID string, +) ([]uievidence.StepReceipt, error) { + attemptID = strings.TrimSpace(attemptID) + if s == nil || s.db == nil || !domain.ValidAgentID(attemptID) { + return nil, apperror.New(apperror.CodeInvalidArgument, + "UI evidence attempt id is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT payload_json FROM ui_evidence_steps + WHERE attempt_id = ? ORDER BY sequence`, attemptID) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]uievidence.StepReceipt, 0) + for rows.Next() { + var raw string + var receipt uievidence.StepReceipt + if err := rows.Scan(&raw); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(raw), &receipt); err != nil || receipt.Validate() != nil { + return nil, fmt.Errorf("invalid persisted UI evidence step for %q", attemptID) + } + values = append(values, receipt) + } + return values, rows.Err() +} + +func (s *SQLiteStore) AddUIEvidenceArtifact(ctx context.Context, + artifact uievidence.Artifact, +) error { + if s == nil || s.db == nil { + return apperror.New(apperror.CodeFailedPrecondition, + "UI evidence store is unavailable") + } + if err := artifact.Validate(); err != nil { + return apperror.New(apperror.CodeInvalidArgument, + "UI evidence artifact is invalid") + } + metadata := artifact.Metadata + raw, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO ui_evidence_artifacts ( + id, attempt_id, run_id, step_id, kind, mime, sha256, size_bytes, width, + height, source_commit, redacted, fingerprint, metadata_json, content, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, metadata.ID, + metadata.AttemptID, metadata.RunID, metadata.StepID, metadata.Kind, + metadata.MIME, metadata.SHA256, metadata.Bytes, metadata.Width, + metadata.Height, metadata.SourceCommit, boolInt(metadata.Redacted), + metadata.Fingerprint, string(raw), artifact.Content, ts(metadata.CreatedAt)) + if err != nil { + return apperror.Wrap(apperror.CodeConflict, + "UI evidence artifact was rejected", err) + } + return nil +} + +func (s *SQLiteStore) ListUIEvidenceArtifacts(ctx context.Context, + attemptID string, +) ([]uievidence.ArtifactMetadata, error) { + attemptID = strings.TrimSpace(attemptID) + if s == nil || s.db == nil || !domain.ValidAgentID(attemptID) { + return nil, apperror.New(apperror.CodeInvalidArgument, + "UI evidence attempt id is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT metadata_json FROM ui_evidence_artifacts + WHERE attempt_id = ? ORDER BY created_at, id`, attemptID) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]uievidence.ArtifactMetadata, 0) + for rows.Next() { + var raw string + var metadata uievidence.ArtifactMetadata + if err := rows.Scan(&raw); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata.Validate() != nil { + return nil, fmt.Errorf("invalid persisted UI evidence artifact for %q", attemptID) + } + values = append(values, metadata) + } + return values, rows.Err() +} + +func (s *SQLiteStore) GetUIEvidenceArtifact(ctx context.Context, + attemptID, artifactID string, +) (uievidence.Artifact, error) { + attemptID = strings.TrimSpace(attemptID) + artifactID = strings.TrimSpace(artifactID) + if s == nil || s.db == nil || !domain.ValidAgentID(attemptID) || + !domain.ValidAgentID(artifactID) { + return uievidence.Artifact{}, apperror.New( + apperror.CodeInvalidArgument, "UI evidence artifact identity is invalid") + } + var raw string + var content []byte + err := s.db.QueryRowContext(ctx, `SELECT metadata_json, content + FROM ui_evidence_artifacts WHERE attempt_id = ? AND id = ?`, + attemptID, artifactID).Scan(&raw, &content) + if errors.Is(err, sql.ErrNoRows) { + return uievidence.Artifact{}, apperror.New( + apperror.CodeNotFound, "UI evidence artifact was not found") + } + if err != nil { + return uievidence.Artifact{}, err + } + var metadata uievidence.ArtifactMetadata + artifact := uievidence.Artifact{Metadata: metadata, Content: content} + if err := json.Unmarshal([]byte(raw), &artifact.Metadata); err != nil || + artifact.Validate() != nil { + return uievidence.Artifact{}, fmt.Errorf( + "invalid persisted UI evidence artifact %q", artifactID) + } + return artifact, nil +} + +func (s *SQLiteStore) UIEvidenceArtifactTotals(ctx context.Context, + attemptID string, +) (int, int64, error) { + attemptID = strings.TrimSpace(attemptID) + if s == nil || s.db == nil || !domain.ValidAgentID(attemptID) { + return 0, 0, apperror.New(apperror.CodeInvalidArgument, + "UI evidence attempt id is invalid") + } + var count int + var bytes int64 + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) + FROM ui_evidence_artifacts WHERE attempt_id = ?`, attemptID).Scan(&count, &bytes) + return count, bytes, err +} + +func (s *SQLiteStore) ReconcileUIEvidenceAttempts(ctx context.Context, + now time.Time, +) ([]uievidence.Attempt, error) { + attempts, err := s.ListUIEvidenceAttempts(ctx, + uievidence.ListFilter{Status: uievidence.StatusRunning, Limit: 500}) + if err != nil { + return nil, err + } + reconciled := make([]uievidence.Attempt, 0, len(attempts)) + for _, attempt := range attempts { + count, bytes, err := s.UIEvidenceArtifactTotals(ctx, attempt.Manifest.AttemptID) + if err != nil { + return nil, err + } + interrupted, err := uievidence.InterruptAttempt(attempt, count, bytes, now) + if err != nil { + return nil, err + } + stored, err := s.UpdateUIEvidenceAttempt(ctx, interrupted, attempt.Version) + if err != nil { + return nil, err + } + reconciled = append(reconciled, stored) + } + return reconciled, nil +} + +type uiEvidenceAttemptQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func getUIEvidenceAttempt(ctx context.Context, queryer uiEvidenceAttemptQueryer, + attemptID string, +) (uievidence.Attempt, error) { + var raw string + err := queryer.QueryRowContext(ctx, `SELECT attempt_json FROM ui_evidence_attempts + WHERE id = ?`, attemptID).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return uievidence.Attempt{}, apperror.New( + apperror.CodeNotFound, "UI evidence attempt was not found") + } + if err != nil { + return uievidence.Attempt{}, err + } + return decodeUIEvidenceAttempt(raw) +} + +func getUIEvidenceAttemptByOperation(ctx context.Context, + queryer uiEvidenceAttemptQueryer, digest string, +) (uievidence.Attempt, bool, error) { + var raw string + err := queryer.QueryRowContext(ctx, `SELECT attempt_json FROM ui_evidence_attempts + WHERE operation_digest = ?`, digest).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return uievidence.Attempt{}, false, nil + } + if err != nil { + return uievidence.Attempt{}, false, err + } + attempt, err := decodeUIEvidenceAttempt(raw) + return attempt, err == nil, err +} + +func decodeUIEvidenceAttempt(raw string) (uievidence.Attempt, error) { + var attempt uievidence.Attempt + if err := json.Unmarshal([]byte(raw), &attempt); err != nil { + return uievidence.Attempt{}, err + } + if err := attempt.Validate(); err != nil { + return uievidence.Attempt{}, fmt.Errorf( + "invalid persisted UI evidence attempt %q: %w", + attempt.Manifest.AttemptID, err) + } + return attempt, nil +} diff --git a/internal/store/ui_evidence_test.go b/internal/store/ui_evidence_test.go new file mode 100644 index 00000000..318abbef --- /dev/null +++ b/internal/store/ui_evidence_test.go @@ -0,0 +1,407 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math" + "path/filepath" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/uievidence" + "cyberagent-workbench/internal/workspacecheckpoint" +) + +func TestUIEvidenceStorePersistsImmutableAttemptStepsAndArtifacts(t *testing.T) { + state, runRecord, mission, _ := newWorkspaceCheckpointStoreFixture(t) + defer state.Close() + ctx := context.Background() + now := time.Date(2026, 8, 19, 6, 0, 0, 0, time.UTC) + manifest := storeUIEvidenceManifest(t, runRecord.ID, mission.ID, + runRecord.SessionID, mission.WorkspaceID, "ui-attempt-store", now) + attempt, err := uievidence.NewAttempt(manifest, "store-operation", now) + if err != nil { + t.Fatal(err) + } + manifestPayload := map[string]any{} + manifestJSON, err := json.Marshal(attempt.Manifest) + if err != nil || json.Unmarshal(manifestJSON, &manifestPayload) != nil { + t.Fatalf("encode manifest: %v", err) + } + delete(manifestPayload["capture"].(map[string]any), "network") + manifestJSON, err = json.Marshal(manifestPayload) + if err != nil { + t.Fatal(err) + } + attemptJSON, err := json.Marshal(attempt) + if err != nil { + t.Fatal(err) + } + if _, err := state.db.ExecContext(ctx, `INSERT INTO ui_evidence_attempts ( + id, protocol_version, operation_digest, request_fingerprint, run_id, + mission_id, session_id, workspace_id, manifest_fingerprint, source_commit, + dirty_digest, status, failure_stage, artifact_count, artifact_bytes, version, + manifest_json, attempt_json, created_at, started_at, completed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + attempt.Manifest.AttemptID, attempt.ProtocolVersion, attempt.OperationDigest, + attempt.RequestFingerprint, attempt.Manifest.RunID, attempt.Manifest.MissionID, + attempt.Manifest.SessionID, attempt.Manifest.WorkspaceID, + attempt.Manifest.Fingerprint, attempt.Manifest.Source.Commit, + attempt.Manifest.Source.DirtyDigest, attempt.Status, attempt.FailureStage, + attempt.ArtifactCount, attempt.ArtifactBytes, attempt.Version, + string(manifestJSON), string(attemptJSON), ts(attempt.CreatedAt), nil, nil, + ts(attempt.UpdatedAt)); err == nil { + t.Fatal("manifest with a missing required JSON field was accepted") + } + created, replayed, err := state.CreateUIEvidenceAttempt(ctx, attempt) + if err != nil || replayed || created.Status != uievidence.StatusNotRun { + t.Fatalf("created=%+v replayed=%t err=%v", created, replayed, err) + } + replayedAttempt, replayed, err := state.CreateUIEvidenceAttempt(ctx, attempt) + if err != nil || !replayed || replayedAttempt.Manifest.AttemptID != manifest.AttemptID { + t.Fatalf("replay=%+v replayed=%t err=%v", replayedAttempt, replayed, err) + } + started, err := uievidence.StartAttempt(created, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + started, err = state.UpdateUIEvidenceAttempt(ctx, started, created.Version) + if err != nil { + t.Fatal(err) + } + + stepStarted := now.Add(2 * time.Second) + wrongReceipt, err := uievidence.SealStepReceipt(uievidence.StepReceipt{ + AttemptID: manifest.AttemptID, StepID: "wrong-step", Sequence: 1, + Kind: uievidence.StepNavigate, Status: uievidence.StatusPassed, + FailureStage: uievidence.FailureNone, StartedAt: stepStarted, + CompletedAt: stepStarted.Add(time.Second)}) + if err != nil { + t.Fatal(err) + } + if err := state.AddUIEvidenceStep(ctx, wrongReceipt); err == nil { + t.Fatal("step outside the immutable manifest was accepted") + } + receipt, err := uievidence.SealStepReceipt(uievidence.StepReceipt{ + AttemptID: manifest.AttemptID, StepID: "navigate", Sequence: 1, + Kind: uievidence.StepNavigate, Status: uievidence.StatusPassed, + FailureStage: uievidence.FailureNone, StartedAt: stepStarted, + CompletedAt: stepStarted.Add(time.Second)}) + if err != nil { + t.Fatal(err) + } + if err := state.AddUIEvidenceStep(ctx, receipt); err != nil { + t.Fatal(err) + } + staleArtifact := storeUIEvidenceArtifact(t, manifest, runRecord.ID, receipt.StepID, + "ui-artifact-stale-time", uievidence.ArtifactDOM, manifest.Environment.Viewport, now) + if err := state.AddUIEvidenceArtifact(ctx, staleArtifact); err == nil { + t.Fatal("artifact captured before the running attempt was accepted") + } + mismatchedViewport := manifest.Environment.Viewport + mismatchedViewport.Width++ + mismatched := storeUIEvidenceArtifact(t, manifest, runRecord.ID, receipt.StepID, + "ui-artifact-bad-viewport", uievidence.ArtifactDOM, mismatchedViewport, + stepStarted.Add(2*time.Second)) + if err := state.AddUIEvidenceArtifact(ctx, mismatched); err == nil { + t.Fatal("artifact from a viewport outside the immutable manifest was accepted") + } + artifact := storeUIEvidenceArtifact(t, manifest, runRecord.ID, receipt.StepID, + "ui-artifact-dom", uievidence.ArtifactDOM, manifest.Environment.Viewport, + stepStarted.Add(2*time.Second)) + if err := state.AddUIEvidenceArtifact(ctx, artifact); err != nil { + t.Fatal(err) + } + loadedArtifact, err := state.GetUIEvidenceArtifact(ctx, + manifest.AttemptID, artifact.Metadata.ID) + if err != nil || string(loadedArtifact.Content) != string(artifact.Content) { + t.Fatalf("artifact=%+v err=%v", loadedArtifact.Metadata, err) + } + + count, bytes, err := state.UIEvidenceArtifactTotals(ctx, manifest.AttemptID) + if err != nil || count != 1 || bytes != int64(len(artifact.Content)) { + t.Fatalf("artifact totals=%d/%d err=%v", count, bytes, err) + } + cleanup := uievidence.CleanupReceipt{BrowserTreeReaped: true, + ApplicationTreeReaped: true, ProfileRemoved: true, NetworkReleased: true, + PortReleased: true} + premature, err := uievidence.CompleteAttempt(started, uievidence.StatusPassed, + uievidence.FailureNone, "", "", uievidence.DiagnosticsSummary{}, cleanup, + count, bytes, now.Add(5*time.Second)) + if err != nil { + t.Fatal(err) + } + if _, err := state.UpdateUIEvidenceAttempt(ctx, premature, started.Version); err == nil { + t.Fatal("passing attempt without all six core artifacts was accepted") + } + for index, kind := range []uievidence.ArtifactKind{ + uievidence.ArtifactScreenshot, + uievidence.ArtifactAccessibility, + uievidence.ArtifactConsole, + uievidence.ArtifactNetwork, + uievidence.ArtifactPerformance, + } { + value := storeUIEvidenceArtifact(t, manifest, runRecord.ID, receipt.StepID, + "ui-artifact-"+string(kind), kind, manifest.Environment.Viewport, + stepStarted.Add(time.Duration(index+3)*time.Second)) + if err := state.AddUIEvidenceArtifact(ctx, value); err != nil { + t.Fatalf("add %s artifact: %v", kind, err) + } + } + count, bytes, err = state.UIEvidenceArtifactTotals(ctx, manifest.AttemptID) + if err != nil || count != 6 || bytes < 6 { + t.Fatalf("complete artifact totals=%d/%d err=%v", count, bytes, err) + } + fabricated, err := uievidence.CompleteAttempt(started, uievidence.StatusPassed, + uievidence.FailureNone, "", "", uievidence.DiagnosticsSummary{}, cleanup, + count+1, bytes+1, now.Add(9*time.Second)) + if err != nil { + t.Fatal(err) + } + if _, err := state.UpdateUIEvidenceAttempt(ctx, fabricated, started.Version); err == nil { + t.Fatal("passing attempt with fabricated artifact totals was accepted") + } + completed, err := uievidence.CompleteAttempt(started, uievidence.StatusPassed, + uievidence.FailureNone, "", "", uievidence.DiagnosticsSummary{}, cleanup, + count, bytes, now.Add(10*time.Second)) + if err != nil { + t.Fatal(err) + } + completed, err = state.UpdateUIEvidenceAttempt(ctx, completed, started.Version) + if err != nil || !completed.Status.Passed() { + t.Fatalf("completed=%+v err=%v", completed, err) + } + if _, err := state.db.ExecContext(ctx, `UPDATE ui_evidence_attempts + SET status = 'failed', version = version + 1 WHERE id = ?`, + manifest.AttemptID); err == nil { + t.Fatal("terminal UI evidence accepted mutation") + } + late := storeUIEvidenceArtifact(t, manifest, runRecord.ID, receipt.StepID, + "ui-artifact-late-dom", uievidence.ArtifactDOM, manifest.Environment.Viewport, + now.Add(11*time.Second)) + if err := state.AddUIEvidenceArtifact(ctx, late); err == nil { + t.Fatal("terminal UI evidence accepted a late artifact") + } +} + +func TestUIEvidenceStartupReconciliationNeverTurnsNotRunGreen(t *testing.T) { + state, runRecord, mission, _ := newWorkspaceCheckpointStoreFixture(t) + defer state.Close() + now := time.Date(2026, 8, 19, 7, 0, 0, 0, time.UTC) + manifest := storeUIEvidenceManifest(t, runRecord.ID, mission.ID, + runRecord.SessionID, mission.WorkspaceID, "ui-attempt-reconcile", now) + attempt, err := uievidence.NewAttempt(manifest, "reconcile-operation", now) + if err != nil { + t.Fatal(err) + } + created, _, err := state.CreateUIEvidenceAttempt(t.Context(), attempt) + if err != nil { + t.Fatal(err) + } + started, err := uievidence.StartAttempt(created, now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if _, err := state.UpdateUIEvidenceAttempt(t.Context(), started, created.Version); err != nil { + t.Fatal(err) + } + reconciled, err := state.ReconcileUIEvidenceAttempts(t.Context(), now.Add(time.Minute)) + if err != nil || len(reconciled) != 1 || + reconciled[0].Status != uievidence.StatusInterrupted || + reconciled[0].Status.Passed() || + reconciled[0].FailureStage != uievidence.FailureCleanup { + t.Fatalf("reconciled=%+v err=%v", reconciled, err) + } +} + +func TestSchemaV119UpgradeAddsUIEvidenceWithoutRewritingV118State(t *testing.T) { + ctx := context.Background() + databasePath := filepath.Join(t.TempDir(), "ui-evidence-v118.db") + state, err := Open(databasePath) + if err != nil { + t.Fatal(err) + } + workspaceRoot := newWorkspaceCheckpointGitRepository(t) + workspace := WorkspaceRecord{ID: "workspace-migration-119", Name: "migration-119", + RootPath: workspaceRoot} + if err := state.SaveWorkspace(ctx, workspace); err != nil { + t.Fatal(err) + } + mission, runRecord, err := application.NewRunService(state).Create(ctx, + application.CreateRunRequest{Goal: "preserve schema v118 state across v119", + Profile: "code", WorkspaceID: workspace.ID, + Budget: domain.Budget{MaxTurns: 2, MaxTokens: 500, MaxToolCalls: 4}}) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 20, 1, 0, 0, 0, time.UTC) + checkpoint := captureStoreCheckpoint(t, runRecord, mission, workspaceRoot, + "checkpoint-before-v119", "receipt-before-v119", + workspacecheckpoint.PhaseStandalone, now) + if _, _, err := state.CreateWorkspaceCheckpoint(ctx, checkpoint); err != nil { + t.Fatal(err) + } + for _, statement := range removeSchemaV119ForTestStatements() { + if _, err := state.db.ExecContext(ctx, statement); err != nil { + _ = state.Close() + t.Fatalf("downgrade v119 with %q: %v", statement, err) + } + } + if err := state.Close(); err != nil { + t.Fatal(err) + } + + upgraded, err := Open(databasePath) + if err != nil { + t.Fatal(err) + } + defer upgraded.Close() + loadedRun, err := upgraded.GetRun(ctx, runRecord.ID) + if err != nil || loadedRun.ID != runRecord.ID { + t.Fatalf("Run after v119 migration=%+v err=%v", loadedRun, err) + } + loadedCheckpoint, err := upgraded.GetWorkspaceCheckpoint(ctx, + checkpoint.Checkpoint.ID) + if err != nil || loadedCheckpoint.ManifestSHA256 != checkpoint.Checkpoint.ManifestSHA256 { + t.Fatalf("v118 checkpoint after v119 migration=%+v err=%v", loadedCheckpoint, err) + } + if version, err := upgraded.SchemaVersion(ctx); err != nil || + version != LatestSchemaVersion { + t.Fatalf("schema version=%d err=%v", version, err) + } + for _, table := range []string{"ui_evidence_attempts", "ui_evidence_steps", + "ui_evidence_artifacts"} { + var count int + if err := upgraded.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil || count != 1 { + t.Fatalf("table %s count=%d err=%v", table, count, err) + } + } + attempts, err := upgraded.ListUIEvidenceAttempts(ctx, + uievidence.ListFilter{RunID: runRecord.ID, Limit: 10}) + if err != nil || len(attempts) != 0 { + t.Fatalf("v119 fabricated UI evidence: %+v err=%v", attempts, err) + } + for name, fragments := range map[string][]string{ + "trg_ui_evidence_attempt_transition": {"not_run", "running", "interrupted"}, + "trg_ui_evidence_attempt_run_binding": {"runs run", "run.session_id", "mission.workspace_id"}, + "trg_ui_evidence_attempt_artifact_totals": {"COUNT(*)", "SUM(size_bytes)"}, + "trg_ui_evidence_attempt_pass_complete": {"status = 'passed'", "COUNT(DISTINCT kind)", "performance"}, + "trg_ui_evidence_attempt_terminal_time": {"julianday(completed_at)", "julianday(NEW.completed_at)"}, + "trg_ui_evidence_step_insert_running": {"NEW.sequence - 1", ".id", ".kind"}, + "trg_ui_evidence_artifact_insert_running": {"NEW.kind != 'video'", "viewport.dpr", "source_commit", "ABS(NEW.width"}, + "trg_ui_evidence_artifact_attempt_quota": {"134217728", "SUM(size_bytes)"}, + "trg_ui_evidence_artifact_store_quota": {"2147483648", "SUM(size_bytes)"}, + } { + var triggerSQL string + if err := upgraded.db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master + WHERE type = 'trigger' AND name = ?`, name).Scan(&triggerSQL); err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if !strings.Contains(triggerSQL, fragment) { + t.Fatalf("trigger %s does not contain %q: %s", name, fragment, triggerSQL) + } + } + } +} + +// removeSchemaV119ForTestStatements restores a v118 database. Older migration +// tests call this through removeSchemaV118ForTestStatements so the downgrade +// chain always removes the newest schema first. +func removeSchemaV119ForTestStatements() []string { + return []string{ + `DROP TABLE ui_evidence_artifacts`, + `DROP TABLE ui_evidence_steps`, + `DROP TABLE ui_evidence_attempts`, + `DELETE FROM schema_migrations WHERE version = 119`, + } +} + +func storeUIEvidenceManifest(t *testing.T, runID, missionID, sessionID, + workspaceID, attemptID string, now time.Time, +) uievidence.Manifest { + t.Helper() + recipe := uievidence.CommandRecipe{ProtocolVersion: "command-runtime.v2", + Profile: "process", ExecutableName: "fixture-server.exe", + ExecutablePathSHA256: storeUIEvidenceDigest("path"), + ExecutableSHA256: storeUIEvidenceDigest("executable"), + CanonicalArgv: []string{"--port", "4173"}, WorkingDirectory: ".", + EnvironmentNames: []string{}, EnvironmentSHA256: storeUIEvidenceDigest("environment"), + TimeoutMilliseconds: 30000, Network: "disabled", Credentials: "none", + Purpose: "serve deterministic UI fixture"} + var err error + recipe, err = uievidence.SealCommandRecipe(recipe) + if err != nil { + t.Fatal(err) + } + manifest := uievidence.Manifest{AttemptID: attemptID, RunID: runID, + MissionID: missionID, SessionID: sessionID, WorkspaceID: workspaceID, + Source: uievidence.SourceBinding{RepositoryKind: "git", + Commit: "0123456789012345678901234567890123456789", Branch: "main", + DirtyDigest: storeUIEvidenceDigest("dirty"), + RootFingerprint: storeUIEvidenceDigest("root"), + IndexSHA256: storeUIEvidenceDigest("index"), + ManifestSHA256: storeUIEvidenceDigest("manifest")}, + Start: recipe, + Readiness: uievidence.Readiness{URL: "http://127.0.0.1:4173/health", + Method: "GET", ExpectedStatus: []int{200}, TimeoutMilliseconds: 30000, + IntervalMilliseconds: 100}, + Browser: uievidence.BrowserIdentity{Product: "Chromium", Version: "1.2.3", + ExecutableSHA256: storeUIEvidenceDigest("browser"), + DriverProtocol: uievidence.DriverProtocolVersion, Headless: true, + TemporaryProfile: true}, URL: "http://127.0.0.1:4173/health", Route: "/health", + Environment: uievidence.Environment{Viewport: uievidence.Viewport{ + Width: 1280, Height: 720, DPR: 1}, Locale: "en-US", + Theme: uievidence.ThemeLight, ReducedMotion: true}, + Fixture: uievidence.Fixture{Name: "store fixture", Seed: "42", + PageState: "ready", DataSHA256: storeUIEvidenceDigest("fixture"), + Deterministic: true, Synthetic: true}, + Steps: []uievidence.Step{{ID: "navigate", Kind: uievidence.StepNavigate}}, + Capture: uievidence.CapturePolicy{Screenshot: true, DOM: true, Accessibility: true, + Console: true, Network: true, Performance: true, MaskSelectors: []string{}}, + FailurePolicy: uievidence.FailurePolicy{FailOnConsoleError: true, + FailOnPageError: true, FailOnRequestError: true, FailOnHTTPStatus: true}, + CreatedAt: now} + sealed, err := uievidence.SealManifest(manifest) + if err != nil { + t.Fatal(err) + } + return sealed +} + +func storeUIEvidenceArtifact(t *testing.T, manifest uievidence.Manifest, + runID, stepID, artifactID string, kind uievidence.ArtifactKind, + viewport uievidence.Viewport, createdAt time.Time, +) uievidence.Artifact { + t.Helper() + mime := "application/json" + width, height := 0, 0 + if kind == uievidence.ArtifactScreenshot { + mime = "image/png" + width = int(math.Round(float64(viewport.Width) * viewport.DPR)) + height = int(math.Round(float64(viewport.Height) * viewport.DPR)) + } + content := []byte("bounded " + string(kind) + " evidence") + artifact, err := uievidence.SealArtifact(uievidence.ArtifactMetadata{ + ID: artifactID, AttemptID: manifest.AttemptID, RunID: runID, + StepID: stepID, Kind: kind, MIME: mime, Width: width, Height: height, + Viewport: viewport, SourceCommit: manifest.Source.Commit, + RetentionPolicy: uievidence.ArtifactRetentionRunHistory, Redacted: true, + Untrusted: true, CreatedAt: createdAt, + }, content) + if err != nil { + t.Fatal(err) + } + return artifact +} + +func storeUIEvidenceDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/uievidence/lifecycle.go b/internal/uievidence/lifecycle.go new file mode 100644 index 00000000..ce61d73d --- /dev/null +++ b/internal/uievidence/lifecycle.go @@ -0,0 +1,115 @@ +package uievidence + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + "unicode" + "unicode/utf8" + + "cyberagent-workbench/internal/redact" +) + +func NewAttempt(manifest Manifest, operationKey string, now time.Time) (Attempt, error) { + if err := manifest.Validate(); err != nil { + return Attempt{}, err + } + operationKey = strings.TrimSpace(operationKey) + if operationKey == "" || len([]byte(operationKey)) > 1024 || + !utf8.ValidString(operationKey) || strings.ContainsRune(operationKey, 0) || + redact.String(operationKey) != operationKey { + return Attempt{}, errors.New("UI evidence operation key is invalid") + } + now = now.UTC() + if now.IsZero() || now.Before(manifest.CreatedAt) { + return Attempt{}, errors.New("UI evidence attempt time is invalid") + } + operation, _ := OperationIdentity(manifest.RunID, operationKey) + attempt := Attempt{ProtocolVersion: AttemptProtocolVersion, Manifest: manifest, + OperationDigest: operation, RequestFingerprint: manifest.Fingerprint, + Status: StatusNotRun, FailureStage: FailureNone, Version: 1, + CreatedAt: now, UpdatedAt: now} + return attempt, attempt.Validate() +} + +func OperationIdentity(runID, operationKey string) (string, string) { + digest := sha256.Sum256([]byte(ProtocolVersion + "\x00" + runID + "\x00" + operationKey)) + encoded := hex.EncodeToString(digest[:]) + return encoded, "ui-attempt-" + encoded[:24] +} + +func StartAttempt(attempt Attempt, now time.Time) (Attempt, error) { + if err := attempt.Validate(); err != nil || attempt.Status != StatusNotRun { + return Attempt{}, errors.New("only not-run UI evidence can start") + } + now = now.UTC() + if now.Before(attempt.UpdatedAt) { + return Attempt{}, errors.New("UI evidence start time moved backwards") + } + attempt.Status = StatusRunning + attempt.StartedAt = &now + attempt.UpdatedAt = now + attempt.Version++ + return attempt, attempt.Validate() +} + +func CompleteAttempt(attempt Attempt, status Status, stage FailureStage, + code, message string, diagnostics DiagnosticsSummary, cleanup CleanupReceipt, + artifactCount int, artifactBytes int64, now time.Time, +) (Attempt, error) { + if err := attempt.Validate(); err != nil || attempt.Status != StatusRunning { + return Attempt{}, errors.New("only running UI evidence can complete") + } + if !status.Terminal() { + return Attempt{}, errors.New("UI evidence completion status is not terminal") + } + now = now.UTC() + if now.Before(attempt.UpdatedAt) { + return Attempt{}, errors.New("UI evidence completion time moved backwards") + } + attempt.Status = status + attempt.FailureStage = stage + attempt.FailureCode = sanitizeFailureText(code, 128, false) + attempt.FailureMessage = sanitizeFailureText(message, 2048, true) + attempt.Diagnostics = diagnostics + attempt.Cleanup = cleanup + attempt.ArtifactCount = artifactCount + attempt.ArtifactBytes = artifactBytes + attempt.CompletedAt = &now + attempt.UpdatedAt = now + attempt.Version++ + return attempt, attempt.Validate() +} + +func InterruptAttempt(attempt Attempt, artifactCount int, artifactBytes int64, + now time.Time, +) (Attempt, error) { + if attempt.Status != StatusRunning { + return Attempt{}, errors.New("only running UI evidence can be interrupted") + } + return CompleteAttempt(attempt, StatusInterrupted, FailureCleanup, + "runtime_restarted", "UI evidence owner restarted before cleanup could be proven", + attempt.Diagnostics, attempt.Cleanup, artifactCount, artifactBytes, now) +} + +func sanitizeFailureText(value string, maxBytes int, allowLines bool) string { + value = redact.String(strings.TrimSpace(value)) + if !utf8.ValidString(value) { + value = strings.ToValidUTF8(value, "�") + } + value = strings.Map(func(current rune) rune { + if current == 0 || unicode.IsControl(current) && + !(allowLines && (current == '\n' || current == '\r' || current == '\t')) { + return ' ' + } + return current + }, value) + value = strings.TrimSpace(value) + for len([]byte(value)) > maxBytes { + _, size := utf8.DecodeLastRuneInString(value) + value = value[:len(value)-size] + } + return strings.TrimSpace(value) +} diff --git a/internal/uievidence/model.go b/internal/uievidence/model.go new file mode 100644 index 00000000..34392781 --- /dev/null +++ b/internal/uievidence/model.go @@ -0,0 +1,940 @@ +// Package uievidence defines the durable, source-bound UI verification +// protocol. Browser content is untrusted evidence: none of these records grant +// process, network, credential, browser-profile, or verification authority. +package uievidence + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "net/netip" + "net/url" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/runner" +) + +const ( + ProtocolVersion = "ui-evidence.v1" + AttemptProtocolVersion = "ui-evidence-attempt.v1" + StepProtocolVersion = "ui-evidence-step.v1" + ArtifactProtocolVersion = "ui-evidence-artifact.v1" + DriverProtocolVersion = "restricted-cdp-ui-evidence.v1" + + MaxSteps = 128 + MaxMasks = 32 + MaxSelectorBytes = 2 * 1024 + MaxPageStateBytes = 8 * 1024 + MaxArtifactBytes = 32 * 1024 * 1024 + MaxAttemptArtifactBytes = 128 * 1024 * 1024 + MaxArtifactStoreBytes = 2 * 1024 * 1024 * 1024 + MaxScreenshotWidth = 7680 + MaxScreenshotHeight = 4320 +) + +type Status string + +const ( + StatusNotRun Status = "not_run" + StatusRunning Status = "running" + StatusPassed Status = "passed" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" + StatusTimedOut Status = "timed_out" + StatusInterrupted Status = "interrupted" +) + +func (s Status) Valid() bool { + switch s { + case StatusNotRun, StatusRunning, StatusPassed, StatusFailed, + StatusCancelled, StatusTimedOut, StatusInterrupted: + return true + default: + return false + } +} + +func (s Status) Terminal() bool { + return s == StatusPassed || s == StatusFailed || s == StatusCancelled || + s == StatusTimedOut || s == StatusInterrupted +} + +// Passed is deliberately narrower than "not failed". In particular not_run +// never becomes green verification evidence. +func (s Status) Passed() bool { return s == StatusPassed } + +type FailureStage string + +const ( + FailureNone FailureStage = "none" + FailureBuild FailureStage = "build" + FailureLaunch FailureStage = "launch" + FailureReadiness FailureStage = "readiness" + FailureNavigation FailureStage = "navigation" + FailureSelector FailureStage = "selector" + FailureAssertion FailureStage = "assertion" + FailureConsole FailureStage = "console" + FailureNetwork FailureStage = "network" + FailureCapture FailureStage = "capture" + FailureCleanup FailureStage = "cleanup" +) + +func (s FailureStage) Valid() bool { + switch s { + case FailureNone, FailureBuild, FailureLaunch, FailureReadiness, + FailureNavigation, FailureSelector, FailureAssertion, FailureConsole, + FailureNetwork, FailureCapture, FailureCleanup: + return true + default: + return false + } +} + +type StepKind string + +const ( + StepNavigate StepKind = "navigate" + StepClick StepKind = "click" + StepType StepKind = "type" + StepAssertPresent StepKind = "assert_present" + StepAssertAbsent StepKind = "assert_absent" + StepCapture StepKind = "capture" +) + +func (k StepKind) Valid() bool { + switch k { + case StepNavigate, StepClick, StepType, StepAssertPresent, + StepAssertAbsent, StepCapture: + return true + default: + return false + } +} + +type ArtifactKind string + +const ( + ArtifactScreenshot ArtifactKind = "screenshot" + ArtifactDOM ArtifactKind = "dom" + ArtifactAccessibility ArtifactKind = "accessibility" + ArtifactConsole ArtifactKind = "console" + ArtifactNetwork ArtifactKind = "network" + ArtifactPerformance ArtifactKind = "performance" + ArtifactVideo ArtifactKind = "video" +) + +func (k ArtifactKind) Valid() bool { + switch k { + case ArtifactScreenshot, ArtifactDOM, ArtifactAccessibility, + ArtifactConsole, ArtifactNetwork, ArtifactPerformance, ArtifactVideo: + return true + default: + return false + } +} + +type ArtifactRetentionPolicy string + +const ArtifactRetentionRunHistory ArtifactRetentionPolicy = "run_history" + +func (p ArtifactRetentionPolicy) Valid() bool { + return p == ArtifactRetentionRunHistory +} + +type Theme string + +const ( + ThemeLight Theme = "light" + ThemeDark Theme = "dark" +) + +func (t Theme) Valid() bool { return t == ThemeLight || t == ThemeDark } + +type SourceBinding struct { + RepositoryKind string `json:"repository_kind"` + Commit string `json:"commit"` + Branch string `json:"branch,omitempty"` + Dirty bool `json:"dirty"` + DirtyDigest string `json:"dirty_digest"` + RootFingerprint string `json:"root_fingerprint"` + IndexSHA256 string `json:"index_sha256"` + ManifestSHA256 string `json:"manifest_sha256"` +} + +func (s SourceBinding) Validate() error { + if s.RepositoryKind != "git" && s.RepositoryKind != "non_git" { + return errors.New("UI evidence repository kind is invalid") + } + if s.RepositoryKind == "git" { + if s.Commit == "unborn" || s.Commit == "non-git" || !validCommit(s.Commit) { + return errors.New("UI evidence source commit is invalid") + } + } else if s.Commit != "non-git" { + return errors.New("non-Git UI evidence source is inconsistent") + } + if len(s.Branch) > 255 || strings.ContainsRune(s.Branch, 0) || + !validDigest(s.DirtyDigest) || !validDigest(s.RootFingerprint) || + !validDigest(s.IndexSHA256) || !validDigest(s.ManifestSHA256) { + return errors.New("UI evidence source binding is invalid") + } + return nil +} + +// CommandRecipe is the persisted, secret-free projection of one exact +// command-runtime.v2 resolved spec. Host paths and environment values are +// represented only by hashes; canonical argv remains reviewable. +type CommandRecipe struct { + ProtocolVersion string `json:"protocol_version"` + Profile string `json:"profile"` + ExecutableName string `json:"executable_name"` + ExecutablePathSHA256 string `json:"executable_path_sha256"` + ExecutableSHA256 string `json:"executable_sha256"` + CanonicalArgv []string `json:"canonical_argv"` + WorkingDirectory string `json:"working_directory"` + EnvironmentNames []string `json:"environment_names"` + EnvironmentSHA256 string `json:"environment_sha256"` + TimeoutMilliseconds int64 `json:"timeout_milliseconds"` + Network string `json:"network"` + Credentials string `json:"credentials"` + Purpose string `json:"purpose"` + Fingerprint string `json:"fingerprint"` +} + +func CommandRecipeFromResolved(spec runner.CommandRuntimeResolvedSpec) (CommandRecipe, error) { + resolved, err := runner.NormalizeCommandRuntimeSpec(spec.Spec, spec.WorkspaceRoot) + if err != nil { + return CommandRecipe{}, err + } + if runner.CommandRuntimeSpecFingerprint(resolved) != + runner.CommandRuntimeSpecFingerprint(spec) { + return CommandRecipe{}, errors.New("UI evidence command recipe lost its resolved identity") + } + spec = resolved + names := make([]string, 0, len(spec.Spec.Environment)) + for _, entry := range spec.Spec.Environment { + names = append(names, entry.Name) + } + sort.Slice(names, func(i, j int) bool { + return strings.ToLower(names[i]) < strings.ToLower(names[j]) + }) + pathDigest := sha256.Sum256([]byte(filepath.Clean(spec.ExecutablePath))) + recipe := CommandRecipe{ + ProtocolVersion: runner.CommandRuntimeProtocolVersion, + Profile: string(spec.Spec.Profile), ExecutableName: filepath.Base(spec.ExecutablePath), + ExecutablePathSHA256: hex.EncodeToString(pathDigest[:]), + ExecutableSHA256: spec.ExecutableSHA256, + CanonicalArgv: append([]string(nil), spec.CanonicalArgv...), + WorkingDirectory: spec.Spec.WorkingDirectory, + EnvironmentNames: names, EnvironmentSHA256: spec.EnvironmentSHA256, + TimeoutMilliseconds: spec.Spec.TimeoutMilliseconds, + Network: string(spec.Spec.Network), Credentials: string(spec.Spec.Credentials), + Purpose: spec.Spec.Purpose, + } + recipe.Fingerprint = fingerprint(recipe) + if err := recipe.Validate(); err != nil { + return CommandRecipe{}, err + } + return recipe, nil +} + +func (r CommandRecipe) Validate() error { + if r.ProtocolVersion != runner.CommandRuntimeProtocolVersion || + (r.Profile != string(runner.CommandRuntimePowerShell) && + r.Profile != string(runner.CommandRuntimeBash) && + r.Profile != string(runner.CommandRuntimeProcess)) || + !validText(r.ExecutableName, 512, false) || + strings.ContainsAny(r.ExecutableName, `/\\`) || + !validDigest(r.ExecutablePathSHA256) || !validDigest(r.ExecutableSHA256) || + !validDigest(r.EnvironmentSHA256) || r.TimeoutMilliseconds < 1 || + r.TimeoutMilliseconds > int64((30*time.Minute)/time.Millisecond) || + r.Network != string(runner.CommandRuntimeNetworkDisabled) || + r.Credentials != string(runner.CommandRuntimeCredentialsNone) || + !validText(r.WorkingDirectory, 4096, false) || + !validText(r.Purpose, 1200, false) || redact.String(r.Purpose) != r.Purpose { + return errors.New("UI evidence command recipe is invalid") + } + if len(r.CanonicalArgv) == 0 || len(r.CanonicalArgv) > 128 || + len(r.EnvironmentNames) > 32 { + return errors.New("UI evidence command recipe bounds are invalid") + } + for _, value := range r.CanonicalArgv { + if !validText(value, 65536, true) || redact.String(value) != value { + return errors.New("UI evidence command recipe argv is invalid") + } + } + previous := "" + for _, value := range r.EnvironmentNames { + key := strings.ToLower(value) + if !validText(value, 128, false) || key <= previous { + return errors.New("UI evidence environment names are invalid") + } + previous = key + } + if r.Fingerprint != fingerprint(r) { + return errors.New("UI evidence command recipe fingerprint is invalid") + } + return nil +} + +func SealCommandRecipe(recipe CommandRecipe) (CommandRecipe, error) { + recipe.Fingerprint = fingerprint(recipe) + return recipe, recipe.Validate() +} + +type Readiness struct { + URL string `json:"url"` + Method string `json:"method"` + ExpectedStatus []int `json:"expected_status"` + TimeoutMilliseconds int64 `json:"timeout_milliseconds"` + IntervalMilliseconds int64 `json:"interval_milliseconds"` +} + +func (r Readiness) Validate() error { + if !validLoopbackHTTPURL(r.URL) || r.Method != "GET" || + r.TimeoutMilliseconds < 100 || r.TimeoutMilliseconds > 120000 || + r.IntervalMilliseconds < 10 || r.IntervalMilliseconds > 5000 || + len(r.ExpectedStatus) == 0 || len(r.ExpectedStatus) > 16 { + return errors.New("UI evidence readiness contract is invalid") + } + previous := 0 + for _, status := range r.ExpectedStatus { + if status < 100 || status > 599 || status <= previous { + return errors.New("UI evidence readiness statuses are invalid") + } + previous = status + } + return nil +} + +type Viewport struct { + Width int `json:"width"` + Height int `json:"height"` + DPR float64 `json:"dpr"` +} + +func (v Viewport) Validate() error { + if v.Width < 320 || v.Width > MaxScreenshotWidth || + v.Height < 240 || v.Height > MaxScreenshotHeight || + math.IsNaN(v.DPR) || math.IsInf(v.DPR, 0) || v.DPR < 0.5 || v.DPR > 4 { + return errors.New("UI evidence viewport is invalid") + } + if float64(v.Width)*v.DPR > MaxScreenshotWidth || + float64(v.Height)*v.DPR > MaxScreenshotHeight { + return errors.New("UI evidence viewport pixel surface exceeds the screenshot limit") + } + return nil +} + +type Environment struct { + Viewport Viewport `json:"viewport"` + Locale string `json:"locale"` + Theme Theme `json:"theme"` + ReducedMotion bool `json:"reduced_motion"` +} + +func (e Environment) Validate() error { + if err := e.Viewport.Validate(); err != nil { + return err + } + if !validLocale(e.Locale) || !e.Theme.Valid() { + return errors.New("UI evidence presentation environment is invalid") + } + return nil +} + +type Fixture struct { + Name string `json:"name"` + Seed string `json:"seed"` + PageState string `json:"page_state"` + DataSHA256 string `json:"data_sha256"` + Deterministic bool `json:"deterministic"` + Synthetic bool `json:"synthetic"` +} + +func (f Fixture) Validate() error { + if !validText(f.Name, 256, false) || !validText(f.Seed, 256, false) || + !validText(f.PageState, MaxPageStateBytes, true) || !validDigest(f.DataSHA256) || + !f.Deterministic || redact.String(f.Name+f.Seed+f.PageState) != f.Name+f.Seed+f.PageState { + return errors.New("UI evidence fixture is invalid") + } + return nil +} + +type Step struct { + ID string `json:"id"` + Kind StepKind `json:"kind"` + Selector string `json:"selector,omitempty"` + InputSHA256 string `json:"input_sha256,omitempty"` + CaptureAfter bool `json:"capture_after"` +} + +func (s Step) Validate() error { + if !validIdentity(s.ID) || !s.Kind.Valid() { + return errors.New("UI evidence step identity is invalid") + } + requiresSelector := s.Kind == StepClick || s.Kind == StepType || + s.Kind == StepAssertPresent || s.Kind == StepAssertAbsent + if requiresSelector != (s.Selector != "") || + (s.Selector != "" && (!validText(s.Selector, MaxSelectorBytes, false) || + redact.String(s.Selector) != s.Selector)) { + return errors.New("UI evidence step selector is invalid") + } + if s.Kind == StepType { + if !validDigest(s.InputSHA256) { + return errors.New("UI evidence typed input digest is invalid") + } + } else if s.InputSHA256 != "" { + return errors.New("UI evidence non-input step contains an input digest") + } + return nil +} + +type CapturePolicy struct { + Screenshot bool `json:"screenshot"` + DOM bool `json:"dom"` + Accessibility bool `json:"accessibility"` + Console bool `json:"console"` + Network bool `json:"network"` + Performance bool `json:"performance"` + Video bool `json:"video"` + MaskSelectors []string `json:"mask_selectors"` +} + +func (p CapturePolicy) Validate() error { + if !p.Screenshot && !p.DOM && !p.Accessibility && !p.Console && + !p.Network && !p.Performance && !p.Video { + return errors.New("UI evidence capture policy is empty") + } + if len(p.MaskSelectors) > MaxMasks { + return errors.New("UI evidence mask selector limit exceeded") + } + seen := map[string]struct{}{} + for _, selector := range p.MaskSelectors { + if !validText(selector, MaxSelectorBytes, false) || + redact.String(selector) != selector { + return errors.New("UI evidence mask selector is invalid") + } + if _, exists := seen[selector]; exists { + return errors.New("UI evidence mask selector is duplicated") + } + seen[selector] = struct{}{} + } + return nil +} + +type FailurePolicy struct { + FailOnConsoleError bool `json:"fail_on_console_error"` + FailOnPageError bool `json:"fail_on_page_error"` + FailOnRequestError bool `json:"fail_on_request_error"` + FailOnHTTPStatus bool `json:"fail_on_http_status"` +} + +func (p FailurePolicy) Validate() error { + if !p.FailOnConsoleError || !p.FailOnPageError || !p.FailOnRequestError || + !p.FailOnHTTPStatus { + return errors.New("UI evidence failure policy must fail closed") + } + return nil +} + +type BrowserIdentity struct { + Product string `json:"product"` + Version string `json:"version"` + ExecutableSHA256 string `json:"executable_sha256"` + DriverProtocol string `json:"driver_protocol"` + Headless bool `json:"headless"` + TemporaryProfile bool `json:"temporary_profile"` +} + +func (b BrowserIdentity) Validate() error { + if !validText(b.Product, 128, false) || !validText(b.Version, 128, false) || + !validDigest(b.ExecutableSHA256) || b.DriverProtocol != DriverProtocolVersion || + !b.TemporaryProfile { + return errors.New("UI evidence browser identity is invalid") + } + return nil +} + +type EvidenceAuthority struct { + ProcessStart bool `json:"process_start"` + NetworkAccess bool `json:"network_access"` + CredentialAccess bool `json:"credential_access"` + PersonalProfile bool `json:"personal_profile"` + RequestMutation bool `json:"request_mutation"` + VerificationPass bool `json:"verification_pass"` +} + +func (a EvidenceAuthority) Validate() error { + if a.ProcessStart || a.NetworkAccess || a.CredentialAccess || + a.PersonalProfile || a.RequestMutation || a.VerificationPass { + return errors.New("UI evidence cannot carry authority") + } + return nil +} + +type Manifest struct { + ProtocolVersion string `json:"protocol_version"` + AttemptID string `json:"attempt_id"` + RunID string `json:"run_id"` + MissionID string `json:"mission_id"` + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id"` + Source SourceBinding `json:"source"` + Build *CommandRecipe `json:"build,omitempty"` + Start CommandRecipe `json:"start"` + Readiness Readiness `json:"readiness"` + Browser BrowserIdentity `json:"browser"` + URL string `json:"url"` + Route string `json:"route"` + Environment Environment `json:"environment"` + Fixture Fixture `json:"fixture"` + Steps []Step `json:"steps"` + Capture CapturePolicy `json:"capture"` + FailurePolicy FailurePolicy `json:"failure_policy"` + Authority EvidenceAuthority `json:"authority"` + CreatedAt time.Time `json:"created_at"` + Fingerprint string `json:"fingerprint"` +} + +func (m Manifest) Validate() error { + if m.ProtocolVersion != ProtocolVersion || !validIdentity(m.AttemptID) || + !validIdentity(m.RunID) || !validIdentity(m.MissionID) || + !validIdentity(m.SessionID) || !validIdentity(m.WorkspaceID) || + m.CreatedAt.IsZero() || m.Fingerprint != fingerprint(m) { + return errors.New("UI evidence manifest identity is invalid") + } + if err := m.Source.Validate(); err != nil { + return err + } + if m.Build != nil { + if err := m.Build.Validate(); err != nil { + return err + } + } + if err := m.Start.Validate(); err != nil { + return err + } + if err := m.Readiness.Validate(); err != nil { + return err + } + if err := m.Browser.Validate(); err != nil { + return err + } + if !validLoopbackHTTPURL(m.URL) || !sameLoopbackOrigin(m.URL, m.Readiness.URL) || + !validRoute(m.Route) || routeForURL(m.URL) != m.Route { + return errors.New("UI evidence target is invalid") + } + if err := m.Environment.Validate(); err != nil { + return err + } + if err := m.Fixture.Validate(); err != nil { + return err + } + if err := m.Capture.Validate(); err != nil { + return err + } + if !m.Capture.Screenshot || !m.Capture.DOM || !m.Capture.Accessibility || + !m.Capture.Console || !m.Capture.Network || !m.Capture.Performance || + m.Capture.Video { + return errors.New("UI evidence v1 requires every core capture and rejects video") + } + if err := m.FailurePolicy.Validate(); err != nil { + return err + } + if err := m.Authority.Validate(); err != nil { + return err + } + if len(m.Steps) == 0 || len(m.Steps) > MaxSteps || m.Steps[0].Kind != StepNavigate { + return errors.New("UI evidence steps must begin with navigation") + } + seen := map[string]struct{}{} + for _, step := range m.Steps { + if err := step.Validate(); err != nil { + return err + } + if _, exists := seen[step.ID]; exists { + return errors.New("UI evidence step identity is duplicated") + } + seen[step.ID] = struct{}{} + } + return nil +} + +func SealManifest(manifest Manifest) (Manifest, error) { + manifest.ProtocolVersion = ProtocolVersion + manifest.CreatedAt = manifest.CreatedAt.UTC() + manifest.Fingerprint = fingerprint(manifest) + return manifest, manifest.Validate() +} + +type DiagnosticsSummary struct { + ConsoleWarnings int `json:"console_warnings"` + ConsoleErrors int `json:"console_errors"` + PageErrors int `json:"page_errors"` + FailedRequests int `json:"failed_requests"` + HTTPFailures int `json:"http_failures"` + AllowedRequests int `json:"allowed_requests"` + BlockedRequests int `json:"blocked_requests"` +} + +func (d DiagnosticsSummary) Validate() error { + for _, value := range []int{d.ConsoleWarnings, d.ConsoleErrors, d.PageErrors, + d.FailedRequests, d.HTTPFailures, d.AllowedRequests, d.BlockedRequests} { + if value < 0 || value > 1_000_000 { + return errors.New("UI evidence diagnostic count is invalid") + } + } + return nil +} + +type CleanupReceipt struct { + BrowserTreeReaped bool `json:"browser_tree_reaped"` + ApplicationTreeReaped bool `json:"application_tree_reaped"` + ProfileRemoved bool `json:"profile_removed"` + NetworkReleased bool `json:"network_released"` + PortReleased bool `json:"port_released"` +} + +func (c CleanupReceipt) Complete() bool { + return c.BrowserTreeReaped && c.ApplicationTreeReaped && c.ProfileRemoved && + c.NetworkReleased && c.PortReleased +} + +type Attempt struct { + ProtocolVersion string `json:"protocol_version"` + Manifest Manifest `json:"manifest"` + OperationDigest string `json:"operation_digest"` + RequestFingerprint string `json:"request_fingerprint"` + Status Status `json:"status"` + FailureStage FailureStage `json:"failure_stage"` + FailureCode string `json:"failure_code,omitempty"` + FailureMessage string `json:"failure_message,omitempty"` + Diagnostics DiagnosticsSummary `json:"diagnostics"` + Cleanup CleanupReceipt `json:"cleanup"` + ArtifactCount int `json:"artifact_count"` + ArtifactBytes int64 `json:"artifact_bytes"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (a Attempt) Validate() error { + if a.ProtocolVersion != AttemptProtocolVersion || a.Manifest.Validate() != nil || + !validDigest(a.OperationDigest) || !validDigest(a.RequestFingerprint) || + a.RequestFingerprint != a.Manifest.Fingerprint || + !a.Status.Valid() || !a.FailureStage.Valid() || a.Version < 1 || + a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() || a.UpdatedAt.Before(a.CreatedAt) || + a.ArtifactCount < 0 || a.ArtifactCount > 10000 || a.ArtifactBytes < 0 || + a.ArtifactBytes > MaxAttemptArtifactBytes || + (a.ArtifactCount == 0) != (a.ArtifactBytes == 0) || + !validOptionalText(a.FailureCode, 128, false) || + !validOptionalText(a.FailureMessage, 2048, true) || + redact.String(a.FailureMessage) != a.FailureMessage || a.Diagnostics.Validate() != nil { + return errors.New("UI evidence attempt is invalid") + } + if a.Status == StatusNotRun { + if a.Version != 1 || a.StartedAt != nil || a.CompletedAt != nil || + a.FailureStage != FailureNone || + a.FailureCode != "" || a.FailureMessage != "" || + a.Diagnostics != (DiagnosticsSummary{}) || a.Cleanup != (CleanupReceipt{}) || + a.ArtifactCount != 0 || a.ArtifactBytes != 0 { + return errors.New("not-run UI evidence contains execution state") + } + return nil + } + if a.StartedAt == nil || a.StartedAt.IsZero() || a.StartedAt.Before(a.CreatedAt) { + return errors.New("executed UI evidence is missing its start time") + } + if a.Status == StatusRunning { + if a.Version != 2 || a.CompletedAt != nil || a.FailureStage != FailureNone || + a.FailureCode != "" || a.FailureMessage != "" || + a.Diagnostics != (DiagnosticsSummary{}) || a.Cleanup != (CleanupReceipt{}) || + a.ArtifactCount != 0 || a.ArtifactBytes != 0 { + return errors.New("running UI evidence contains terminal state") + } + return nil + } + if !a.Status.Terminal() || a.Version != 3 || a.CompletedAt == nil || a.CompletedAt.IsZero() || + a.CompletedAt.Before(*a.StartedAt) || a.UpdatedAt.Before(*a.CompletedAt) { + return errors.New("terminal UI evidence timing is invalid") + } + if a.Status == StatusPassed { + if a.FailureStage != FailureNone || a.FailureCode != "" || + a.FailureMessage != "" || !a.Cleanup.Complete() || + a.ArtifactCount < 1 || a.ArtifactBytes < 1 || + a.Diagnostics.ConsoleErrors != 0 || a.Diagnostics.PageErrors != 0 || + a.Diagnostics.FailedRequests != 0 || a.Diagnostics.HTTPFailures != 0 || + a.Diagnostics.BlockedRequests != 0 { + return errors.New("passed UI evidence is not clean and fail-closed") + } + } else if a.FailureStage == FailureNone || a.FailureCode == "" { + return errors.New("unsuccessful UI evidence lacks a failure classification") + } + return nil +} + +type StepReceipt struct { + ProtocolVersion string `json:"protocol_version"` + AttemptID string `json:"attempt_id"` + StepID string `json:"step_id"` + Sequence int `json:"sequence"` + Kind StepKind `json:"kind"` + Status Status `json:"status"` + FailureStage FailureStage `json:"failure_stage"` + Message string `json:"message,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` + Fingerprint string `json:"fingerprint"` +} + +func (r StepReceipt) Validate() error { + if r.ProtocolVersion != StepProtocolVersion || !validIdentity(r.AttemptID) || + !validIdentity(r.StepID) || r.Sequence < 1 || r.Sequence > MaxSteps || + !r.Kind.Valid() || (r.Status != StatusPassed && r.Status != StatusFailed && + r.Status != StatusCancelled && r.Status != StatusTimedOut) || + !r.FailureStage.Valid() || !validOptionalText(r.Message, 2048, true) || + redact.String(r.Message) != r.Message || r.StartedAt.IsZero() || + r.CompletedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || + r.Fingerprint != fingerprint(r) { + return errors.New("UI evidence step receipt is invalid") + } + if (r.Status == StatusPassed) != (r.FailureStage == FailureNone) { + return errors.New("UI evidence step outcome is inconsistent") + } + return nil +} + +func SealStepReceipt(receipt StepReceipt) (StepReceipt, error) { + receipt.ProtocolVersion = StepProtocolVersion + receipt.StartedAt = receipt.StartedAt.UTC() + receipt.CompletedAt = receipt.CompletedAt.UTC() + receipt.Fingerprint = fingerprint(receipt) + return receipt, receipt.Validate() +} + +type ArtifactMetadata struct { + ProtocolVersion string `json:"protocol_version"` + ID string `json:"id"` + AttemptID string `json:"attempt_id"` + RunID string `json:"run_id"` + StepID string `json:"step_id"` + Kind ArtifactKind `json:"kind"` + MIME string `json:"mime"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Viewport Viewport `json:"viewport"` + SourceCommit string `json:"source_commit"` + RetentionPolicy ArtifactRetentionPolicy `json:"retention_policy"` + Redacted bool `json:"redacted"` + Untrusted bool `json:"untrusted"` + CreatedAt time.Time `json:"created_at"` + Fingerprint string `json:"fingerprint"` +} + +func (m ArtifactMetadata) Validate() error { + if m.ProtocolVersion != ArtifactProtocolVersion || !validIdentity(m.ID) || + !validIdentity(m.AttemptID) || !validIdentity(m.RunID) || + !validIdentity(m.StepID) || !m.Kind.Valid() || m.Kind == ArtifactVideo || + !validText(m.MIME, 128, false) || !validDigest(m.SHA256) || + m.Bytes < 1 || m.Bytes > MaxArtifactBytes || m.CreatedAt.IsZero() || + !m.Untrusted || m.Fingerprint != fingerprint(m) || m.Viewport.Validate() != nil || + !validCommit(m.SourceCommit) || !m.RetentionPolicy.Valid() { + return errors.New("UI evidence artifact metadata is invalid") + } + if m.Kind == ArtifactScreenshot { + if m.MIME != "image/png" || m.Width < 1 || m.Height < 1 || + m.Width > MaxScreenshotWidth || m.Height > MaxScreenshotHeight || + math.Abs(float64(m.Width)-float64(m.Viewport.Width)*m.Viewport.DPR) > 1 || + math.Abs(float64(m.Height)-float64(m.Viewport.Height)*m.Viewport.DPR) > 1 { + return errors.New("UI evidence screenshot metadata is invalid") + } + } else if m.Width != 0 || m.Height != 0 { + return errors.New("non-image UI evidence artifact has dimensions") + } + return nil +} + +type Artifact struct { + Metadata ArtifactMetadata + Content []byte +} + +type ListFilter struct { + RunID string + Status Status + Limit int +} + +func (f ListFilter) Validate() error { + if f.Limit < 0 || f.Limit > 500 || + (f.RunID != "" && !validIdentity(f.RunID)) || + (f.Status != "" && !f.Status.Valid()) { + return errors.New("UI evidence list filter is invalid") + } + return nil +} + +func (a Artifact) Validate() error { + if err := a.Metadata.Validate(); err != nil { + return err + } + if int64(len(a.Content)) != a.Metadata.Bytes { + return errors.New("UI evidence artifact byte count is inconsistent") + } + digest := sha256.Sum256(a.Content) + if hex.EncodeToString(digest[:]) != a.Metadata.SHA256 { + return errors.New("UI evidence artifact integrity check failed") + } + return nil +} + +func SealArtifact(metadata ArtifactMetadata, content []byte) (Artifact, error) { + digest := sha256.Sum256(content) + metadata.ProtocolVersion = ArtifactProtocolVersion + metadata.CreatedAt = metadata.CreatedAt.UTC() + metadata.Bytes = int64(len(content)) + metadata.SHA256 = hex.EncodeToString(digest[:]) + metadata.Fingerprint = fingerprint(metadata) + artifact := Artifact{Metadata: metadata, Content: append([]byte(nil), content...)} + return artifact, artifact.Validate() +} + +func fingerprint(value any) string { + raw, err := json.Marshal(value) + if err != nil { + return "" + } + var canonical any + if json.Unmarshal(raw, &canonical) != nil { + return "" + } + if object, ok := canonical.(map[string]any); ok { + delete(object, "fingerprint") + } + raw, err = json.Marshal(canonical) + if err != nil { + return "" + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +func validDigest(value string) bool { + if len(value) != sha256.Size*2 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func validCommit(value string) bool { + if value == "unborn" || value == "non-git" { + return true + } + if len(value) != 40 && len(value) != 64 { + return false + } + _, err := hex.DecodeString(value) + return err == nil && value == strings.ToLower(value) +} + +func validIdentity(value string) bool { + return value == strings.TrimSpace(value) && domain.ValidAgentID(value) +} + +func validOptionalText(value string, maxBytes int, lines bool) bool { + return value == "" || validText(value, maxBytes, lines) +} + +func validText(value string, maxBytes int, lines bool) bool { + if value == "" || len([]byte(value)) > maxBytes || !utf8.ValidString(value) || + strings.ContainsRune(value, 0) || value != strings.TrimSpace(value) { + return false + } + if !lines && strings.ContainsAny(value, "\r\n") { + return false + } + return true +} + +func validLocale(value string) bool { + if !validText(value, 64, false) { + return false + } + parts := strings.Split(value, "-") + if len(parts) < 1 || len(parts) > 3 || len(parts[0]) < 2 || len(parts[0]) > 8 { + return false + } + for _, part := range parts { + for _, current := range part { + if current < 'A' || current > 'Z' { + if current < 'a' || current > 'z' { + if current < '0' || current > '9' { + return false + } + } + } + } + } + return true +} + +func validRoute(value string) bool { + return validText(value, 4096, false) && strings.HasPrefix(value, "/") && + !strings.HasPrefix(value, "//") +} + +func validLoopbackHTTPURL(value string) bool { + if len(value) > 4096 || strings.ContainsAny(value, "\r\n\t") { + return false + } + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path == "" { + return false + } + address, err := netip.ParseAddr(parsed.Hostname()) + if err != nil || !address.Unmap().IsLoopback() { + return false + } + port, err := strconv.Atoi(parsed.Port()) + return err == nil && port >= 1 && port <= 65535 +} + +func sameLoopbackOrigin(left, right string) bool { + leftURL, leftErr := url.Parse(left) + rightURL, rightErr := url.Parse(right) + return leftErr == nil && rightErr == nil && leftURL.Scheme == rightURL.Scheme && + strings.EqualFold(leftURL.Host, rightURL.Host) +} + +func routeForURL(value string) string { + parsed, err := url.Parse(value) + if err != nil || parsed.EscapedPath() == "" { + return "" + } + return parsed.EscapedPath() +} + +func InputSHA256(value string) (string, error) { + if !validText(value, 64*1024, true) || redact.String(value) != value { + return "", fmt.Errorf("UI evidence input is invalid or contains secret-like material") + } + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]), nil +} diff --git a/internal/uievidence/model_test.go b/internal/uievidence/model_test.go new file mode 100644 index 00000000..6fda9444 --- /dev/null +++ b/internal/uievidence/model_test.go @@ -0,0 +1,240 @@ +package uievidence + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + "time" +) + +func TestStatusNotRunNeverPasses(t *testing.T) { + for _, status := range []Status{StatusNotRun, StatusRunning, StatusFailed, + StatusCancelled, StatusTimedOut, StatusInterrupted} { + if status.Passed() { + t.Fatalf("status %q unexpectedly maps to passed", status) + } + } + if !StatusPassed.Passed() { + t.Fatal("passed status did not map to passed") + } +} + +func TestSealManifestBindsSourceRecipeRuntimeAndPresentation(t *testing.T) { + manifest := validManifest(t) + sealed, err := SealManifest(manifest) + if err != nil { + t.Fatal(err) + } + if sealed.Fingerprint == "" { + t.Fatal("manifest fingerprint is empty") + } + + changed := sealed + changed.Environment.Viewport.Width++ + if changed.Validate() == nil { + t.Fatal("mutated viewport retained a valid fingerprint") + } + changed = sealed + changed.Source.DirtyDigest = digest("changed") + if changed.Validate() == nil { + t.Fatal("mutated source retained a valid fingerprint") + } +} + +func TestManifestV1RequiresCoreCapturesAndRejectsVideo(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*CapturePolicy) + }{ + {name: "screenshot", mutate: func(value *CapturePolicy) { value.Screenshot = false }}, + {name: "DOM", mutate: func(value *CapturePolicy) { value.DOM = false }}, + {name: "accessibility", mutate: func(value *CapturePolicy) { value.Accessibility = false }}, + {name: "console", mutate: func(value *CapturePolicy) { value.Console = false }}, + {name: "network", mutate: func(value *CapturePolicy) { value.Network = false }}, + {name: "performance", mutate: func(value *CapturePolicy) { value.Performance = false }}, + {name: "video", mutate: func(value *CapturePolicy) { value.Video = true }}, + } { + t.Run(test.name, func(t *testing.T) { + manifest := validManifest(t) + test.mutate(&manifest.Capture) + if _, err := SealManifest(manifest); err == nil { + t.Fatal("incomplete UI evidence v1 capture policy was accepted") + } + }) + } +} + +func TestManifestRejectsPseudoCommitsForGitSources(t *testing.T) { + for _, commit := range []string{"unborn", "non-git"} { + manifest := validManifest(t) + manifest.Source.Commit = commit + if _, err := SealManifest(manifest); err == nil { + t.Fatalf("Git source accepted pseudo commit %q", commit) + } + } +} + +func TestArtifactV1RejectsReservedVideoKind(t *testing.T) { + _, err := SealArtifact(ArtifactMetadata{ + ID: "ui-artifact-video", AttemptID: "ui-attempt-test", RunID: "run-test", + StepID: "capture", Kind: ArtifactVideo, MIME: "video/webm", + Viewport: Viewport{Width: 1280, Height: 720, DPR: 1}, + SourceCommit: "0123456789012345678901234567890123456789", + RetentionPolicy: ArtifactRetentionRunHistory, Untrusted: true, + CreatedAt: time.Now().UTC(), + }, []byte("reserved")) + if err == nil { + t.Fatal("ui-evidence.v1 accepted reserved video content") + } +} + +func TestViewportRejectsPixelSurfaceOutsideScreenshotLimits(t *testing.T) { + for _, viewport := range []Viewport{ + {Width: 7680, Height: 1080, DPR: 2}, + {Width: 1920, Height: 4320, DPR: 1.25}, + } { + if err := viewport.Validate(); err == nil { + t.Fatalf("oversized viewport pixel surface was accepted: %+v", viewport) + } + } +} + +func TestScreenshotDimensionsMustMatchViewportPixelSurface(t *testing.T) { + _, err := SealArtifact(ArtifactMetadata{ + ID: "ui-artifact-screenshot", AttemptID: "ui-attempt-test", RunID: "run-test", + StepID: "capture", Kind: ArtifactScreenshot, MIME: "image/png", + Width: 1, Height: 1, Viewport: Viewport{Width: 1280, Height: 720, DPR: 1.5}, + SourceCommit: "0123456789012345678901234567890123456789", + RetentionPolicy: ArtifactRetentionRunHistory, Redacted: true, Untrusted: true, + CreatedAt: time.Now().UTC(), + }, []byte("not a production screenshot")) + if err == nil { + t.Fatal("screenshot dimensions outside the manifest pixel surface were accepted") + } +} + +func TestAttemptRequiresCleanDiagnosticsAndCleanupToPass(t *testing.T) { + manifest, err := SealManifest(validManifest(t)) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + started := now.Add(time.Second) + completed := started.Add(time.Second) + attempt := Attempt{ProtocolVersion: AttemptProtocolVersion, Manifest: manifest, + OperationDigest: digest("operation"), RequestFingerprint: manifest.Fingerprint, + Status: StatusPassed, FailureStage: FailureNone, + Cleanup: CleanupReceipt{BrowserTreeReaped: true, ApplicationTreeReaped: true, + ProfileRemoved: true, NetworkReleased: true, PortReleased: true}, + ArtifactCount: 1, ArtifactBytes: 1, + Version: 3, CreatedAt: now, StartedAt: &started, CompletedAt: &completed, + UpdatedAt: completed} + if err := attempt.Validate(); err != nil { + t.Fatal(err) + } + wrongVersion := attempt + wrongVersion.Version = 2 + if wrongVersion.Validate() == nil { + t.Fatal("terminal UI evidence accepted a non-terminal lifecycle version") + } + for _, test := range []struct { + name string + diagnostics DiagnosticsSummary + }{ + {name: "console error", diagnostics: DiagnosticsSummary{ConsoleErrors: 1}}, + {name: "page error", diagnostics: DiagnosticsSummary{PageErrors: 1}}, + {name: "failed request", diagnostics: DiagnosticsSummary{FailedRequests: 1}}, + {name: "HTTP failure", diagnostics: DiagnosticsSummary{HTTPFailures: 1}}, + {name: "blocked request", diagnostics: DiagnosticsSummary{BlockedRequests: 1}}, + } { + t.Run(test.name, func(t *testing.T) { + changed := attempt + changed.Diagnostics = test.diagnostics + if changed.Validate() == nil { + t.Fatal("unclean diagnostics unexpectedly produced passing evidence") + } + }) + } + withoutArtifacts := attempt + withoutArtifacts.ArtifactCount = 0 + withoutArtifacts.ArtifactBytes = 0 + if withoutArtifacts.Validate() == nil { + t.Fatal("passing evidence without an artifact was accepted") + } +} + +func TestNotRunAttemptRejectsExecutionResidue(t *testing.T) { + manifest, err := SealManifest(validManifest(t)) + if err != nil { + t.Fatal(err) + } + attempt, err := NewAttempt(manifest, "operation", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + wrongVersion := attempt + wrongVersion.Version = 2 + if wrongVersion.Validate() == nil { + t.Fatal("not-run UI evidence accepted an executed lifecycle version") + } + for _, mutate := range []func(*Attempt){ + func(value *Attempt) { value.Diagnostics.ConsoleWarnings = 1 }, + func(value *Attempt) { value.Cleanup.ProfileRemoved = true }, + func(value *Attempt) { value.ArtifactCount, value.ArtifactBytes = 1, 1 }, + } { + changed := attempt + mutate(&changed) + if changed.Validate() == nil { + t.Fatal("not-run evidence accepted execution residue") + } + } +} + +func TestInputDigestRejectsSecretLikeValues(t *testing.T) { + if _, err := InputSHA256("token=abcdefghijklmnopqrstuvwxyz1234567890"); err == nil { + t.Fatal("secret-like input was accepted") + } + want := digest("fixture value") + if got, err := InputSHA256("fixture value"); err != nil || got != want { + t.Fatalf("input digest=%q err=%v", got, err) + } +} + +func validManifest(t *testing.T) Manifest { + t.Helper() + recipe := CommandRecipe{ProtocolVersion: "command-runtime.v2", Profile: "process", + ExecutableName: "fixture.exe", ExecutablePathSHA256: digest("path"), + ExecutableSHA256: digest("exe"), CanonicalArgv: []string{"--serve"}, + WorkingDirectory: ".", EnvironmentNames: []string{}, + EnvironmentSHA256: digest("env"), TimeoutMilliseconds: 30000, + Network: "disabled", Credentials: "none", Purpose: "serve fixture"} + recipe.Fingerprint = fingerprint(recipe) + return Manifest{AttemptID: "ui_attempt_test", RunID: "run_test", + MissionID: "mission_test", SessionID: "session_test", WorkspaceID: "workspace_test", + Source: SourceBinding{RepositoryKind: "git", Commit: "0123456789012345678901234567890123456789", + Branch: "main", DirtyDigest: digest("dirty"), RootFingerprint: digest("root"), + IndexSHA256: digest("index"), ManifestSHA256: digest("manifest")}, + Start: recipe, + Readiness: Readiness{URL: "http://127.0.0.1:4173/health", Method: "GET", + ExpectedStatus: []int{200}, TimeoutMilliseconds: 30000, IntervalMilliseconds: 100}, + Browser: BrowserIdentity{Product: "Chromium", Version: "1.2.3", + ExecutableSHA256: digest("browser"), DriverProtocol: DriverProtocolVersion, + Headless: true, TemporaryProfile: true}, + URL: "http://127.0.0.1:4173/health", Route: "/health", + Environment: Environment{Viewport: Viewport{Width: 1280, Height: 720, DPR: 1}, + Locale: "en-US", Theme: ThemeLight, ReducedMotion: true}, + Fixture: Fixture{Name: "regression", Seed: "42", PageState: "ready", + DataSHA256: digest("fixture"), Deterministic: true, Synthetic: true}, + Steps: []Step{{ID: "navigate", Kind: StepNavigate}, + {ID: "assert", Kind: StepAssertPresent, Selector: "main"}}, + Capture: CapturePolicy{Screenshot: true, DOM: true, Accessibility: true, + Console: true, Network: true, Performance: true, MaskSelectors: []string{}}, + FailurePolicy: FailurePolicy{FailOnConsoleError: true, FailOnPageError: true, + FailOnRequestError: true, FailOnHTTPStatus: true}, + CreatedAt: time.Now().UTC()} +} + +func digest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/uievidence/source.go b/internal/uievidence/source.go new file mode 100644 index 00000000..8ffeb547 --- /dev/null +++ b/internal/uievidence/source.go @@ -0,0 +1,177 @@ +package uievidence + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "cyberagent-workbench/internal/workspacecheckpoint" + "cyberagent-workbench/internal/workspaceidentity" +) + +const maxGitStatusBytes = 8 * 1024 * 1024 + +// BindSource turns an already validated workspace checkpoint into the source +// identity persisted by ui-evidence.v1. Git status bytes and host paths are +// hashed but never retained in the returned record. +func BindSource(ctx context.Context, workspaceRoot string, + snapshot workspacecheckpoint.Snapshot, +) (SourceBinding, error) { + if ctx == nil { + return SourceBinding{}, context.Canceled + } + if err := ctx.Err(); err != nil { + return SourceBinding{}, err + } + if err := snapshot.Validate(); err != nil { + return SourceBinding{}, err + } + workspaceRoot = strings.TrimSpace(workspaceRoot) + if workspaceRoot == "" { + return SourceBinding{}, errors.New("UI evidence Workspace root is invalid") + } + root, err := filepath.Abs(workspaceRoot) + if err != nil || !filepath.IsAbs(root) { + return SourceBinding{}, errors.New("UI evidence Workspace root is invalid") + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return SourceBinding{}, errors.New("UI evidence Workspace root is unavailable") + } + rootFingerprint, err := workspaceRootFingerprint(root) + if err != nil || rootFingerprint != snapshot.Checkpoint.RootFingerprint { + return SourceBinding{}, errors.New("UI evidence Workspace identity changed after checkpoint capture") + } + + binding := SourceBinding{RepositoryKind: "git", + Commit: snapshot.Checkpoint.BaseCommit, Branch: snapshot.Checkpoint.Branch, + RootFingerprint: snapshot.Checkpoint.RootFingerprint, + IndexSHA256: snapshot.Checkpoint.IndexSHA256} + binding.ManifestSHA256, err = sourceManifestSHA256(snapshot.Entries) + if err != nil { + return SourceBinding{}, err + } + status := []byte(nil) + if snapshot.Checkpoint.BaseCommit == "non-git" { + binding.RepositoryKind = "non_git" + } else { + status, err = readGitStatus(ctx, root) + if err != nil { + return SourceBinding{}, err + } + binding.Dirty = len(status) != 0 + } + payload := struct { + RepositoryKind string `json:"repository_kind"` + Commit string `json:"commit"` + Branch string `json:"branch"` + IndexSHA256 string `json:"index_sha256"` + ManifestSHA256 string `json:"manifest_sha256"` + StatusSHA256 string `json:"status_sha256"` + }{RepositoryKind: binding.RepositoryKind, Commit: binding.Commit, + Branch: binding.Branch, IndexSHA256: binding.IndexSHA256, + ManifestSHA256: binding.ManifestSHA256, StatusSHA256: bytesSHA256(status)} + raw, err := json.Marshal(payload) + if err != nil { + return SourceBinding{}, err + } + binding.DirtyDigest = bytesSHA256(raw) + return binding, binding.Validate() +} + +// sourceManifestSHA256 binds every tracked and non-ignored untracked entry, +// including its worktree content digest, while deliberately excluding ignored +// build/cache directories. The full checkpoint still records those directories +// as excluded metadata, but their creation during a reviewed build must not be +// confused with source drift. +func sourceManifestSHA256(entries []workspacecheckpoint.Entry) (string, error) { + sourceEntries := make([]workspacecheckpoint.Entry, 0, len(entries)) + for _, entry := range entries { + if entry.State != workspacecheckpoint.StateIgnored { + sourceEntries = append(sourceEntries, entry) + } + } + raw, err := json.Marshal(sourceEntries) + if err != nil { + return "", err + } + return bytesSHA256(raw), nil +} + +func readGitStatus(ctx context.Context, root string) ([]byte, error) { + git, err := exec.LookPath("git") + if err != nil { + return nil, errors.New("UI evidence source binding requires Git") + } + command := exec.CommandContext(ctx, git, "-C", root, "--no-optional-locks", + "-c", "core.hooksPath=", "-c", "core.fsmonitor=false", "status", + "--porcelain=v1", "-z", "--untracked-files=all") + command.Dir = root + command.Env = gitStatusEnvironment() + var stdout, stderr boundedBuffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("capture UI evidence Git status: %w", err) + } + if stdout.exceeded || stderr.exceeded { + return nil, errors.New("UI evidence Git status exceeds its bound") + } + return append([]byte(nil), stdout.Bytes()...), nil +} + +type boundedBuffer struct { + bytes.Buffer + exceeded bool +} + +func (b *boundedBuffer) Write(value []byte) (int, error) { + remaining := maxGitStatusBytes - b.Len() + if remaining <= 0 { + b.exceeded = true + return len(value), nil + } + if len(value) > remaining { + _, _ = b.Buffer.Write(value[:remaining]) + b.exceeded = true + return len(value), nil + } + return b.Buffer.Write(value) +} + +func gitStatusEnvironment() []string { + names := []string{"PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP"} + values := make([]string, 0, len(names)+8) + for _, name := range names { + if value, ok := os.LookupEnv(name); ok { + values = append(values, name+"="+value) + } + } + return append(values, "LANG=C", "LC_ALL=C", "GIT_TERMINAL_PROMPT=0", + "GCM_INTERACTIVE=never", "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_PAGER=cat") +} + +func workspaceRootFingerprint(root string) (string, error) { + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("UI evidence Workspace root is not a direct directory") + } + return workspaceidentity.Fingerprint(filepath.Clean(root)) +} + +func bytesSHA256(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/uievidence/source_test.go b/internal/uievidence/source_test.go new file mode 100644 index 00000000..b89b1788 --- /dev/null +++ b/internal/uievidence/source_test.go @@ -0,0 +1,75 @@ +package uievidence + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "cyberagent-workbench/internal/workspacecheckpoint" +) + +func TestBindSourceIgnoresBuildOutputsButDetectsSourceChanges(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git unavailable") + } + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("dist/\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("fixed source\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"init", "-q"}, + {"config", "user.email", "ui-evidence@example.invalid"}, + {"config", "user.name", "UI Evidence"}, {"add", "."}, {"commit", "-m", "fixture"}} { + command := exec.Command("git", append([]string{"-C", root}, arguments...)...) + command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", arguments, err, output) + } + } + + initial := bindTestSource(t, root, "source-initial", time.Now().UTC()) + if err := os.Mkdir(filepath.Join(root, "dist"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "dist", "bundle.js"), + []byte("generated output\n"), 0o600); err != nil { + t.Fatal(err) + } + afterBuild := bindTestSource(t, root, "source-after-build", time.Now().UTC()) + if afterBuild != initial { + t.Fatalf("ignored build output changed source binding:\ninitial=%+v\nafter=%+v", + initial, afterBuild) + } + + if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("changed source\n"), 0o600); err != nil { + t.Fatal(err) + } + afterSourceChange := bindTestSource(t, root, "source-after-change", time.Now().UTC()) + if afterSourceChange == initial || afterSourceChange.ManifestSHA256 == initial.ManifestSHA256 { + t.Fatal("tracked content change retained the fixed source binding") + } +} + +func bindTestSource(t *testing.T, root, id string, at time.Time) SourceBinding { + t.Helper() + snapshot, err := workspacecheckpoint.Capture(context.Background(), + workspacecheckpoint.CaptureRequest{ID: id, RunID: "run-source-test", + MissionID: "mission-source-test", SessionID: "session-source-test", + WorkspaceID: "workspace-source-test", WorkspaceRoot: root, + AttemptID: "attempt-source-test", Trigger: workspacecheckpoint.TriggerManual, + Phase: workspacecheckpoint.PhaseStandalone, TriggerReceiptID: id, + RequestedBy: "run_supervisor", Title: "UI evidence source test", CreatedAt: at}) + if err != nil { + t.Fatal(err) + } + binding, err := BindSource(context.Background(), root, snapshot) + if err != nil { + t.Fatal(err) + } + return binding +} diff --git a/web/src/App.tsx b/web/src/App.tsx index d129c05e..111d68a7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -75,6 +75,8 @@ export default function App() { const evidenceAttachmentEnabled = useConnectionStore((state) => state.evidenceAttachmentEnabled); const verificationEvidenceEnabled = useConnectionStore( (state) => state.verificationEvidenceEnabled); + const uiEvidenceControlEnabled = useConnectionStore( + (state) => state.uiEvidenceControlEnabled); const embeddedAnalyzerExecutionEnabled = useConnectionStore( (state) => state.embeddedAnalyzerExecutionEnabled); const dockerExecutionEnabled = useConnectionStore( @@ -109,6 +111,7 @@ export default function App() { skillInstallationEnabled={skillInstallationEnabled} evidenceAttachmentEnabled={evidenceAttachmentEnabled} verificationEvidenceEnabled={verificationEvidenceEnabled} + uiEvidenceControlEnabled={uiEvidenceControlEnabled} embeddedAnalyzerExecutionEnabled={embeddedAnalyzerExecutionEnabled} dockerExecutionEnabled={dockerExecutionEnabled} agentCodeToolsEnabled={agentCodeToolsEnabled} />; @@ -125,7 +128,7 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio modelControlEnabled, providerCredentialEnabled, fileEditReviewEnabled, fileEditProposalEnabled, fileEditApplyEnabled, runWakeControlEnabled, runWakeExecutionEnabled, runWakeWorkerEnabled, skillInstallationEnabled, - evidenceAttachmentEnabled, verificationEvidenceEnabled, + evidenceAttachmentEnabled, verificationEvidenceEnabled, uiEvidenceControlEnabled, embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, agentCodeToolsEnabled }: { token: string; controlToken: string; @@ -157,6 +160,7 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio skillInstallationEnabled: boolean; evidenceAttachmentEnabled: boolean; verificationEvidenceEnabled: boolean; + uiEvidenceControlEnabled: boolean; embeddedAnalyzerExecutionEnabled: boolean; dockerExecutionEnabled: boolean; agentCodeToolsEnabled: boolean; @@ -186,7 +190,7 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio providerCredentialEnabled, fileEditReviewEnabled, fileEditProposalEnabled, fileEditApplyEnabled, runWakeControlEnabled, runWakeExecutionEnabled, runWakeWorkerEnabled, skillInstallationEnabled, evidenceAttachmentEnabled, - verificationEvidenceEnabled, + verificationEvidenceEnabled, uiEvidenceControlEnabled, embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, agentCodeToolsEnabled, @@ -201,7 +205,8 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio providerCredentialEnabled, fileEditReviewEnabled, fileEditProposalEnabled, fileEditApplyEnabled, runWakeControlEnabled, runWakeExecutionEnabled, runWakeWorkerEnabled, skillInstallationEnabled, evidenceAttachmentEnabled, - verificationEvidenceEnabled, embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, + verificationEvidenceEnabled, uiEvidenceControlEnabled, + embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, agentCodeToolsEnabled]); const queryClient = useQueryClient(); const health = useConnectionStore((state) => state.health); @@ -245,6 +250,8 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio { id: "skill-install", label: t("Skill 安装", "Skill installation"), enabled: skillInstallationEnabled }, { id: "evidence", label: t("证据挂载", "Evidence attachment"), enabled: evidenceAttachmentEnabled }, { id: "verification", label: t("验证证据", "Verification evidence"), enabled: verificationEvidenceEnabled }, + { id: "ui-evidence", label: t("真实浏览器 UI 证据", "Real-browser UI evidence"), + enabled: uiEvidenceControlEnabled }, { id: "embedded-analyzer", label: t("内置分析器", "Embedded analyzer"), enabled: embeddedAnalyzerExecutionEnabled }, { id: "docker-execution", label: t("Docker 沙箱", "Docker sandbox"), enabled: dockerExecutionEnabled }, { id: "agent-code-tools", label: t("模型工作区工具", "Model workspace tools"), enabled: agentCodeToolsEnabled }, @@ -257,7 +264,8 @@ function ConnectedWorkbench({ token, controlToken, runControlEnabled, runCreatio runCreationEnabled, runExecutionEnabled, runLifecycleEnabled, runWakeControlEnabled, runWakeExecutionEnabled, runWakeWorkerEnabled, sessionMessageEnabled, sessionSteeringControlEnabled, skillInstallationEnabled, - verificationEvidenceEnabled, embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, + verificationEvidenceEnabled, uiEvidenceControlEnabled, + embeddedAnalyzerExecutionEnabled, dockerExecutionEnabled, agentCodeToolsEnabled, t]); useEffect(() => { diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 8f63d804..d8257d42 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -1,5 +1,6 @@ import { CyberAgentClient, clientCapabilitiesFromRuntime } from "./client"; -import type { RunEventStreamView, RunLifecycleControlView } from "./types"; +import type { RunEventStreamView, RunLifecycleControlView, + UIEvidenceArtifactMetadata } from "./types"; const healthEnvelope = { version: "api.v1", @@ -27,6 +28,7 @@ function runtimeCapabilitiesData(overrides: Record = {}) { run_wake_execution_enabled: true, run_wake_worker_enabled: true, skill_installation_enabled: true, evidence_attachment_enabled: true, verification_evidence_enabled: true, + ui_evidence_control_enabled: true, embedded_analyzer_execution_enabled: true, workspace_checkpoint_control_enabled: true, batch_delivery_control_enabled: true, @@ -129,6 +131,80 @@ const specialistModelCancellationData = { model_attempt: 2, status: "observed", requested_at: "2026-07-18T00:00:00Z", replayed: false, }; +function uiEvidencePassedAttemptData() { + return { + protocol_version: "ui-evidence-attempt.v1", + manifest: { + protocol_version: "ui-evidence.v1", attempt_id: "ui-attempt-1", run_id: "run-1", + mission_id: "mission-1", session_id: "session-1", workspace_id: "workspace-1", + source: { repository_kind: "git", commit: "a".repeat(40), branch: "codex/test", + dirty: false, dirty_digest: "b".repeat(64), root_fingerprint: "c".repeat(64), + index_sha256: "d".repeat(64), manifest_sha256: "e".repeat(64) }, + start: { protocol_version: "command-runtime.v2", profile: "powershell", + executable_name: "powershell.exe", executable_path_sha256: "1".repeat(64), + executable_sha256: "2".repeat(64), canonical_argv: ["powershell.exe", "-Command", + "npm run dev"], working_directory: "web", environment_names: [], + environment_sha256: "3".repeat(64), timeout_milliseconds: 60_000, + network: "disabled", credentials: "none", purpose: "UI evidence", + fingerprint: "4".repeat(64) }, + readiness: { url: "http://127.0.0.1:4173/", method: "GET", expected_status: [200], + timeout_milliseconds: 60_000, interval_milliseconds: 250 }, + browser: { product: "edge", version: "140.0.0.0", + executable_sha256: "5".repeat(64), + driver_protocol: "restricted-cdp-ui-evidence.v1", headless: true, + temporary_profile: true }, + url: "http://127.0.0.1:4173/", route: "/", environment: { + viewport: { width: 1440, height: 900, dpr: 1 }, locale: "en-US", + theme: "light", reduced_motion: false, + }, + fixture: { name: "fixture", seed: "seed", page_state: "{}", + data_sha256: "6".repeat(64), deterministic: true, synthetic: true }, + steps: [{ id: "navigate", kind: "navigate", capture_after: true }], + capture: { screenshot: true, dom: true, accessibility: true, console: true, + network: true, performance: true, video: false, mask_selectors: [] }, + failure_policy: { fail_on_console_error: true, fail_on_page_error: true, + fail_on_request_error: true, fail_on_http_status: true }, + authority: { process_start: false, network_access: false, credential_access: false, + personal_profile: false, request_mutation: false, verification_pass: false }, + created_at: "2026-08-20T00:00:00Z", fingerprint: "7".repeat(64), + }, + operation_digest: "8".repeat(64), request_fingerprint: "7".repeat(64), status: "passed", + failure_stage: "none", diagnostics: { console_warnings: 0, console_errors: 0, + page_errors: 0, failed_requests: 0, http_failures: 0, allowed_requests: 1, + blocked_requests: 0 }, cleanup: { browser_tree_reaped: true, + application_tree_reaped: true, profile_removed: true, network_released: true, + port_released: true }, artifact_count: 6, artifact_bytes: 1_024, version: 3, + created_at: "2026-08-20T00:00:00Z", started_at: "2026-08-20T00:00:01Z", + completed_at: "2026-08-20T00:00:02Z", updated_at: "2026-08-20T00:00:02Z", + }; +} + +function uiEvidencePassedBundleData() { + const attempt = uiEvidencePassedAttemptData(); + const kinds = ["screenshot", "dom", "accessibility", "console", "network", + "performance"] as const; + const sizes = [500, 100, 100, 100, 100, 124]; + return { + attempt, + artifacts: kinds.map((kind, index) => ({ + protocol_version: "ui-evidence-artifact.v1", id: `ui-artifact-${kind}`, + attempt_id: attempt.manifest.attempt_id, run_id: attempt.manifest.run_id, + step_id: "navigate", kind, mime: kind === "screenshot" ? "image/png" : "application/json", + sha256: String(index + 1).repeat(64), bytes: sizes[index], + ...(kind === "screenshot" ? { width: 1440, height: 900 } : {}), + viewport: { width: 1440, height: 900, dpr: 1 }, + source_commit: attempt.manifest.source.commit, retention_policy: "run_history", + redacted: true, untrusted: true, created_at: "2026-08-20T00:00:01Z", + fingerprint: String(index + 2).repeat(64), + })), + steps: [{ protocol_version: "ui-evidence-step.v1", + attempt_id: attempt.manifest.attempt_id, step_id: "navigate", sequence: 1, + kind: "navigate", status: "passed", failure_stage: "none", + started_at: "2026-08-20T00:00:01Z", completed_at: "2026-08-20T00:00:02Z", + fingerprint: "f".repeat(64) }], + }; +} + describe("CyberAgentClient", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -152,6 +228,109 @@ describe("CyberAgentClient", () => { expect(init.credentials).toBe("omit"); }); + it.each([ + ["blocked request", (attempt: ReturnType) => { + attempt.diagnostics.blocked_requests = 1; + }], + ["missing core capture", (attempt: ReturnType) => { + attempt.manifest.capture.accessibility = false; + }], + ["forged lifecycle version", (attempt: ReturnType) => { + attempt.version = 2; + }], + ["nested manifest extension", (attempt: ReturnType) => { + (attempt.manifest.source as Record).unreviewed_path = "C:\\secret"; + }], + ["network-enabled start recipe", (attempt: ReturnType) => { + attempt.manifest.start.network = "enabled"; + }], + ["non-loopback browser target", (attempt: ReturnType) => { + attempt.manifest.url = "https://example.com:443/"; + }], + ["duplicate manifest step", (attempt: ReturnType) => { + attempt.manifest.steps.push({ ...attempt.manifest.steps[0] }); + }], + ])("rejects passed UI evidence with a %s", async (_label, mutate) => { + const attempt = uiEvidencePassedAttemptData(); + mutate(attempt); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-ui-evidence", data: [attempt], + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + await expect(new CyberAgentClient("read-secret").uiEvidence("run-1")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("rejects a terminal UI evidence bundle whose artifact totals are incomplete", async () => { + const bundle = uiEvidencePassedBundleData(); + bundle.artifacts.pop(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-ui-evidence-bundle", data: bundle, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + await expect(new CyberAgentClient("read-secret").uiEvidenceBundle("ui-attempt-1")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("rejects screenshot dimensions that do not match the sealed viewport and DPR", async () => { + const bundle = uiEvidencePassedBundleData(); + bundle.artifacts[0].width = 1; + bundle.artifacts[0].height = 1; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-ui-evidence-dimensions", data: bundle, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + await expect(new CyberAgentClient("read-secret").uiEvidenceBundle("ui-attempt-1")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it.each([ + ["artifact after completion", (bundle: ReturnType) => { + bundle.artifacts[0].created_at = "2026-08-20T00:00:03Z"; + }], + ["step before attempt start", (bundle: ReturnType) => { + bundle.steps[0].started_at = "2026-08-20T00:00:00Z"; + }], + ])("rejects UI evidence chronology with %s", async (_label, mutate) => { + const bundle = uiEvidencePassedBundleData(); + mutate(bundle); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-ui-evidence-chronology", data: bundle, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + await expect(new CyberAgentClient("read-secret").uiEvidenceBundle("ui-attempt-1")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("accepts a complete source-bound UI evidence bundle with explicit retention", async () => { + const bundle = uiEvidencePassedBundleData(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-ui-evidence-complete", data: bundle, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + await expect(new CyberAgentClient("read-secret").uiEvidenceBundle("ui-attempt-1")) + .resolves.toEqual(bundle); + }); + + it("streams an exact hash-bound artifact and rejects bytes beyond the sealed size", async () => { + const content = new TextEncoder().encode("bounded evidence"); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", content)); + const sha256 = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const metadata = { ...uiEvidencePassedBundleData().artifacts[1], + bytes: content.byteLength, sha256 } as UIEvidenceArtifactMetadata; + const headers = { "Content-Type": metadata.mime, ETag: `"${sha256}"`, + "X-CyberAgent-Content-SHA256": sha256, "X-CyberAgent-Evidence-Untrusted": "true" }; + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(content, { headers })) + .mockResolvedValueOnce(new Response(new Uint8Array([...content, 1]), { headers })); + vi.stubGlobal("fetch", fetchMock); + const client = new CyberAgentClient("read-secret"); + + await expect(client.downloadUIEvidenceArtifact("ui-attempt-1", metadata)) + .resolves.toMatchObject({ size: content.byteLength, type: metadata.mime }); + await expect(client.downloadUIEvidenceArtifact("ui-attempt-1", metadata)) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + it("rejects a cross-origin API base before issuing a request", () => { expect(() => new CyberAgentClient("read-secret", "https://example.com/api/v1")) .toThrow("current browser origin"); @@ -179,6 +358,7 @@ describe("CyberAgentClient", () => { run_wake_execution_enabled: true, run_wake_worker_enabled: true, skill_installation_enabled: true, evidence_attachment_enabled: true, verification_evidence_enabled: true, + ui_evidence_control_enabled: true, embedded_analyzer_execution_enabled: true, workspace_checkpoint_control_enabled: true, batch_delivery_control_enabled: true, @@ -204,6 +384,7 @@ describe("CyberAgentClient", () => { fileEditProposalEnabled: true, providerCredentialEnabled: true, runWakeWorkerEnabled: true, verificationEvidenceEnabled: true, + uiEvidenceControlEnabled: true, embeddedAnalyzerExecutionEnabled: true, workspaceCheckpointControlEnabled: true, batchDeliveryControlEnabled: true, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2bf55725..9c419a08 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -99,6 +99,10 @@ import type { RunWakeExecutionRequestView, RunWakeExecutionView, RuntimeCapabilitiesView, + UIEvidenceArtifactMetadata, + UIEvidenceAttempt, + UIEvidenceBundle, + UIEvidenceStartView, SkillPackageInstallRequestView, SkillPackageInstallView, RunEventPollView, @@ -168,6 +172,7 @@ export interface ClientCapabilities { skillInstallationEnabled?: boolean; evidenceAttachmentEnabled?: boolean; verificationEvidenceEnabled?: boolean; + uiEvidenceControlEnabled?: boolean; embeddedAnalyzerExecutionEnabled?: boolean; workspaceCheckpointControlEnabled?: boolean; batchDeliveryControlEnabled?: boolean; @@ -1109,6 +1114,375 @@ function parseProviderCredentialList(value: unknown): ProviderCredentialListView return { ...value, items } as unknown as ProviderCredentialListView; } +const uiEvidenceStatuses = ["not_run", "running", "passed", "failed", "cancelled", + "timed_out", "interrupted"] as const; +const uiEvidenceFailureStages = ["none", "build", "launch", "readiness", "navigation", + "selector", "assertion", "console", "network", "capture", "cleanup"] as const; +const uiEvidenceStepKinds = ["navigate", "click", "type", "assert_present", "assert_absent", + "capture"] as const; + +function validUIEvidenceText(value: unknown, maximum: number, lines = false): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maximum && + value.trim() === value && !value.includes("\0") && (lines || !/[\r\n]/u.test(value)); +} + +function hasRequiredOnlyKeys(value: unknown, required: string[], optional: string[] = []): + value is Record { + return isRecord(value) && hasOnlyKeys(value, [...required, ...optional]) && + required.every((key) => Object.prototype.hasOwnProperty.call(value, key)); +} + +function validUIEvidenceLoopbackURL(value: unknown): value is string { + if (typeof value !== "string" || value.length > 4_096 || /[\r\n\t?#]/u.test(value)) return false; + const match = /^(https?):\/\/(\[[^\]]+\]|[^/:@]+):(\d{1,5})(\/.*)$/u.exec(value); + if (!match) return false; + const port = Number(match[3]); + const host = match[2].replace(/^\[|\]$/gu, "").toLowerCase(); + const ipv4 = /^127(?:\.\d{1,3}){3}$/u.test(host) && + host.split(".").every((part) => Number(part) <= 255); + const ipv6 = host === "::1" || /^::ffff:127(?:\.\d{1,3}){3}$/u.test(host); + if ((!ipv4 && !ipv6) || port < 1 || port > 65_535) return false; + try { + const parsed = new URL(value); + return parsed.username === "" && parsed.password === "" && parsed.pathname !== "" && + parsed.search === "" && parsed.hash === ""; + } catch { + return false; + } +} + +function sameUIEvidenceOrigin(left: string, right: string): boolean { + try { + const leftURL = new URL(left); + const rightURL = new URL(right); + return leftURL.protocol === rightURL.protocol && + leftURL.hostname.toLowerCase() === rightURL.hostname.toLowerCase() && + leftURL.port === rightURL.port; + } catch { + return false; + } +} + +function validUIEvidenceSource(value: unknown): boolean { + const required = ["commit", "dirty", "dirty_digest", "index_sha256", "manifest_sha256", + "repository_kind", "root_fingerprint"]; + if (!hasRequiredOnlyKeys(value, required, ["branch"]) || + !["git", "non_git"].includes(String(value.repository_kind)) || + typeof value.dirty !== "boolean" || !isSHA256(value.dirty_digest) || + !isSHA256(value.root_fingerprint) || !isSHA256(value.index_sha256) || + !isSHA256(value.manifest_sha256) || + (Object.prototype.hasOwnProperty.call(value, "branch") && + !validUIEvidenceText(value.branch, 255))) return false; + return value.repository_kind === "git" + ? typeof value.commit === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value.commit) + : value.commit === "non-git"; +} + +function validUIEvidenceCommandRecipe(value: unknown): boolean { + const keys = ["canonical_argv", "credentials", "environment_names", "environment_sha256", + "executable_name", "executable_path_sha256", "executable_sha256", "fingerprint", "network", + "profile", "protocol_version", "purpose", "timeout_milliseconds", "working_directory"]; + if (!hasExactKeys(value, keys) || value.protocol_version !== "command-runtime.v2" || + !["powershell", "bash", "process"].includes(String(value.profile)) || + !validUIEvidenceText(value.executable_name, 512) || /[\\/]/u.test(value.executable_name) || + !isSHA256(value.executable_path_sha256) || !isSHA256(value.executable_sha256) || + !isSHA256(value.environment_sha256) || !isSHA256(value.fingerprint) || + !safePositiveInteger(value.timeout_milliseconds) || value.timeout_milliseconds > 1_800_000 || + value.network !== "disabled" || value.credentials !== "none" || + !validUIEvidenceText(value.working_directory, 4_096) || + !validUIEvidenceText(value.purpose, 1_200) || !Array.isArray(value.canonical_argv) || + value.canonical_argv.length < 1 || value.canonical_argv.length > 128 || + value.canonical_argv.some((entry) => !validUIEvidenceText(entry, 65_536, true)) || + !Array.isArray(value.environment_names) || value.environment_names.length > 32 || + value.environment_names.some((entry) => !validUIEvidenceText(entry, 128))) return false; + const names = value.environment_names as string[]; + return names.every((name, index) => index === 0 || + name.toLowerCase() > names[index - 1].toLowerCase()); +} + +function validUIEvidenceReadiness(value: unknown): boolean { + if (!hasExactKeys(value, ["expected_status", "interval_milliseconds", "method", + "timeout_milliseconds", "url"]) || !validUIEvidenceLoopbackURL(value.url) || + value.method !== "GET" || !safePositiveInteger(value.timeout_milliseconds) || + value.timeout_milliseconds < 100 || value.timeout_milliseconds > 120_000 || + !safePositiveInteger(value.interval_milliseconds) || value.interval_milliseconds < 10 || + value.interval_milliseconds > 5_000 || !Array.isArray(value.expected_status) || + value.expected_status.length < 1 || value.expected_status.length > 16) return false; + return value.expected_status.every((status, index, statuses) => Number.isInteger(status) && + status >= 100 && status <= 599 && (index === 0 || status > statuses[index - 1])); +} + +function validUIEvidenceEnvironment(value: unknown): boolean { + if (!hasExactKeys(value, ["locale", "reduced_motion", "theme", "viewport"]) || + !hasExactKeys(value.viewport, ["dpr", "height", "width"]) || + !safePositiveInteger(value.viewport.width) || !safePositiveInteger(value.viewport.height) || + value.viewport.width < 320 || value.viewport.width > 7_680 || + value.viewport.height < 240 || value.viewport.height > 4_320 || + typeof value.viewport.dpr !== "number" || !Number.isFinite(value.viewport.dpr) || + value.viewport.dpr < 0.5 || value.viewport.dpr > 4 || + value.viewport.width * value.viewport.dpr > 7_680 || + value.viewport.height * value.viewport.dpr > 4_320 || + !["light", "dark"].includes(String(value.theme)) || + typeof value.reduced_motion !== "boolean" || !validUIEvidenceText(value.locale, 64)) return false; + const parts = value.locale.split("-"); + return parts.length >= 1 && parts.length <= 3 && parts[0].length >= 2 && + parts[0].length <= 8 && parts.every((part) => /^[A-Za-z0-9]*$/u.test(part)); +} + +function validUIEvidenceFixture(value: unknown): boolean { + return hasExactKeys(value, ["data_sha256", "deterministic", "name", "page_state", "seed", + "synthetic"]) && validUIEvidenceText(value.name, 256) && + validUIEvidenceText(value.seed, 256) && validUIEvidenceText(value.page_state, 8_192, true) && + isSHA256(value.data_sha256) && value.deterministic === true && + typeof value.synthetic === "boolean"; +} + +function validUIEvidenceSteps(value: unknown): boolean { + if (!Array.isArray(value) || value.length < 1 || value.length > 128) return false; + const identifiers = new Set(); + for (const entry of value) { + if (!hasRequiredOnlyKeys(entry, ["capture_after", "id", "kind"], + ["input_sha256", "selector"]) || !boundedIdentity(entry.id) || + !uiEvidenceStepKinds.includes(String(entry.kind) as typeof uiEvidenceStepKinds[number]) || + typeof entry.capture_after !== "boolean") return false; + const requiresSelector = ["click", "type", "assert_present", "assert_absent"] + .includes(String(entry.kind)); + const hasSelector = Object.prototype.hasOwnProperty.call(entry, "selector"); + const hasInput = Object.prototype.hasOwnProperty.call(entry, "input_sha256"); + if (requiresSelector !== hasSelector || + (hasSelector && !validUIEvidenceText(entry.selector, 2_048)) || + ((entry.kind === "type") !== hasInput) || (hasInput && !isSHA256(entry.input_sha256)) || + identifiers.has(entry.id as string)) return false; + identifiers.add(entry.id as string); + } + return value[0].kind === "navigate"; +} + +function validUIEvidenceBrowser(value: unknown): boolean { + return hasExactKeys(value, ["driver_protocol", "executable_sha256", "headless", "product", + "temporary_profile", "version"]) && ["chrome", "edge"].includes(String(value.product)) && + validUIEvidenceText(value.version, 128) && isSHA256(value.executable_sha256) && + value.driver_protocol === "restricted-cdp-ui-evidence.v1" && + typeof value.headless === "boolean" && value.temporary_profile === true; +} + +function parseUIEvidenceAttempt(value: unknown, expectedRunID = ""): UIEvidenceAttempt { + const required = ["artifact_bytes", "artifact_count", "cleanup", "created_at", "diagnostics", + "failure_stage", "manifest", "operation_digest", "protocol_version", "request_fingerprint", + "status", "updated_at", "version"]; + const optional = ["completed_at", "failure_code", "failure_message", "started_at"]; + if (!isRecord(value) || !hasOnlyKeys(value, [...required, ...optional]) || + required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) || + value.protocol_version !== "ui-evidence-attempt.v1" || + !uiEvidenceStatuses.includes(String(value.status) as typeof uiEvidenceStatuses[number]) || + !uiEvidenceFailureStages.includes(String(value.failure_stage) as typeof uiEvidenceFailureStages[number]) || + !isSHA256(value.operation_digest) || !isSHA256(value.request_fingerprint) || + !safePositiveInteger(value.version) || !safeBoundedCount(value.artifact_count, 10_000) || + !safeBoundedCount(value.artifact_bytes, 128 * 1024 * 1024) || + !validDate(value.created_at) || !validDate(value.updated_at) || + !isRecord(value.manifest) || !isRecord(value.cleanup) || !isRecord(value.diagnostics)) { + throw new APIRequestError("UI evidence attempt is invalid", "INVALID_RESPONSE", 502); + } + const manifest = value.manifest; + if (!hasExactKeys(manifest, ["attempt_id", "authority", "browser", "capture", "created_at", + "environment", "failure_policy", "fingerprint", "fixture", "mission_id", + "protocol_version", "readiness", "route", "run_id", "session_id", "source", "start", + "steps", "url", "workspace_id"].concat(Object.prototype.hasOwnProperty.call(manifest, "build") ? ["build"] : [])) || + manifest.protocol_version !== "ui-evidence.v1" || !boundedIdentity(manifest.attempt_id) || + !boundedIdentity(manifest.run_id) || !boundedIdentity(manifest.mission_id) || + !boundedIdentity(manifest.session_id) || !boundedIdentity(manifest.workspace_id) || + (expectedRunID !== "" && manifest.run_id !== expectedRunID) || + !validDate(manifest.created_at) || !isSHA256(manifest.fingerprint) || + manifest.fingerprint !== value.request_fingerprint || !validUIEvidenceLoopbackURL(manifest.url) || + !validUIEvidenceText(manifest.route, 4_096) || !manifest.route.startsWith("/") || + manifest.route.startsWith("//") || new URL(manifest.url).pathname !== manifest.route || + !isRecord(manifest.authority) || !isRecord(manifest.browser) || + !isRecord(manifest.capture) || !isRecord(manifest.environment) || + !isRecord(manifest.failure_policy) || !isRecord(manifest.fixture) || + !isRecord(manifest.readiness) || !isRecord(manifest.source) || !isRecord(manifest.start) || + !validUIEvidenceSource(manifest.source) || !validUIEvidenceCommandRecipe(manifest.start) || + (Object.prototype.hasOwnProperty.call(manifest, "build") && + !validUIEvidenceCommandRecipe(manifest.build)) || !validUIEvidenceReadiness(manifest.readiness) || + !sameUIEvidenceOrigin(manifest.url, manifest.readiness.url as string) || + !validUIEvidenceBrowser(manifest.browser) || !validUIEvidenceEnvironment(manifest.environment) || + !validUIEvidenceFixture(manifest.fixture) || !validUIEvidenceSteps(manifest.steps)) { + throw new APIRequestError("UI evidence manifest is invalid", "INVALID_RESPONSE", 502); + } + const capture = manifest.capture as Record; + if (!hasExactKeys(manifest.authority, ["credential_access", "network_access", + "personal_profile", "process_start", "request_mutation", "verification_pass"]) || + Object.values(manifest.authority).some((entry) => entry !== false) || + !hasExactKeys(manifest.capture, ["accessibility", "console", "dom", "mask_selectors", + "network", "performance", "screenshot", "video"]) || + ["accessibility", "console", "dom", "network", "performance", "screenshot"] + .some((key) => capture[key] !== true) || + !hasExactKeys(manifest.failure_policy, ["fail_on_console_error", "fail_on_http_status", + "fail_on_page_error", "fail_on_request_error"]) || + Object.values(manifest.failure_policy).some((entry) => entry !== true) || + manifest.capture.video !== false || !Array.isArray(manifest.capture.mask_selectors) || + manifest.capture.mask_selectors.length > 32 || + manifest.capture.mask_selectors.some((entry) => !validUIEvidenceText(entry, 2_048)) || + new Set(manifest.capture.mask_selectors).size !== manifest.capture.mask_selectors.length) { + throw new APIRequestError("UI evidence widened its evidence authority", "INVALID_RESPONSE", 502); + } + const cleanupKeys = ["application_tree_reaped", "browser_tree_reaped", "network_released", + "port_released", "profile_removed"]; + const diagnosticKeys = ["allowed_requests", "blocked_requests", "console_errors", + "console_warnings", "failed_requests", "http_failures", "page_errors"]; + const cleanup = value.cleanup as Record; + const diagnostics = value.diagnostics as Record; + if (!hasExactKeys(cleanup, cleanupKeys) || + cleanupKeys.some((key) => typeof cleanup[key] !== "boolean") || + !hasExactKeys(diagnostics, diagnosticKeys) || + diagnosticKeys.some((key) => !safeBoundedCount(diagnostics[key], 1_000_000))) { + throw new APIRequestError("UI evidence cleanup or diagnostics are invalid", "INVALID_RESPONSE", 502); + } + const status = String(value.status); + const hasStarted = Object.prototype.hasOwnProperty.call(value, "started_at"); + const hasCompleted = Object.prototype.hasOwnProperty.call(value, "completed_at"); + const hasFailureCode = Object.prototype.hasOwnProperty.call(value, "failure_code"); + const hasFailureMessage = Object.prototype.hasOwnProperty.call(value, "failure_message"); + const started = hasStarted && typeof value.started_at === "string" && validDate(value.started_at); + const completed = hasCompleted && typeof value.completed_at === "string" && validDate(value.completed_at); + const hasExecutionResidue = diagnosticKeys.some((key) => diagnostics[key] !== 0) || + cleanupKeys.some((key) => cleanup[key] !== false) || value.artifact_count !== 0 || + value.artifact_bytes !== 0; + const createdAt = Date.parse(String(value.created_at)); + const updatedAt = Date.parse(String(value.updated_at)); + const startedAt = started ? Date.parse(String(value.started_at)) : 0; + const completedAt = completed ? Date.parse(String(value.completed_at)) : 0; + if (updatedAt < createdAt || (started && startedAt < createdAt) || + (completed && (!started || completedAt < startedAt || updatedAt < completedAt)) || + (hasStarted && !started) || (hasCompleted && !completed) || + (hasFailureCode && (typeof value.failure_code !== "string" || value.failure_code === "")) || + (hasFailureMessage && typeof value.failure_message !== "string") || + ((value.artifact_count === 0) !== (value.artifact_bytes === 0)) || + (status === "not_run" && (value.version !== 1 || started || completed || hasFailureCode || hasFailureMessage || + value.failure_stage !== "none" || hasExecutionResidue)) || + (status === "running" && (value.version !== 2 || !started || completed || hasFailureCode || hasFailureMessage || + value.failure_stage !== "none" || hasExecutionResidue)) || + (status === "passed" && (value.version !== 3 || !started || !completed || value.failure_stage !== "none" || + hasFailureCode || hasFailureMessage || value.artifact_count < 1 || value.artifact_bytes < 1 || + cleanupKeys.some((key) => cleanup[key] !== true) || + ["console_errors", "page_errors", "failed_requests", "http_failures", "blocked_requests"] + .some((key) => diagnostics[key] !== 0))) || + (!["not_run", "running", "passed"].includes(status) && + (value.version !== 3 || !started || !completed || value.failure_stage === "none" || + typeof value.failure_code !== "string" || value.failure_code === ""))) { + throw new APIRequestError("UI evidence status is not fail-closed", "INVALID_RESPONSE", 502); + } + return value as unknown as UIEvidenceAttempt; +} + +function parseUIEvidenceArtifactMetadata(value: unknown, attempt: UIEvidenceAttempt): + UIEvidenceArtifactMetadata { + const required = ["attempt_id", "bytes", "created_at", "fingerprint", "id", "kind", "mime", + "protocol_version", "redacted", "retention_policy", "run_id", "sha256", "source_commit", + "step_id", "untrusted", "viewport"]; + const optional = ["height", "width"]; + const attemptStartedAt = attempt.started_at ? Date.parse(attempt.started_at) : Number.NaN; + const attemptCompletedAt = attempt.completed_at ? Date.parse(attempt.completed_at) : undefined; + if (!isRecord(value) || !hasOnlyKeys(value, [...required, ...optional]) || + required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) || + value.protocol_version !== "ui-evidence-artifact.v1" || + value.attempt_id !== attempt.manifest.attempt_id || value.run_id !== attempt.manifest.run_id || + !boundedIdentity(value.id) || !boundedIdentity(value.step_id) || !isSHA256(value.sha256) || + !isSHA256(value.fingerprint) || value.source_commit !== attempt.manifest.source.commit || + !safePositiveInteger(value.bytes) || + value.bytes > 32 * 1024 * 1024 || typeof value.mime !== "string" || + !["screenshot", "dom", "accessibility", "console", "network", "performance"] + .includes(String(value.kind)) || typeof value.redacted !== "boolean" || + value.retention_policy !== "run_history" || value.untrusted !== true || + !validDate(value.created_at) || !Number.isFinite(attemptStartedAt) || + Date.parse(String(value.created_at)) < attemptStartedAt || + (attemptCompletedAt !== undefined && Date.parse(String(value.created_at)) > attemptCompletedAt) || + !hasExactKeys(value.viewport, ["dpr", "height", "width"]) || + !safePositiveInteger(value.viewport.width) || !safePositiveInteger(value.viewport.height) || + typeof value.viewport.dpr !== "number" || !Number.isFinite(value.viewport.dpr) || + value.viewport.width !== attempt.manifest.environment.viewport.width || + value.viewport.height !== attempt.manifest.environment.viewport.height || + value.viewport.dpr !== attempt.manifest.environment.viewport.dpr || + (value.kind === "screenshot" && (value.mime !== "image/png" || + !safePositiveInteger(value.width) || !safePositiveInteger(value.height) || + value.width > 7_680 || value.height > 4_320 || + Math.abs(value.width - value.viewport.width * value.viewport.dpr) > 1 || + Math.abs(value.height - value.viewport.height * value.viewport.dpr) > 1)) || + (value.kind !== "screenshot" && + (Object.prototype.hasOwnProperty.call(value, "width") || + Object.prototype.hasOwnProperty.call(value, "height")))) { + throw new APIRequestError("UI evidence artifact metadata is invalid", "INVALID_RESPONSE", 502); + } + return value as unknown as UIEvidenceArtifactMetadata; +} + +function parseUIEvidenceBundle(value: unknown, attemptID: string): UIEvidenceBundle { + if (!hasExactKeys(value, ["artifacts", "attempt", "steps"]) || + !Array.isArray(value.artifacts) || !Array.isArray(value.steps)) { + throw new APIRequestError("UI evidence bundle is invalid", "INVALID_RESPONSE", 502); + } + const attempt = parseUIEvidenceAttempt(value.attempt); + if (attempt.manifest.attempt_id !== attemptID || value.artifacts.length > 10_000 || + value.steps.length > 128) { + throw new APIRequestError("UI evidence bundle identity is invalid", "INVALID_RESPONSE", 502); + } + const artifacts = value.artifacts.map((entry) => parseUIEvidenceArtifactMetadata(entry, attempt)); + const artifactIDs = new Set(artifacts.map((entry) => entry.id)); + const artifactFingerprints = new Set(artifacts.map((entry) => entry.fingerprint)); + const artifactBytes = artifacts.reduce((sum, entry) => sum + entry.bytes, 0); + const manifestSteps = attempt.manifest.steps; + const manifestStepIDs = new Set(manifestSteps.map((entry) => entry.id)); + const terminal = !["not_run", "running"].includes(attempt.status); + if (artifactIDs.size !== artifacts.length || artifactFingerprints.size !== artifacts.length || + artifacts.some((entry) => !manifestStepIDs.has(entry.step_id)) || + (terminal && (artifacts.length !== attempt.artifact_count || + artifactBytes !== attempt.artifact_bytes)) || + (attempt.status === "passed" && + ["screenshot", "dom", "accessibility", "console", "network", "performance"] + .some((kind) => !artifacts.some((entry) => entry.kind === kind)))) { + throw new APIRequestError("UI evidence bundle artifact totals are inconsistent", + "INVALID_RESPONSE", 502); + } + const steps = value.steps.map((entry) => { + const required = ["attempt_id", "completed_at", "failure_stage", "fingerprint", "kind", + "protocol_version", "sequence", "started_at", "status", "step_id"]; + const optional = ["message"]; + if (!isRecord(entry) || !hasOnlyKeys(entry, [...required, ...optional]) || + required.some((key) => !Object.prototype.hasOwnProperty.call(entry, key)) || + entry.protocol_version !== "ui-evidence-step.v1" || + entry.attempt_id !== attemptID || !boundedIdentity(entry.step_id) || + !safePositiveInteger(entry.sequence) || entry.sequence > 128 || + !["navigate", "click", "type", "assert_present", "assert_absent", "capture"] + .includes(String(entry.kind)) || + !["passed", "failed", "cancelled", "timed_out"].includes(String(entry.status)) || + !uiEvidenceFailureStages.includes(String(entry.failure_stage) as typeof uiEvidenceFailureStages[number]) || + !isSHA256(entry.fingerprint) || !validDate(entry.started_at) || !validDate(entry.completed_at) || + !attempt.started_at || Date.parse(String(entry.started_at)) < Date.parse(attempt.started_at) || + (attempt.completed_at !== undefined && + Date.parse(String(entry.completed_at)) > Date.parse(attempt.completed_at)) || + Date.parse(String(entry.completed_at)) < Date.parse(String(entry.started_at)) || + (Object.prototype.hasOwnProperty.call(entry, "message") && + (typeof entry.message !== "string" || entry.message.length > 2_048)) || + ((entry.status === "passed") !== (entry.failure_stage === "none"))) { + throw new APIRequestError("UI evidence step receipt is invalid", "INVALID_RESPONSE", 502); + } + const expected = manifestSteps[Number(entry.sequence) - 1]; + if (!expected || expected.id !== entry.step_id || expected.kind !== entry.kind) { + throw new APIRequestError("UI evidence step receipt does not match its manifest", + "INVALID_RESPONSE", 502); + } + return entry; + }); + if (new Set(steps.map((entry) => entry.sequence)).size !== steps.length || + new Set(steps.map((entry) => entry.step_id)).size !== steps.length || + new Set(steps.map((entry) => entry.fingerprint)).size !== steps.length || + (attempt.status === "passed" && + (steps.length !== manifestSteps.length || steps.some((entry) => entry.status !== "passed")))) { + throw new APIRequestError("UI evidence step receipts are inconsistent", + "INVALID_RESPONSE", 502); + } + return { attempt, artifacts, steps } as unknown as UIEvidenceBundle; +} + function parseRuntimeCapabilities(value: unknown): RuntimeCapabilitiesView { const capabilityKeys = ["agent_code_tools_enabled", "approval_control_enabled", "command_runtime_enabled", "docker_execution_enabled", @@ -1120,6 +1494,7 @@ function parseRuntimeCapabilities(value: unknown): RuntimeCapabilitiesView { "evidence_attachment_enabled", "verification_evidence_enabled", "embedded_analyzer_execution_enabled", "workspace_checkpoint_control_enabled", "batch_delivery_control_enabled", "batch_delivery_host_validation_enabled", + "ui_evidence_control_enabled", "file_edit_apply_enabled", "file_edit_proposal_enabled", "file_edit_review_enabled", "model_control_enabled", "plan_delivery_control_enabled", "process_execution_enabled", "provider_credential_enabled", "protocol_version", @@ -1156,6 +1531,8 @@ function parseRuntimeCapabilities(value: unknown): RuntimeCapabilitiesView { (value.batch_delivery_host_validation_enabled && (!value.batch_delivery_control_enabled || !value.execution_permission_control_enabled || !value.operator_approval_enabled || !value.danger_full_access_enabled)) || + (value.ui_evidence_control_enabled && (!value.command_runtime_enabled || + !value.browser_cdp_permission_control_enabled)) || value.command_runtime_enabled !== (value.run_execution_enabled && value.danger_full_access_enabled) || value.process_execution_enabled !== value.command_runtime_enabled || @@ -1263,6 +1640,7 @@ export function clientCapabilitiesFromRuntime(value: RuntimeCapabilitiesView): C workspaceCheckpointControlEnabled: value.workspace_checkpoint_control_enabled, batchDeliveryControlEnabled: value.batch_delivery_control_enabled, batchDeliveryHostValidationEnabled: value.batch_delivery_host_validation_enabled, + uiEvidenceControlEnabled: value.ui_evidence_control_enabled, dockerExecutionEnabled: value.docker_execution_enabled, agentCodeToolsEnabled: value.agent_code_tools_enabled, }; @@ -3420,6 +3798,7 @@ export class CyberAgentClient { readonly hasWorkspaceCheckpointControl: boolean; readonly hasBatchDeliveryControl: boolean; readonly hasBatchDeliveryHostValidation: boolean; + readonly hasUIEvidence: boolean; constructor( private readonly token: string, @@ -3478,6 +3857,8 @@ export class CyberAgentClient { (capabilities.operatorApprovalEnabled ?? false) && (capabilities.dangerFullAccessEnabled ?? false) && (capabilities.batchDeliveryHostValidationEnabled ?? false); + this.hasUIEvidence = controlPresent && (capabilities.uiEvidenceControlEnabled ?? false) && + this.hasRunExecution && this.hasBrowserCDPPermissionControl; } async health(signal?: AbortSignal): Promise { @@ -3493,6 +3874,109 @@ export class CyberAgentClient { { product }, signal)); } + async uiEvidence(runID: string, signal?: AbortSignal): Promise { + if (!boundedIdentity(runID) || runID.trim() !== runID) { + throw new Error("A normalized Run identity is required"); + } + const value = await this.get( + `/runs/${encodeURIComponent(runID)}/ui-evidence`, { limit: 100 }, signal); + if (!Array.isArray(value) || value.length > 500) { + throw new APIRequestError("UI evidence list is invalid", "INVALID_RESPONSE", 502); + } + const attempts = value.map((entry) => parseUIEvidenceAttempt(entry, runID)); + if (new Set(attempts.map((entry) => entry.manifest.attempt_id)).size !== attempts.length) { + throw new APIRequestError("UI evidence list contains duplicate attempts", "INVALID_RESPONSE", 502); + } + return attempts; + } + + async uiEvidenceBundle(attemptID: string, signal?: AbortSignal): Promise { + if (!boundedIdentity(attemptID) || attemptID.trim() !== attemptID) { + throw new Error("A normalized UI evidence attempt identity is required"); + } + return parseUIEvidenceBundle(await this.get( + `/ui-evidence/${encodeURIComponent(attemptID)}`, {}, signal), attemptID); + } + + async startUIEvidence(runID: string, body: UIEvidenceStartView, + signal?: AbortSignal): Promise { + if (!this.hasUIEvidence || !boundedIdentity(runID) || runID.trim() !== runID || + typeof body.operation_key !== "string" || body.operation_key.trim() !== body.operation_key || + body.operation_key.length < 1) { + throw new Error("UI evidence control, a normalized Run, and an operation key are required"); + } + return parseUIEvidenceAttempt(await this.sendControl( + `/runs/${encodeURIComponent(runID)}/ui-evidence`, body, body.operation_key, signal), runID); + } + + async cancelUIEvidence(attemptID: string, signal?: AbortSignal): Promise { + if (!this.hasUIEvidence || !boundedIdentity(attemptID) || attemptID.trim() !== attemptID) { + throw new Error("UI evidence control and a normalized attempt identity are required"); + } + return parseUIEvidenceAttempt(await this.sendControl( + `/ui-evidence/${encodeURIComponent(attemptID)}/cancel`, { confirm: true }, + `ui-evidence-cancel-${attemptID}`, signal)); + } + + async downloadUIEvidenceArtifact(attemptID: string, metadata: UIEvidenceArtifactMetadata, + signal?: AbortSignal): Promise { + if (!boundedIdentity(attemptID) || attemptID !== metadata.attempt_id || + !boundedIdentity(metadata.id) || !isSHA256(metadata.sha256) || metadata.untrusted !== true) { + throw new Error("Exact untrusted UI evidence artifact metadata is required"); + } + const response = await fetch(this.url(`/ui-evidence/${encodeURIComponent(attemptID)}/artifacts/` + + encodeURIComponent(metadata.id)), { + method: "GET", + headers: { ...this.headers(), Accept: metadata.mime }, + signal, + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + if (!response.ok) throw await this.responseError(response); + if (response.headers.get("content-type") !== metadata.mime || + response.headers.get("etag") !== `"${metadata.sha256}"` || + response.headers.get("x-cyberagent-content-sha256") !== metadata.sha256 || + response.headers.get("x-cyberagent-evidence-untrusted") !== "true") { + throw new APIRequestError("UI evidence artifact headers are invalid", "INVALID_RESPONSE", + response.status, response.headers.get("x-request-id") || ""); + } + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null && Number(declaredLength) !== metadata.bytes) { + throw new APIRequestError("UI evidence artifact byte count changed", "INVALID_RESPONSE", 502); + } + if (!response.body) { + throw new APIRequestError("UI evidence artifact body is unavailable", "INVALID_RESPONSE", 502); + } + const bytes = new Uint8Array(metadata.bytes); + const reader = response.body.getReader(); + let offset = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (offset + value.byteLength > bytes.byteLength) { + await reader.cancel(); + throw new APIRequestError("UI evidence artifact exceeded its sealed byte count", + "INVALID_RESPONSE", 502); + } + bytes.set(value, offset); + offset += value.byteLength; + } + } finally { + reader.releaseLock(); + } + if (offset !== metadata.bytes) { + throw new APIRequestError("UI evidence artifact byte count changed", "INVALID_RESPONSE", 502); + } + const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes)); + const digestHex = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + if (digestHex !== metadata.sha256) { + throw new APIRequestError("UI evidence artifact digest verification failed", "INVALID_RESPONSE", 502); + } + return new Blob([bytes], { type: metadata.mime }); + } + async modelAvailability(signal?: AbortSignal): Promise { const value = await this.get("/models", {}, signal); return parseModelAvailability(value); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 5c6c0d1f..2f8d5cb5 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -1692,6 +1692,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/runs/{run_id}/ui-evidence": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List source-bound UI evidence attempts + * @description Returns immutable manifests and fail-closed outcomes. not_run is unknown and never a passing result. + */ + get: operations["listRunUIEvidence"]; + put?: never; + /** + * Start real-browser UI evidence + * @description Persists not_run before asynchronously executing the exact source-bound build/start recipe in a Run-owned process tree, a temporary browser Profile, and a loopback-only reviewed Safe Web runtime. + */ + post: operations["startRunUIEvidence"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/runs/{run_id}/verification-evidence": { parameters: { query?: never; @@ -2320,6 +2344,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/ui-evidence/{attempt_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Inspect one UI evidence bundle + * @description Returns the exact manifest, step receipts, and artifact metadata without binary content. + */ + get: operations["getUIEvidence"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ui-evidence/{attempt_id}/artifacts/{artifact_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download one verified UI evidence artifact + * @description Returns exact hash-verified untrusted bytes with MIME, ETag, source digest, and no-store headers. + */ + get: operations["downloadUIEvidenceArtifact"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ui-evidence/{attempt_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel one UI evidence attempt + * @description Cancels the service-owned context and waits for bounded process-tree, Profile, network, and port cleanup. + */ + post: operations["cancelUIEvidence"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/work-items/{work_item_id}": { parameters: { query?: never; @@ -3427,6 +3511,34 @@ export interface components { verification_snapshot_receipt_reviews: components["schemas"]["CodeHandoffSnapshotReceiptReviewsView"]; workspace_id: string; }; + CommandRuntimeEnvironment: { + name: string; + value: string; + }; + CommandRuntimeOutputPolicy: { + /** Format: int32 */ + artifact_bytes: number; + /** Format: int32 */ + inline_bytes: number; + }; + CommandRuntimeSpec: { + arguments?: string[]; + close_initial_stdin: boolean; + credentials: string; + environment: components["schemas"]["CommandRuntimeEnvironment"][]; + executable?: string; + initial_stdin?: string; + network: string; + output: components["schemas"]["CommandRuntimeOutputPolicy"]; + profile: string; + purpose: string; + script?: string; + stdin_policy: string; + /** Format: int64 */ + timeout_milliseconds: number; + version: string; + working_directory: string; + }; CommandSpec: { arguments?: string[]; executable: string; @@ -4481,7 +4593,8 @@ export interface components { mounts: components["schemas"]["Mount"][]; network: components["schemas"]["NetworkScope"]; output: components["schemas"]["OutputSpec"]; - protocol_version: string; + /** @enum {string} */ + protocol_version: "ui-evidence.v1"; resources: components["schemas"]["ResourceLimits"]; /** Format: int32 */ timeout_seconds: number; @@ -5901,6 +6014,7 @@ export interface components { session_steering_control_enabled: boolean; shell_execution_enabled: boolean; skill_installation_enabled: boolean; + ui_evidence_control_enabled: boolean; verification_evidence_enabled: boolean; wake_worker: components["schemas"]["RunWakeWorkerHealthView"]; workspace_checkpoint_control_enabled: boolean; @@ -6148,6 +6262,251 @@ export interface components { updated_at: string; workspace_id: string; }; + UIEvidenceArtifactMetadata: { + attempt_id: string; + /** Format: int64 */ + bytes: number; + /** Format: date-time */ + created_at: string; + fingerprint: string; + /** Format: int32 */ + height?: number; + id: string; + /** @enum {string} */ + kind: "screenshot" | "dom" | "accessibility" | "console" | "network" | "performance"; + mime: string; + /** @enum {string} */ + protocol_version: "ui-evidence-artifact.v1"; + redacted: boolean; + /** @enum {string} */ + retention_policy: "run_history"; + run_id: string; + sha256: string; + source_commit: string; + step_id: string; + untrusted: boolean; + viewport: components["schemas"]["UIEvidenceViewport"]; + /** Format: int32 */ + width?: number; + }; + UIEvidenceAttempt: { + /** Format: int64 */ + artifact_bytes: number; + /** Format: int32 */ + artifact_count: number; + cleanup: components["schemas"]["UIEvidenceCleanupReceipt"]; + /** Format: date-time */ + completed_at?: string; + /** Format: date-time */ + created_at: string; + diagnostics: components["schemas"]["UIEvidenceDiagnosticsSummary"]; + failure_code?: string; + failure_message?: string; + /** @enum {string} */ + failure_stage: "none" | "build" | "launch" | "readiness" | "navigation" | "selector" | "assertion" | "console" | "network" | "capture" | "cleanup"; + manifest: components["schemas"]["UIEvidenceManifest"]; + operation_digest: string; + /** @enum {string} */ + protocol_version: "ui-evidence-attempt.v1"; + request_fingerprint: string; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + status: "not_run" | "running" | "passed" | "failed" | "cancelled" | "timed_out" | "interrupted"; + /** Format: date-time */ + updated_at: string; + /** Format: int64 */ + version: number; + }; + UIEvidenceBrowserIdentity: { + /** @enum {string} */ + driver_protocol: "restricted-cdp-ui-evidence.v1"; + executable_sha256: string; + headless: boolean; + /** @enum {string} */ + product: "chrome" | "edge"; + temporary_profile: boolean; + version: string; + }; + UIEvidenceBrowserSelection: { + /** @enum {string} */ + channel: "stable" | "beta" | "dev" | "canary"; + /** @enum {string} */ + product: "chrome" | "edge"; + }; + UIEvidenceBundle: { + artifacts: components["schemas"]["UIEvidenceArtifactMetadata"][]; + attempt: components["schemas"]["UIEvidenceAttempt"]; + steps: components["schemas"]["UIEvidenceStepReceipt"][]; + }; + UIEvidenceCapturePolicy: { + accessibility: boolean; + console: boolean; + dom: boolean; + mask_selectors: string[]; + network: boolean; + performance: boolean; + screenshot: boolean; + video: boolean; + }; + UIEvidenceCleanupReceipt: { + application_tree_reaped: boolean; + browser_tree_reaped: boolean; + network_released: boolean; + port_released: boolean; + profile_removed: boolean; + }; + UIEvidenceCommandRecipe: { + canonical_argv: string[]; + /** @enum {string} */ + credentials: "none"; + environment_names: string[]; + environment_sha256: string; + executable_name: string; + executable_path_sha256: string; + executable_sha256: string; + fingerprint: string; + /** @enum {string} */ + network: "disabled"; + /** @enum {string} */ + profile: "powershell" | "bash" | "process"; + /** @enum {string} */ + protocol_version: "command-runtime.v2"; + purpose: string; + /** Format: int64 */ + timeout_milliseconds: number; + working_directory: string; + }; + UIEvidenceDiagnosticsSummary: { + /** Format: int32 */ + allowed_requests: number; + /** Format: int32 */ + blocked_requests: number; + /** Format: int32 */ + console_errors: number; + /** Format: int32 */ + console_warnings: number; + /** Format: int32 */ + failed_requests: number; + /** Format: int32 */ + http_failures: number; + /** Format: int32 */ + page_errors: number; + }; + UIEvidenceEnvironment: { + locale: string; + reduced_motion: boolean; + /** @enum {string} */ + theme: "light" | "dark"; + viewport: components["schemas"]["UIEvidenceViewport"]; + }; + UIEvidenceEvidenceAuthority: { + credential_access: boolean; + network_access: boolean; + personal_profile: boolean; + process_start: boolean; + request_mutation: boolean; + verification_pass: boolean; + }; + UIEvidenceFailurePolicy: { + fail_on_console_error: boolean; + fail_on_http_status: boolean; + fail_on_page_error: boolean; + fail_on_request_error: boolean; + }; + UIEvidenceFixture: { + data_sha256: string; + deterministic: boolean; + name: string; + page_state: string; + seed: string; + synthetic: boolean; + }; + UIEvidenceManifest: { + attempt_id: string; + authority: components["schemas"]["UIEvidenceEvidenceAuthority"]; + browser: components["schemas"]["UIEvidenceBrowserIdentity"]; + build?: components["schemas"]["UIEvidenceCommandRecipe"]; + capture: components["schemas"]["UIEvidenceCapturePolicy"]; + /** Format: date-time */ + created_at: string; + environment: components["schemas"]["UIEvidenceEnvironment"]; + failure_policy: components["schemas"]["UIEvidenceFailurePolicy"]; + fingerprint: string; + fixture: components["schemas"]["UIEvidenceFixture"]; + mission_id: string; + /** @enum {string} */ + protocol_version: "ui-evidence.v1"; + readiness: components["schemas"]["UIEvidenceReadiness"]; + route: string; + run_id: string; + session_id: string; + source: components["schemas"]["UIEvidenceSourceBinding"]; + start: components["schemas"]["UIEvidenceCommandRecipe"]; + steps: components["schemas"]["UIEvidenceStep"][]; + url: string; + workspace_id: string; + }; + UIEvidenceReadiness: { + expected_status: number[]; + /** Format: int64 */ + interval_milliseconds: number; + method: string; + /** Format: int64 */ + timeout_milliseconds: number; + url: string; + }; + UIEvidenceRuntimeStep: { + input?: string; + step: components["schemas"]["UIEvidenceStep"]; + }; + UIEvidenceSourceBinding: { + branch?: string; + commit: string; + dirty: boolean; + dirty_digest: string; + index_sha256: string; + manifest_sha256: string; + /** @enum {string} */ + repository_kind: "git" | "non_git"; + root_fingerprint: string; + }; + UIEvidenceStep: { + capture_after: boolean; + id: string; + input_sha256?: string; + /** @enum {string} */ + kind: "navigate" | "click" | "type" | "assert_present" | "assert_absent" | "capture"; + selector?: string; + }; + UIEvidenceStepReceipt: { + attempt_id: string; + /** Format: date-time */ + completed_at: string; + /** @enum {string} */ + failure_stage: "none" | "build" | "launch" | "readiness" | "navigation" | "selector" | "assertion" | "console" | "network" | "capture" | "cleanup"; + fingerprint: string; + /** @enum {string} */ + kind: "navigate" | "click" | "type" | "assert_present" | "assert_absent" | "capture"; + message?: string; + /** @enum {string} */ + protocol_version: "ui-evidence-step.v1"; + /** Format: int32 */ + sequence: number; + /** Format: date-time */ + started_at: string; + /** @enum {string} */ + status: "passed" | "failed" | "cancelled" | "timed_out"; + step_id: string; + }; + UIEvidenceViewport: { + /** Format: double */ + dpr: number; + /** Format: int32 */ + height: number; + /** Format: int32 */ + width: number; + }; VerificationAssociationControlView: { approval: boolean; /** Format: date-time */ @@ -6866,6 +7225,23 @@ export interface components { expected_live_fingerprint: string; target_path?: string; }; + uiEvidenceCancelView: { + confirm: boolean; + }; + uiEvidenceStartView: { + browser: components["schemas"]["UIEvidenceBrowserSelection"]; + build?: components["schemas"]["CommandRuntimeSpec"]; + capture: components["schemas"]["UIEvidenceCapturePolicy"]; + environment: components["schemas"]["UIEvidenceEnvironment"]; + failure_policy: components["schemas"]["UIEvidenceFailurePolicy"]; + fixture: components["schemas"]["UIEvidenceFixture"]; + operation_key: string; + readiness: components["schemas"]["UIEvidenceReadiness"]; + route: string; + start: components["schemas"]["CommandRuntimeSpec"]; + steps: components["schemas"]["UIEvidenceRuntimeStep"][]; + url: string; + }; workspaceCheckpointCaptureView: { operation_key: string; title?: string; @@ -10684,6 +11060,89 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + listRunUIEvidence: { + parameters: { + query?: { + /** @description Optional exact attempt status */ + status?: "not_run" | "running" | "passed" | "failed" | "cancelled" | "timed_out" | "interrupted"; + /** @description Maximum attempts */ + limit?: number; + }; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["UIEvidenceAttempt"][]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + startRunUIEvidence: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Run identity */ + run_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["uiEvidenceStartView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["UIEvidenceAttempt"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; listRunVerificationEvidence: { parameters: { query?: never; @@ -12214,6 +12673,118 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + getUIEvidence: { + parameters: { + query?: never; + header?: never; + path: { + /** @description UI evidence attempt identity */ + attempt_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["UIEvidenceBundle"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + downloadUIEvidenceArtifact: { + parameters: { + query?: never; + header?: never; + path: { + /** @description UI evidence attempt identity */ + attempt_id: string; + /** @description Artifact identity */ + artifact_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Hash-verified untrusted evidence bytes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + "application/octet-stream": string; + "image/png": string; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; + cancelUIEvidence: { + parameters: { + query?: never; + header?: never; + path: { + /** @description UI evidence attempt identity */ + attempt_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["uiEvidenceCancelView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["UIEvidenceAttempt"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; getWorkItem: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 873a50ae..f2861c14 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -60,6 +60,10 @@ export type FindingReportSummaryView = components["schemas"]["FindingReportSumma export type FindingReportView = components["schemas"]["FindingReportView"]; export type HealthView = components["schemas"]["HealthView"]; export type RuntimeCapabilitiesView = components["schemas"]["RuntimeCapabilitiesView"]; +export type UIEvidenceArtifactMetadata = components["schemas"]["UIEvidenceArtifactMetadata"]; +export type UIEvidenceAttempt = components["schemas"]["UIEvidenceAttempt"]; +export type UIEvidenceBundle = components["schemas"]["UIEvidenceBundle"]; +export type UIEvidenceStartView = components["schemas"]["uiEvidenceStartView"]; export type EmbeddedAnalyzerExecutionRequestView = components["schemas"]["EmbeddedAnalyzerExecutionRequestView"]; export type EmbeddedAnalyzerExecutionControlView = diff --git a/web/src/components/common.tsx b/web/src/components/common.tsx index a5f113d3..6a10c243 100644 --- a/web/src/components/common.tsx +++ b/web/src/components/common.tsx @@ -8,6 +8,7 @@ const chineseStatuses: Record = { prepared: "已准备", committed: "已提交", approved: "已批准", denied: "已拒绝", proposed: "待审阅", applied: "已应用", active: "活动", blocked: "受阻", waiting: "等待中", "waiting-approval": "等待审批", passed: "通过", + "not-run": "未运行", interrupted: "已中断", "timed-out": "已超时", unknown: "未知", available: "可用", unavailable: "不可用", qualified: "已验证", configured: "已配置", "not-configured": "未配置", "qualification-required": "需要验证", "trusted-builtin": "内置可信", diff --git a/web/src/components/connection-gate.test.tsx b/web/src/components/connection-gate.test.tsx index 568d8f1d..5c480fe7 100644 --- a/web/src/components/connection-gate.test.tsx +++ b/web/src/components/connection-gate.test.tsx @@ -90,6 +90,7 @@ describe("ConnectionGate", () => { skill_installation_enabled: false, evidence_attachment_enabled: false, verification_evidence_enabled: false, + ui_evidence_control_enabled: false, embedded_analyzer_execution_enabled: false, workspace_checkpoint_control_enabled: false, batch_delivery_control_enabled: false, @@ -141,6 +142,7 @@ function runtimeCapabilities() { run_wake_worker_enabled: false, skill_installation_enabled: true, evidence_attachment_enabled: true, process_execution_enabled: true, verification_evidence_enabled: true, embedded_analyzer_execution_enabled: true, + ui_evidence_control_enabled: true, workspace_checkpoint_control_enabled: true, batch_delivery_control_enabled: true, batch_delivery_host_validation_enabled: false, diff --git a/web/src/components/connection-gate.tsx b/web/src/components/connection-gate.tsx index be415c25..a2d153b5 100644 --- a/web/src/components/connection-gate.tsx +++ b/web/src/components/connection-gate.tsx @@ -66,6 +66,7 @@ export function ConnectionGate() { skillInstallationEnabled: bootstrap.skill_installation_enabled, evidenceAttachmentEnabled: bootstrap.evidence_attachment_enabled, verificationEvidenceEnabled: bootstrap.verification_evidence_enabled, + uiEvidenceControlEnabled: bootstrap.ui_evidence_control_enabled, embeddedAnalyzerExecutionEnabled: bootstrap.embedded_analyzer_execution_enabled, workspaceCheckpointControlEnabled: bootstrap.workspace_checkpoint_control_enabled, batchDeliveryControlEnabled: bootstrap.batch_delivery_control_enabled, diff --git a/web/src/components/run-workspace.tsx b/web/src/components/run-workspace.tsx index b72d2925..afb37881 100644 --- a/web/src/components/run-workspace.tsx +++ b/web/src/components/run-workspace.tsx @@ -91,10 +91,11 @@ import { EmbeddedAnalyzerPanel } from "./embedded-analyzer-panel"; import { DockerSandboxPanel } from "./docker-sandbox-panel"; import { ContextContinuityPanel } from "./context-continuity-panel"; import { WorkspaceCheckpointPanel } from "./workspace-checkpoint-panel"; +import { UIEvidencePanel } from "./ui-evidence-panel"; export type RunTab = "activity" | "overview" | "journey" | "actions" | "approvals" | "diffs" | "repository" | "files" | "evidence" | "verify" | "handoff" | "receipts" | "agents" | "delegations" | "fanout" | "findings" | "events" | "work" | - "context" | "checkpoints" | "notes" | "artifacts" | "tools" | "analyzer" | "child-tasks" | "sandbox"; + "context" | "checkpoints" | "notes" | "artifacts" | "tools" | "analyzer" | "child-tasks" | "sandbox" | "ui-evidence"; const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity }> = [ { id: "activity", label: ["活动", "Activity"], icon: MessageSquareText }, @@ -107,6 +108,7 @@ const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity } { id: "files", label: ["文件", "Files"], icon: FolderOpen }, { id: "evidence", label: ["证据", "Evidence"], icon: Paperclip }, { id: "verify", label: ["验证", "Verify"], icon: ClipboardCheck }, + { id: "ui-evidence", label: ["UI 证据", "UI evidence"], icon: View }, { id: "handoff", label: ["交接", "Handoff"], icon: BookOpenCheck }, { id: "receipts", label: ["操作收据", "Receipts"], icon: History }, { id: "checkpoints", label: ["工作区检查点", "Checkpoints"], icon: History }, @@ -381,6 +383,7 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: {
} + {tab === "ui-evidence" && } {tab === "handoff" && detail.mode.surface === "code" && } diff --git a/web/src/components/ui-evidence-panel.test.tsx b/web/src/components/ui-evidence-panel.test.tsx new file mode 100644 index 00000000..c85e9005 --- /dev/null +++ b/web/src/components/ui-evidence-panel.test.tsx @@ -0,0 +1,114 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { CyberAgentClient } from "../api/client"; +import type { UIEvidenceAttempt } from "../api/types"; +import { UIEvidencePanel } from "./ui-evidence-panel"; + +vi.mock("../lib/locale", () => ({ + useLocale: () => ({ t: (chinese: string) => chinese }), +})); + +function attempt(status: "not_run" | "passed", id: string): UIEvidenceAttempt { + const started = status === "passed" ? { started_at: "2026-08-20T00:00:01Z", + completed_at: "2026-08-20T00:00:02Z" } : {}; + return { + protocol_version: "ui-evidence-attempt.v1", + manifest: { + protocol_version: "ui-evidence.v1", attempt_id: id, run_id: "run-1", + mission_id: "mission-1", session_id: "session-1", workspace_id: "workspace-1", + source: { repository_kind: "git", commit: "a".repeat(40), branch: "codex/test", + dirty: false, dirty_digest: "b".repeat(64), root_fingerprint: "c".repeat(64), + index_sha256: "d".repeat(64), manifest_sha256: "e".repeat(64) }, + start: { protocol_version: "command-runtime.v2", profile: "powershell", + executable_name: "powershell.exe", executable_path_sha256: "1".repeat(64), + executable_sha256: "2".repeat(64), canonical_argv: ["powershell.exe", "-Command", "npm run dev"], + working_directory: "web", environment_names: [], environment_sha256: "3".repeat(64), + timeout_milliseconds: 60_000, network: "disabled", credentials: "none", + purpose: "UI evidence", fingerprint: "4".repeat(64) }, + readiness: { url: "http://127.0.0.1:4173/", method: "GET", + expected_status: [200], timeout_milliseconds: 60_000, interval_milliseconds: 250 }, + browser: { product: "edge", version: "140.0.0.0", executable_sha256: "5".repeat(64), + driver_protocol: "restricted-cdp-ui-evidence.v1", headless: true, + temporary_profile: true }, + url: "http://127.0.0.1:4173/", route: "/", environment: { + viewport: { width: 1440, height: 900, dpr: 1 }, locale: "en-US", + theme: "light", reduced_motion: false }, + fixture: { name: "fixture", seed: "seed", page_state: "{}", + data_sha256: "6".repeat(64), deterministic: true, synthetic: true }, + steps: [{ id: "navigate", kind: "navigate", capture_after: true }], + capture: { screenshot: true, dom: true, accessibility: true, console: true, + network: true, performance: true, video: false, mask_selectors: [] }, + failure_policy: { fail_on_console_error: true, fail_on_page_error: true, + fail_on_request_error: true, fail_on_http_status: true }, + authority: { process_start: false, network_access: false, credential_access: false, + personal_profile: false, request_mutation: false, verification_pass: false }, + created_at: "2026-08-20T00:00:00Z", fingerprint: "7".repeat(64), + }, + operation_digest: "8".repeat(64), request_fingerprint: "7".repeat(64), status, + failure_stage: "none", diagnostics: { console_warnings: 0, console_errors: 0, + page_errors: 0, failed_requests: 0, http_failures: 0, allowed_requests: 1, + blocked_requests: 0 }, cleanup: { browser_tree_reaped: status === "passed", + application_tree_reaped: status === "passed", profile_removed: status === "passed", + network_released: status === "passed", port_released: status === "passed" }, + artifact_count: status === "passed" ? 6 : 0, + artifact_bytes: status === "passed" ? 1_024 : 0, + version: status === "passed" ? 3 : 1, + created_at: "2026-08-20T00:00:00Z", updated_at: "2026-08-20T00:00:02Z", ...started, + } as UIEvidenceAttempt; +} + +function renderPanel(client: CyberAgentClient) { + return render(); +} + +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, + uiEvidence: vi.fn().mockResolvedValue([notRun, passed]), + uiEvidenceBundle: vi.fn().mockResolvedValue({ attempt: notRun, steps: [], artifacts: [] }), + } as unknown as CyberAgentClient; + + renderPanel(client); + + const notRunBadges = await screen.findAllByText("未运行"); + expect(notRunBadges.length).toBeGreaterThan(0); + for (const badge of notRunBadges) { + expect(badge).toHaveClass("status-not-run"); + expect(badge).not.toHaveClass("status-passed"); + } + expect(screen.getByText("通过")).toHaveClass("status-passed"); + expect(screen.getByText(/页面内容与下载产物均不可信/)).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, + uiEvidence: vi.fn().mockResolvedValue([]), + uiEvidenceBundle: vi.fn().mockResolvedValue({ attempt: created, steps: [], artifacts: [] }), + } as unknown as CyberAgentClient; + const user = userEvent.setup(); + renderPanel(client); + + await user.click(screen.getByText("审阅并启动精确清单")); + await user.click(screen.getByRole("button", { name: "载入本仓库模板" })); + const startButton = screen.getByRole("button", { name: "启动真实浏览器验证" }); + expect(startButton).toBeDisabled(); + await user.click(screen.getByRole("checkbox")); + expect(startButton).toBeEnabled(); + await user.click(startButton); + + await waitFor(() => expect(startUIEvidence).toHaveBeenCalledTimes(1)); + expect(startUIEvidence).toHaveBeenCalledWith("run-1", expect.objectContaining({ + url: "http://127.0.0.1:4173/", route: "/", + fixture: expect.objectContaining({ deterministic: true, synthetic: true }), + failure_policy: { fail_on_console_error: true, fail_on_page_error: true, + fail_on_request_error: true, fail_on_http_status: true }, + })); + }); +}); diff --git a/web/src/components/ui-evidence-panel.tsx b/web/src/components/ui-evidence-panel.tsx new file mode 100644 index 00000000..bc1681bf --- /dev/null +++ b/web/src/components/ui-evidence-panel.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Ban, + Camera, + Download, + FileJson, + LoaderCircle, + Play, + RefreshCw, + ShieldAlert, +} from "lucide-react"; +import type { CyberAgentClient } from "../api/client"; +import type { + UIEvidenceArtifactMetadata, + UIEvidenceAttempt, + UIEvidenceStartView, +} from "../api/types"; +import { formatBytes, formatDate, shortID } from "../lib/format"; +import { useLocale } from "../lib/locale"; +import { EmptyState, ErrorState, LoadingState, StatusBadge } from "./common"; + +const emptyFixtureSHA256 = + "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + +function templateRequest(): UIEvidenceStartView { + return { + operation_key: `desktop-ui-evidence-${globalThis.crypto.randomUUID()}`, + start: { + version: "command-runtime.v2", + profile: "powershell", + script: "npm run dev -- --host 127.0.0.1 --port 4173", + working_directory: "web", + environment: [], + stdin_policy: "closed", + close_initial_stdin: true, + timeout_milliseconds: 1_800_000, + output: { inline_bytes: 16_384, artifact_bytes: 262_144 }, + network: "disabled", + credentials: "none", + purpose: "Launch the reviewed Workspace web application for source-bound UI evidence", + }, + readiness: { + url: "http://127.0.0.1:4173/", + method: "GET", + expected_status: [200], + timeout_milliseconds: 60_000, + interval_milliseconds: 250, + }, + url: "http://127.0.0.1:4173/", + route: "/", + browser: { product: "edge", channel: "stable" }, + environment: { + viewport: { width: 1440, height: 900, dpr: 1 }, + locale: "en-US", + theme: "light", + reduced_motion: false, + }, + fixture: { + name: "empty-local-state", + seed: "ui-evidence-v1", + page_state: "{}", + data_sha256: emptyFixtureSHA256, + deterministic: true, + synthetic: true, + }, + steps: [{ + step: { id: "navigate", kind: "navigate", capture_after: true }, + }, { + step: { id: "app-root", kind: "assert_present", selector: "#root", capture_after: true }, + }], + capture: { + screenshot: true, + dom: true, + accessibility: true, + console: true, + network: true, + performance: true, + video: false, + mask_selectors: [], + }, + failure_policy: { + fail_on_console_error: true, + fail_on_page_error: true, + fail_on_request_error: true, + fail_on_http_status: true, + }, + }; +} + +export function UIEvidencePanel({ client, runID }: { + client: CyberAgentClient; + runID: string; +}) { + const { t } = useLocale(); + const queryClient = useQueryClient(); + const [selectedID, setSelectedID] = useState(""); + const [requestJSON, setRequestJSON] = useState(""); + const [reviewed, setReviewed] = useState(false); + const [localError, setLocalError] = useState(""); + const attempts = useQuery({ + queryKey: ["run", runID, "ui-evidence"], + queryFn: ({ signal }) => client.uiEvidence(runID, signal), + enabled: Boolean(runID), + refetchInterval: (query) => query.state.data?.some((attempt) => attempt.status === "running") + ? 1_500 : false, + }); + const activeID = selectedID || attempts.data?.[0]?.manifest.attempt_id || ""; + const bundle = useQuery({ + queryKey: ["ui-evidence", activeID], + queryFn: ({ signal }) => client.uiEvidenceBundle(activeID, signal), + enabled: Boolean(activeID), + refetchInterval: (query) => query.state.data?.attempt.status === "running" ? 1_500 : false, + }); + + useEffect(() => { + if (selectedID && attempts.data && !attempts.data.some( + (attempt) => attempt.manifest.attempt_id === selectedID)) { + setSelectedID(""); + } + }, [attempts.data, selectedID]); + + const refresh = async (attemptID?: string) => { + await queryClient.invalidateQueries({ queryKey: ["run", runID, "ui-evidence"] }); + if (attemptID) { + await queryClient.invalidateQueries({ queryKey: ["ui-evidence", attemptID] }); + } + }; + const start = useMutation({ + mutationFn: async () => { + const parsed: unknown = JSON.parse(requestJSON); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(t("启动清单必须是 JSON 对象", "The launch manifest must be a JSON object")); + } + return client.startUIEvidence(runID, parsed as UIEvidenceStartView); + }, + onSuccess: async (attempt) => { + setSelectedID(attempt.manifest.attempt_id); + setReviewed(false); + setLocalError(""); + await refresh(attempt.manifest.attempt_id); + }, + }); + const cancel = useMutation({ + mutationFn: (attemptID: string) => client.cancelUIEvidence(attemptID), + onSuccess: async (attempt) => refresh(attempt.manifest.attempt_id), + }); + + const submit = (event: FormEvent) => { + event.preventDefault(); + setLocalError(""); + if (!reviewed || requestJSON.trim() === "") return; + try { + JSON.parse(requestJSON); + } catch (caught) { + setLocalError(humanError(caught)); + return; + } + start.mutate(); + }; + + const download = async (artifact: UIEvidenceArtifactMetadata) => { + setLocalError(""); + try { + const content = await client.downloadUIEvidenceArtifact(artifact.attempt_id, artifact); + const objectURL = URL.createObjectURL(content); + const anchor = document.createElement("a"); + anchor.href = objectURL; + anchor.download = artifactFilename(artifact); + anchor.rel = "noopener"; + anchor.click(); + URL.revokeObjectURL(objectURL); + } catch (caught) { + setLocalError(humanError(caught)); + } + }; + + const current = bundle.data?.attempt; + const mutationError = start.error || cancel.error; + return
+
+
+ +
+
+
+ + {attempts.isLoading && } + {attempts.isError && } + {attempts.data?.length === 0 && {t("尚未创建 UI 验证 Attempt", "No UI evidence attempt has been created")}} + + {attempts.data && attempts.data.length > 0 &&
+
+ {attempts.data.map((attempt) => setSelectedID(attempt.manifest.attempt_id)} + selected={attempt.manifest.attempt_id === activeID} />)} +
+
+ {bundle.isLoading && } + {bundle.isError && } + {bundle.data && } +
+
} + + {current?.status === "running" && client.hasUIEvidence && } + +
+ {t("审阅并启动精确清单", "Review and start an exact manifest")} +

{t( + "模板面向本仓库的 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.", + )}

+ +
+