diff --git a/docs/PROGRESS_BOOK.md b/docs/PROGRESS_BOOK.md index 1bedbb08..85bf8601 100644 --- a/docs/PROGRESS_BOOK.md +++ b/docs/PROGRESS_BOOK.md @@ -316,7 +316,7 @@ P6 当前已推进到 schema v68:v63 固定阻塞态 start-gate 审查,v64 ### Agent 与运行时 - CLI 入口、命令分发、版本命令和 Run-first Bubble Tea TUI;选择器对 Run/Session 各限最近 50 条,Edits 对最近 20 条 FileEdit 提供 metadata/diff-only 只读详情和 128 KiB/4096 行显示上限。 -- Agent Kernel、Planner、Executor、Critic 与 Task/Event 类型边界。 +- Task/Event 类型、任务适配与历史事件读取;模型与工具执行循环由 `application.RunSupervisor` 承担。 - schema v19 为每个新 Run 原子创建稳定 root `AgentNode`;旧库可惰性补建。root 的 ready/running/waiting/completed/failed/cancelled、turn/token 用量和 active attempt 与 RunSupervisor/RunService 同事务投影。 - `internal/coordinator` 提供 root register、结构化 inbox send/consume、snapshot/restore;`run graph` 验证当前节点与 pending inbox metadata 是否匹配最新 `agent_graph.v1` 快照。 - 图最多 3 个节点、深度 1、每个 inbox 最多 128 条 pending/4096 条总历史、每批消费 32 条、快照保留 32 份;payload 递归脱敏并以 SHA-256 进入快照完整性校验。默认 Coordinator 无 admission capability,显式内部 policy 才能把 root capacity 提升到最多两个 child;Runner/context/scheduler 均只有 Go 内部显式入口,仍没有公开/model spawn。 diff --git a/docs/adr/0165-retire-legacy-command-execution.md b/docs/adr/0165-retire-legacy-command-execution.md index 0d92580f..81fe54da 100644 --- a/docs/adr/0165-retire-legacy-command-execution.md +++ b/docs/adr/0165-retire-legacy-command-execution.md @@ -17,6 +17,11 @@ flags and capability fields. Fixed operator commands use Command Runtime. `command-plan`, historical `command-proposal list/show`, and `once-command proposals` remain non-executing reads. +Remove uncalled request constructors and their producer-only validation helpers +as well. Historical tests construct saved records directly, retaining record +validation and fingerprint checks. The retired Once spec/request/approval hash +helpers are unnecessary for reading the stored fingerprint fields. + ## Retained data and continuation Keep schema migrations, immutable proposal/approval/grant/intent/result tables, diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index 86d023ad..3f999357 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -11,7 +11,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [agent-browser-supervisor-ledger](#agent-browser-supervisor-ledger) | `internal-durable` | Agent browser, Supervisor tool, and persistence maintainers | 11 | true | | [agent-scheduling-delivery-ledgers](#agent-scheduling-delivery-ledgers) | `internal-durable` | Agent graph, scheduler, and batch-delivery maintainers | 73 | true | | [analyzer-interchange](#analyzer-interchange) | `external-durable` | Analyzer contract maintainers | 40 | true | -| [authority-approval-ledgers](#authority-approval-ledgers) | `internal-durable` | Execution authority and approval maintainers | 52 | true | +| [authority-approval-ledgers](#authority-approval-ledgers) | `internal-durable` | Execution authority and approval maintainers | 49 | true | | [browser-cdp-process-session](#browser-cdp-process-session) | `ephemeral` | Browser runtime maintainers | 23 | false | | [browser-ui-evidence-ledgers](#browser-ui-evidence-ledgers) | `internal-durable` | Browser and UI evidence maintainers | 44 | true | | [capability-readiness-projection](#capability-readiness-projection) | `projection` | Application readiness maintainers | 4 | true | @@ -19,7 +19,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [control-plane-ledgers](#control-plane-ledgers) | `internal-durable` | Core Go control-plane maintainers | 111 | true | | [credential-provider-ledgers](#credential-provider-ledgers) | `internal-durable` | Credential, provider, model-route, and pricing maintainers | 23 | true | | [desktop-risk-restart-session](#desktop-risk-restart-session) | `ephemeral` | Desktop shell lifecycle maintainers | 1 | false | -| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 15 | true | +| [desktop-web-presentation-state](#desktop-web-presentation-state) | `projection` | Desktop and React workbench maintainers | 14 | true | | [docker-attach-process-session](#docker-attach-process-session) | `ephemeral` | Docker runtime transport maintainers | 1 | false | | [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true | | [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 47 | true | @@ -266,7 +266,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `universal-tool-binding-reader` (`v1`, active) at `internal/executionauth` - `approval-preference-reader` (`v1, v2`, active) at `internal/domain` -
52 active identifiers +
49 active identifiers - `approval_grant_consumption.v1` - `approval_grant_operation_key.v1` @@ -275,10 +275,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `executionauth.authorization.v1` - `full-cdp-session-operation.v1` - `once_command.v1` -- `once_command_approval.v1` - `once_command_policy.v1` -- `once_command_request.v1` -- `once_command_spec.v1` - `operator_steering_cancellation_operation.v1` - `operator_steering_cancellation_request.v1` - `operator_steering_delivery_mode.v1` @@ -323,6 +320,14 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi
+
3 retained retirement records + +- `once_command_approval.v1`: ADR 0165 retires the Once execution producer; remove its uncalled approval fingerprint helper. +- `once_command_request.v1`: ADR 0165 retires the Once execution producer; remove its uncalled request fingerprint helper. +- `once_command_spec.v1`: ADR 0165 retires the Once execution producer; remove its uncalled command-spec fingerprint helper. + +
+ ### browser-cdp-process-session - Class: `ephemeral` @@ -689,7 +694,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - Readers: - `desktop-web-presentation-state-reader` (`v1`, active) at `web/src` -
15 active identifiers +
14 active identifiers - `desktop_clipboard_files.v1` - `desktop_connection_bootstrap.v1` @@ -704,13 +709,13 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `desktop_workspace_launcher_list.v1` - `desktop_workspace_open.v1` - `prayu.run-navigation.v1` -- `prayu.settings.sidebar.width.v1` - `v2_file_upload.v1`
-
1 retained retirement records +
2 retained retirement records +- `prayu.settings.sidebar.width.v1`: Remove the unreachable legacy SettingsView and its private sidebar-width preference reader/writer. - `prayu.sidebar.width.v1`: The authorized unified-shell replacement removed the legacy App sidebar reader/writer. This is presentation retirement only; existing localStorage values are neither deleted nor migrated.
diff --git a/internal/agent/critic.go b/internal/agent/critic.go deleted file mode 100644 index 4870663c..00000000 --- a/internal/agent/critic.go +++ /dev/null @@ -1,7 +0,0 @@ -package agent - -type Critic struct{} - -func NewCritic() Critic { - return Critic{} -} diff --git a/internal/agent/executor.go b/internal/agent/executor.go deleted file mode 100644 index c3fbf61a..00000000 --- a/internal/agent/executor.go +++ /dev/null @@ -1,7 +0,0 @@ -package agent - -type Executor struct{} - -func NewExecutor() Executor { - return Executor{} -} diff --git a/internal/agent/kernel.go b/internal/agent/kernel.go deleted file mode 100644 index b42ac129..00000000 --- a/internal/agent/kernel.go +++ /dev/null @@ -1,94 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "cyberagent-workbench/internal/llm" - "cyberagent-workbench/internal/policy" -) - -type Store interface { - GetTask(ctx context.Context, id string) (Task, error) - UpdateTaskStatus(ctx context.Context, id string, status string) error - RecordEvent(ctx context.Context, event Event) error -} - -type Kernel struct { - store Store - router *llm.Router - checker policy.Checker -} - -func NewKernel(store Store, router *llm.Router, checker policy.Checker) *Kernel { - return &Kernel{store: store, router: router, checker: checker} -} - -func (k *Kernel) Step(ctx context.Context, taskID string) error { - task, err := k.store.GetTask(ctx, taskID) - if err != nil { - return err - } - - if err := k.store.UpdateTaskStatus(ctx, task.ID, StatusRunning); err != nil { - return err - } - k.record(ctx, task, "task.running", "task moved to running", nil) - - req := llm.ChatRequest{ - Messages: []llm.Message{ - {Role: "system", Content: "You are the Traverse Board mock planner. Return safe, concise next steps."}, - {Role: "user", Content: fmt.Sprintf("kind=%s mode=%s goal=%s", task.Kind, task.Mode, task.Goal)}, - }, - Metadata: map[string]string{ - "task_id": task.ID, - "workspace_id": task.WorkspaceID, - }, - } - - resp, err := k.router.Chat(ctx, string(task.Kind), req) - if err != nil { - _ = k.store.UpdateTaskStatus(ctx, task.ID, StatusFailed) - k.record(ctx, task, "model.error", err.Error(), nil) - return err - } - - decision := k.checker.CheckText("model_response", resp.Text) - k.record(ctx, task, "policy.decision", decision.Reason, decision) - if !decision.Allowed { - _ = k.store.UpdateTaskStatus(ctx, task.ID, StatusDenied) - return fmt.Errorf("policy denied model response: %s", decision.Reason) - } - - k.record(ctx, task, "model.response", resp.Text, map[string]any{ - "model": resp.Model, - "provider": resp.Provider, - "usage": resp.Usage, - "tool_calls": resp.ToolCalls, - }) - - if err := k.store.UpdateTaskStatus(ctx, task.ID, StatusCompleted); err != nil { - return err - } - k.record(ctx, task, "task.completed", "task completed by mock agent step", nil) - return nil -} - -func (k *Kernel) record(ctx context.Context, task Task, typ string, message string, payload any) { - var payloadJSON string - if payload != nil { - if b, err := json.Marshal(payload); err == nil { - payloadJSON = string(b) - } - } - _ = k.store.RecordEvent(ctx, Event{ - TaskID: task.ID, - WorkspaceID: task.WorkspaceID, - Type: typ, - Message: message, - PayloadJSON: payloadJSON, - CreatedAt: time.Now().UTC(), - }) -} diff --git a/internal/agent/planner.go b/internal/agent/planner.go deleted file mode 100644 index e35ba4ff..00000000 --- a/internal/agent/planner.go +++ /dev/null @@ -1,14 +0,0 @@ -package agent - -import "cyberagent-workbench/internal/tools" - -type Plan struct { - Summary string - ToolCalls []tools.Call -} - -type Planner struct{} - -func NewPlanner() Planner { - return Planner{} -} diff --git a/internal/app/app.go b/internal/app/app.go index b35d00cd..76fa8e14 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,7 +56,6 @@ type App struct { models *modelregistry.Registry credentials credential.Store checker policy.Checker - kernel *agent.Kernel calls *application.ActiveCallRegistry dockerObserver sandbox.DockerProductionObserver dockerWriteTransport sandbox.DockerContainerWriteTransport @@ -418,7 +417,6 @@ func (a *App) ensureStore() error { _ = st.Close() return err } - a.kernel = agent.NewKernel(st, a.router, a.checker) if err := a.ensureCodeIntel(); err != nil { a.store = nil _ = st.Close() diff --git a/internal/app/script_command_test.go b/internal/app/script_command_test.go index 5bb7fc71..e44cda5c 100644 --- a/internal/app/script_command_test.go +++ b/internal/app/script_command_test.go @@ -43,7 +43,7 @@ func TestLegacyTemplateCommandsDoNotCallModelOrClaimCompletion(t *testing.T) { var out, errOut bytes.Buffer checker := policy.NewDefaultChecker() a := &App{home: home, out: &out, errOut: &errOut, store: st, router: router, - checker: checker, kernel: agent.NewKernel(st, router, checker)} + checker: checker} if err := a.dispatch(t.Context(), []string{ "script", "new", "Create a Python add function", "--workspace", "audit", "--language", "python", diff --git a/internal/application/command_history_test.go b/internal/application/command_history_test.go index 15a77fb7..8af26042 100644 --- a/internal/application/command_history_test.go +++ b/internal/application/command_history_test.go @@ -64,8 +64,13 @@ func hostHandoffRecordedFixture(t *testing.T) *hostHistoryFixture { if err := proposal.Validate(); err != nil { t.Fatal(err) } - review, err := runner.NewHostCommandReview("review-history", proposal, runner.HostCommandReviewApprove, "test_operator", "saved decision", strings.Repeat("b", 64), now) - if err != nil { + review := runner.HostCommandReview{ID: "review-history", ProtocolVersion: runner.HostCommandReviewProtocolVersion, + PolicyVersion: runner.HostCommandPolicyVersion, ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, + RunID: proposal.RunID, Decision: runner.HostCommandReviewApprove, ReviewedBy: "test_operator", Reason: "saved decision", + OperationKeyDigest: strings.Repeat("b", 64), SingleUseExecutionAuthorized: true, CreatedAt: now} + review.RequestFingerprint = runner.HostCommandReviewRequestFingerprint(review) + review.Fingerprint = runner.HostCommandReviewFingerprint(review) + if err := review.Validate(); err != nil { t.Fatal(err) } receipt := runner.HostExecutionReceipt{RequestID: "request-history", ProtocolVersion: runner.HostCommandReceiptProtocolVersion, @@ -76,9 +81,13 @@ func hostHandoffRecordedFixture(t *testing.T) *hostHistoryFixture { if err := receipt.Validate(); err != nil { t.Fatal(err) } - result, err := runner.NewHostCommandProposalResult("result-history", proposal, review, receipt.RequestID, "completed", - session.SourceGoCommandResult, "host-command-proposal:"+proposal.ID, strings.Repeat("c", 64), now) - if err != nil { + result := runner.HostCommandProposalResult{ID: "result-history", ProtocolVersion: runner.HostCommandResultProtocolVersion, + PolicyVersion: runner.HostCommandPolicyVersion, ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, + ReviewID: review.ID, ReviewFingerprint: review.Fingerprint, RequestID: receipt.RequestID, + RunID: proposal.RunID, SessionID: proposal.SessionID, Status: "completed", SourceKind: session.SourceGoCommandResult, + SourceRef: "host-command-proposal:" + proposal.ID, ContentSHA256: strings.Repeat("c", 64), CreatedAt: now} + result.Fingerprint = runner.HostCommandProposalResultFingerprint(result) + if err := result.Validate(); err != nil { t.Fatal(err) } return &hostHistoryFixture{proposal: proposal, review: &review, result: &result, receipt: &receipt, diff --git a/internal/httpapi/host_command_saved_output_test.go b/internal/httpapi/host_command_saved_output_test.go index c67e09d9..f8371405 100644 --- a/internal/httpapi/host_command_saved_output_test.go +++ b/internal/httpapi/host_command_saved_output_test.go @@ -26,7 +26,10 @@ func TestHostCommandSavedOutputProjectionRequiresExactSavedIdentity(t *testing.T receipt.ProtocolVersion, receipt.PolicyVersion = runner.HostCommandReceiptProtocolVersion, runner.HostExecutionPolicyVersion receipt.StdoutCapturedBytes, receipt.StdoutObservedBytes = 6, 6 view.Receipt = receipt - output := runner.NewHostCommandSavedOutput(runner.HostExecutionResult{Stdout: runner.ControlledOutput{Data: []byte("中文")}}) + output := runner.HostCommandSavedOutput{ + Stdout: runner.HostCommandSavedStream{Text: "中文", Redacted: true}, + Stderr: runner.HostCommandSavedStream{Redacted: true}, + } result := runner.HostCommandProposalResult{ ID: "host-result-saved-output", ProtocolVersion: runner.HostCommandResultProtocolVersion, PolicyVersion: runner.HostCommandPolicyVersion, ProposalID: view.Proposal.ID, diff --git a/internal/runner/controlled_command_proposal.go b/internal/runner/controlled_command_proposal.go index 3ca7850c..032c1813 100644 --- a/internal/runner/controlled_command_proposal.go +++ b/internal/runner/controlled_command_proposal.go @@ -11,7 +11,6 @@ import ( "unicode/utf8" "cyberagent-workbench/internal/domain" - "cyberagent-workbench/internal/redact" ) const ( @@ -27,18 +26,6 @@ const ( var ErrControlledCommandProposalBoundary = errors.New( "controlled command proposal boundary is invalid") -type ControlledCommandProposalRequest struct { - ID string - Plan ControlledCommandPlan - MissionID string - SessionID string - RootAgentID string - Permission domain.RunExecutionPermissionSnapshot - Purpose string - RequestedBy string - CreatedAt time.Time -} - // ControlledCommandProposal is a non-authorizing request for one Go-owned // command template. It intentionally contains no executable, argv, shell text, // environment, network, or persistence field. @@ -71,57 +58,6 @@ type ControlledCommandProposal struct { CreatedAt time.Time } -func NewControlledCommandProposal( - request ControlledCommandProposalRequest, -) (ControlledCommandProposal, error) { - request.ID = strings.TrimSpace(request.ID) - request.MissionID = strings.TrimSpace(request.MissionID) - request.SessionID = strings.TrimSpace(request.SessionID) - request.RootAgentID = strings.TrimSpace(request.RootAgentID) - request.RequestedBy = strings.TrimSpace(request.RequestedBy) - request.Purpose = strings.TrimSpace(redact.String(request.Purpose)) - request.CreatedAt = request.CreatedAt.UTC() - if err := request.Plan.Validate(); err != nil { - return ControlledCommandProposal{}, err - } - if err := request.Permission.Validate(); err != nil { - return ControlledCommandProposal{}, err - } - if request.Permission.RunID != request.Plan.RunID || - request.Permission.MissionID != request.MissionID || - !domain.ValidAgentID(request.SessionID) || - !domain.ValidAgentID(request.RootAgentID) || - request.RequestedBy != "run_supervisor" { - return ControlledCommandProposal{}, ErrControlledCommandProposalBoundary - } - proposal := ControlledCommandProposal{ - ID: request.ID, ProtocolVersion: ControlledCommandProposalProtocolVersion, - PolicyVersion: ControlledCommandProposalPolicyVersion, - RunID: request.Plan.RunID, MissionID: request.MissionID, - SessionID: request.SessionID, WorkspaceID: request.Plan.WorkspaceID, - RootAgentID: request.RootAgentID, - InteractionSnapshotID: request.Plan.InteractionSnapshotID, - InteractionRevision: request.Plan.InteractionRevision, - ExecutionProfileRevision: request.Plan.ExecutionProfileRevision, - PermissionSnapshotID: request.Permission.ID, - PermissionRevision: request.Permission.Revision, - PermissionMode: request.Permission.Mode, - PlanID: request.Plan.ID, - PlanFingerprint: request.Plan.Fingerprint, - Kind: request.Plan.Kind, - RelativePath: request.Plan.RelativePath, - TimeoutMilliseconds: request.Plan.TimeoutMilliseconds, - Purpose: request.Purpose, - RequestedBy: request.RequestedBy, - CreatedAt: request.CreatedAt, - } - proposal.Fingerprint = ControlledCommandProposalFingerprint(proposal) - if err := proposal.Validate(); err != nil { - return ControlledCommandProposal{}, err - } - return proposal, nil -} - func (p ControlledCommandProposal) Validate() error { for _, value := range []string{ p.ID, p.RunID, p.MissionID, p.SessionID, p.WorkspaceID, p.RootAgentID, @@ -178,53 +114,6 @@ func ControlledCommandProposalFingerprint( return hex.EncodeToString(digest[:]) } -func ControlledCommandProposalRequestFingerprint( - proposal ControlledCommandProposal, -) string { - semantic := struct { - ProtocolVersion string - PolicyVersion string - RunID string - MissionID string - SessionID string - WorkspaceID string - RootAgentID string - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileRevision int64 - PermissionSnapshotID string - PermissionRevision int64 - PermissionMode domain.RunExecutionPermissionMode - PlanFingerprint string - Kind ControlledCommandKind - RelativePath string - TimeoutMilliseconds int64 - Purpose string - RequestedBy string - }{ - ProtocolVersion: proposal.ProtocolVersion, PolicyVersion: proposal.PolicyVersion, - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - RootAgentID: proposal.RootAgentID, - InteractionSnapshotID: proposal.InteractionSnapshotID, - InteractionRevision: proposal.InteractionRevision, - ExecutionProfileRevision: proposal.ExecutionProfileRevision, - PermissionSnapshotID: proposal.PermissionSnapshotID, - PermissionRevision: proposal.PermissionRevision, - PermissionMode: proposal.PermissionMode, - PlanFingerprint: proposal.PlanFingerprint, Kind: proposal.Kind, - RelativePath: proposal.RelativePath, - TimeoutMilliseconds: proposal.TimeoutMilliseconds, - Purpose: proposal.Purpose, RequestedBy: proposal.RequestedBy, - } - encoded, err := json.Marshal(semantic) - if err != nil { - return "" - } - digest := sha256.Sum256(encoded) - return hex.EncodeToString(digest[:]) -} - type ControlledCommandProposalOperation struct { KeyDigest string RequestFingerprint string @@ -288,46 +177,6 @@ type ControlledCommandProposalReview struct { CreatedAt time.Time } -func NewControlledCommandProposalReview( - id string, - proposal ControlledCommandProposal, - decision ControlledCommandReviewDecision, - reviewedBy string, - reason string, - operationKeyDigest string, - at time.Time, -) (ControlledCommandProposalReview, error) { - if err := proposal.Validate(); err != nil { - return ControlledCommandProposalReview{}, err - } - reviewedBy = strings.TrimSpace(reviewedBy) - reason = strings.TrimSpace(redact.String(reason)) - if reason == "" { - if decision == ControlledCommandReviewApprove { - reason = "operator approved the exact fixed command proposal" - } else { - reason = "operator denied the fixed command proposal" - } - } - review := ControlledCommandProposalReview{ - ID: strings.TrimSpace(id), - ProtocolVersion: ControlledCommandReviewProtocolVersion, - PolicyVersion: ControlledCommandProposalPolicyVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - Decision: decision, ReviewedBy: reviewedBy, Reason: reason, - OperationKeyDigest: operationKeyDigest, - SingleUseExecutionAuthorized: decision == ControlledCommandReviewApprove, - CreatedAt: at.UTC(), - } - review.RequestFingerprint = ControlledCommandReviewRequestFingerprint(review) - if err := review.Validate(); err != nil { - return ControlledCommandProposalReview{}, err - } - return review, nil -} - func (r ControlledCommandProposalReview) Validate() error { for _, value := range []string{ r.ID, r.ProposalID, r.RunID, r.MissionID, r.SessionID, r.WorkspaceID, @@ -423,42 +272,6 @@ type ControlledCommandProposalResult struct { CreatedAt time.Time } -func NewControlledCommandProposalResult( - id string, - proposal ControlledCommandProposal, - review ControlledCommandProposalReview, - execution ControlledExecutionResult, - sessionMessageID int64, - sourceKind string, - sourceRef string, - contentSHA256 string, - at time.Time, -) (ControlledCommandProposalResult, error) { - status := ControlledCommandProposalResultCompleted - if execution.ExitCode != 0 || execution.TimedOut || execution.Cancelled || - execution.OutputLimitExceeded { - status = ControlledCommandProposalResultFailed - } - result := ControlledCommandProposalResult{ - ID: strings.TrimSpace(id), - ProtocolVersion: ControlledCommandResultProtocolVersion, - PolicyVersion: ControlledCommandProposalPolicyVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - ReviewID: review.ID, RequestID: execution.RequestID, - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - SessionMessageID: sessionMessageID, Status: status, - SourceKind: strings.TrimSpace(sourceKind), - SourceRef: strings.TrimSpace(sourceRef), - ContentSHA256: strings.TrimSpace(contentSHA256), - CreatedAt: at.UTC(), - } - if err := result.Validate(); err != nil { - return ControlledCommandProposalResult{}, err - } - return result, nil -} - func (r ControlledCommandProposalResult) Validate() error { for _, value := range []string{ r.ID, r.ProposalID, r.ReviewID, r.RequestID, r.RunID, r.MissionID, diff --git a/internal/runner/controlled_command_proposal_test.go b/internal/runner/controlled_command_proposal_test.go index 9b0a9fe9..3890f5b8 100644 --- a/internal/runner/controlled_command_proposal_test.go +++ b/internal/runner/controlled_command_proposal_test.go @@ -7,99 +7,55 @@ import ( "cyberagent-workbench/internal/domain" ) -func TestControlledCommandProposalSealsFixedIntent(t *testing.T) { - now := time.Now().UTC() - request := controlledCommandTestRequest(t, ControlledCommandGitStatus) - request.ID = "plan-proposal" - plan, err := PlanControlledCommand(request) - if err != nil { +func TestHistoricalControlledCommandProposalRejectsAlteredIntent(t *testing.T) { + proposal := controlledCommandProposalFixture() + if err := proposal.Validate(); err != nil { t.Fatal(err) } - run := domain.Run{ID: plan.RunID, MissionID: request.Interaction.MissionID} - mission := domain.Mission{ID: run.MissionID} - permission, err := domain.NewInitialRunExecutionPermissionSnapshot( - "permission-proposal", run, mission, "schema_v88", now) - if err != nil { - t.Fatal(err) - } - proposal, err := NewControlledCommandProposal( - ControlledCommandProposalRequest{ - ID: "command-proposal", Plan: plan, MissionID: mission.ID, - SessionID: "session-proposal", RootAgentID: "agent-root-proposal", - Permission: permission, Purpose: "inspect repository status", - RequestedBy: "run_supervisor", CreatedAt: now, - }) - if err != nil { - t.Fatal(err) - } - if proposal.ExecutionAuthorized || proposal.InstructionAuthorized || - proposal.CapabilityGrant || proposal.Kind != ControlledCommandGitStatus || - proposal.Fingerprint == "" { - t.Fatalf("unexpected proposal: %+v", proposal) - } - tampered := proposal - tampered.Kind = ControlledCommandGoVersion - if err := tampered.Validate(); err == nil { - t.Fatal("tampered proposal unexpectedly validated") + proposal.Kind = ControlledCommandGitStatus + if err := proposal.Validate(); err == nil { + t.Fatal("tampered historical proposal unexpectedly validated") } } -func TestControlledCommandProposalReviewIsSingleUseAndImmutable(t *testing.T) { - proposal := controlledCommandProposalFixture(t) - review, err := NewControlledCommandProposalReview( - "command-review", proposal, ControlledCommandReviewApprove, - "cli_operator", "approved for exact execution", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - time.Now().UTC()) - if err != nil { +func TestHistoricalControlledCommandReviewIsSingleUseAndImmutable(t *testing.T) { + proposal := controlledCommandProposalFixture() + review := ControlledCommandProposalReview{ + ID: "command-review", ProtocolVersion: ControlledCommandReviewProtocolVersion, + PolicyVersion: ControlledCommandProposalPolicyVersion, ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, + RunID: proposal.RunID, MissionID: proposal.MissionID, SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, + Decision: ControlledCommandReviewApprove, ReviewedBy: "cli_operator", Reason: "approved for exact execution", + OperationKeyDigest: hostCommandTestDigest, SingleUseExecutionAuthorized: true, CreatedAt: proposal.CreatedAt, + } + review.RequestFingerprint = ControlledCommandReviewRequestFingerprint(review) + if err := review.Validate(); err != nil { t.Fatal(err) } - if !review.SingleUseExecutionAuthorized || review.CapabilityGrant { - t.Fatalf("unexpected review authority: %+v", review) - } tampered := review tampered.Decision = ControlledCommandReviewDeny if err := tampered.Validate(); err == nil { - t.Fatal("tampered review unexpectedly validated") + t.Fatal("tampered historical review unexpectedly validated") } - for _, reviewer := range []string{ - "agent", "model", "repository", "skill", "supervisor", "run_supervisor", - } { - if _, err := NewControlledCommandProposalReview( - "command-review-"+reviewer, proposal, - ControlledCommandReviewApprove, reviewer, "must be rejected", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - time.Now().UTC()); err == nil { + for _, reviewer := range []string{"agent", "model", "repository", "skill", "supervisor", "run_supervisor"} { + tampered := review + tampered.ReviewedBy = reviewer + tampered.RequestFingerprint = ControlledCommandReviewRequestFingerprint(tampered) + if err := tampered.Validate(); err == nil { t.Fatalf("reserved reviewer %q unexpectedly validated", reviewer) } } } -func controlledCommandProposalFixture(t *testing.T) ControlledCommandProposal { - t.Helper() - now := time.Now().UTC() - request := controlledCommandTestRequest(t, ControlledCommandGoVersion) - request.ID = "plan-fixture" - plan, err := PlanControlledCommand(request) - if err != nil { - t.Fatal(err) - } - run := domain.Run{ID: plan.RunID, MissionID: request.Interaction.MissionID} - mission := domain.Mission{ID: run.MissionID} - permission, err := domain.NewInitialRunExecutionPermissionSnapshot( - "permission-fixture", run, mission, "schema_v88", now) - if err != nil { - t.Fatal(err) - } - proposal, err := NewControlledCommandProposal( - ControlledCommandProposalRequest{ - ID: "proposal-fixture", Plan: plan, MissionID: mission.ID, - SessionID: "session-fixture", RootAgentID: "agent-root-fixture", - Permission: permission, Purpose: "inspect Go toolchain version", - RequestedBy: "run_supervisor", CreatedAt: now, - }) - if err != nil { - t.Fatal(err) - } +func controlledCommandProposalFixture() ControlledCommandProposal { + proposal := ControlledCommandProposal{ + ID: "proposal-fixture", ProtocolVersion: ControlledCommandProposalProtocolVersion, PolicyVersion: ControlledCommandProposalPolicyVersion, + RunID: "run-fixture", MissionID: "mission-fixture", SessionID: "session-fixture", WorkspaceID: "workspace-fixture", RootAgentID: "agent-root-fixture", + InteractionSnapshotID: "interaction-fixture", InteractionRevision: 1, ExecutionProfileRevision: 1, + PermissionSnapshotID: "permission-fixture", PermissionRevision: 1, PermissionMode: domain.RunExecutionPermissionConservative, + PlanID: "plan-fixture", PlanFingerprint: hostCommandTestDigest, Kind: ControlledCommandGoVersion, + TimeoutMilliseconds: 1000, Purpose: "inspect Go toolchain version", RequestedBy: "run_supervisor", + CreatedAt: time.Date(2026, 7, 26, 13, 0, 0, 0, time.UTC), + } + proposal.Fingerprint = ControlledCommandProposalFingerprint(proposal) return proposal } diff --git a/internal/runner/controlled_execution.go b/internal/runner/controlled_execution.go index 6c935141..7a7c4510 100644 --- a/internal/runner/controlled_execution.go +++ b/internal/runner/controlled_execution.go @@ -37,31 +37,6 @@ type ControlledExecutionIntent struct { CreatedAt time.Time } -func NewControlledExecutionIntent(plan ControlledCommandPlan, - requestedBy string, createdAt time.Time, -) (ControlledExecutionIntent, error) { - if err := plan.Validate(); err != nil || !validExecutionOperator(requestedBy) || - createdAt.IsZero() { - return ControlledExecutionIntent{}, ErrControlledExecutionBoundary - } - intent := ControlledExecutionIntent{ - ProtocolVersion: ControlledExecutionIntentProtocolVersion, - PolicyVersion: ControlledExecutionPolicyVersion, - RequestID: ControlledExecutionRequestID(plan), - PlanID: plan.ID, PlanFingerprint: plan.Fingerprint, - RunID: plan.RunID, WorkspaceID: plan.WorkspaceID, - InteractionSnapshotID: plan.InteractionSnapshotID, - InteractionRevision: plan.InteractionRevision, - ExecutionProfileRevision: plan.ExecutionProfileRevision, - Kind: plan.Kind, RequestedBy: requestedBy, - CreatedAt: createdAt.UTC(), - } - if err := intent.Validate(); err != nil { - return ControlledExecutionIntent{}, err - } - return intent, nil -} - func ControlledExecutionRequestID(plan ControlledCommandPlan) string { if !validSHA256(plan.Fingerprint) { return "" @@ -92,19 +67,6 @@ var ( ErrControlledOutputLimit = errors.New("controlled command output limit exceeded") ) -// ControlledExecutionRequest is an operator-only, one-shot use of a closed -// command plan. It deliberately repeats the current durable bindings so a -// stale plan cannot be started after a Run mode or profile transition. -type ControlledExecutionRequest struct { - Plan ControlledCommandPlan - WorkspaceRoot string - Interaction domain.RunExecutionInteractionSnapshot - CurrentProfile domain.RunExecutionProfileSnapshot - CurrentSurface domain.ExecutionSurface - RequestedBy string - OperatorConfirmed bool -} - type ControlledStartSpec struct { RequestID string PlanID string @@ -194,89 +156,6 @@ func (o ControlledOutput) Validate() error { return nil } -type ControlledStartResult struct { - ExitCode int - Stdout ControlledOutput - Stderr ControlledOutput - StartedAt time.Time - CompletedAt time.Time - TimedOut bool - Cancelled bool - OutputLimitExceeded bool - TreeReaped bool - RestrictedToken bool - LowIntegrityToken bool - JobAssignedAtCreation bool - KillOnJobClose bool - ActiveProcessLimit int - ProcessMemoryLimit int64 - StdinClosed bool - EnvironmentInherited bool - NetworkRequested bool - PersistentProcess bool - ProductExecutionEnabled bool -} - -func (r ControlledStartResult) Validate() error { - if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || - !r.TreeReaped || !r.RestrictedToken || !r.LowIntegrityToken || - !r.JobAssignedAtCreation || !r.KillOnJobClose || - r.ActiveProcessLimit != 1 || - r.ProcessMemoryLimit != MaxControlledProcessMemoryBytes || - !r.StdinClosed || r.EnvironmentInherited || r.NetworkRequested || - r.PersistentProcess || !r.ProductExecutionEnabled || - (r.TimedOut && r.Cancelled) { - return ErrControlledExecutionBoundary - } - if err := r.Stdout.Validate(); err != nil { - return err - } - if err := r.Stderr.Validate(); err != nil { - return err - } - if r.OutputLimitExceeded && - r.Stdout.ObservedBytes != MaxControlledOutputObservedBytes && - r.Stderr.ObservedBytes != MaxControlledOutputObservedBytes { - return ErrControlledExecutionBoundary - } - return nil -} - -type ControlledExecutionResult struct { - ProtocolVersion string - PolicyVersion string - RequestID string - PlanID string - PlanFingerprint string - RunID string - WorkspaceID string - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileRevision int64 - Kind ControlledCommandKind - Backend string - ExitCode int - Stdout ControlledOutput - Stderr ControlledOutput - StartedAt time.Time - CompletedAt time.Time - TimedOut bool - Cancelled bool - OutputLimitExceeded bool - TreeReaped bool - RestrictedToken bool - LowIntegrityToken bool - JobAssignedAtCreation bool - KillOnJobClose bool - ActiveProcessLimit int - ProcessMemoryLimit int64 - StdinClosed bool - EnvironmentInherited bool - NetworkRequested bool - PersistentProcess bool - ProductExecutionEnabled bool -} - // ControlledExecutionReceipt is the metadata-only durable projection of a // sealed execution result. Raw stdout and stderr deliberately do not belong to // this type. @@ -360,36 +239,6 @@ func (r ControlledExecutionReceipt) Validate() error { return nil } -func (r ControlledExecutionResult) Validate() error { - if r.ProtocolVersion != ControlledExecutionProtocolVersion || - r.PolicyVersion != ControlledExecutionPolicyVersion || - !validIdentity(r.RequestID) || !validIdentity(r.PlanID) || - !validSHA256(r.PlanFingerprint) || !domain.ValidAgentID(r.RunID) || - !domain.ValidAgentID(r.WorkspaceID) || - !validIdentity(r.InteractionSnapshotID) || - r.InteractionRevision <= 0 || r.ExecutionProfileRevision <= 0 || - !validIdentity(r.Backend) { - return ErrControlledExecutionBoundary - } - if _, err := ParseControlledCommandKind(string(r.Kind)); err != nil { - return err - } - started := ControlledStartResult{ - ExitCode: r.ExitCode, Stdout: r.Stdout, Stderr: r.Stderr, - StartedAt: r.StartedAt, CompletedAt: r.CompletedAt, - TimedOut: r.TimedOut, Cancelled: r.Cancelled, - OutputLimitExceeded: r.OutputLimitExceeded, TreeReaped: r.TreeReaped, - RestrictedToken: r.RestrictedToken, LowIntegrityToken: r.LowIntegrityToken, - JobAssignedAtCreation: r.JobAssignedAtCreation, - KillOnJobClose: r.KillOnJobClose, ActiveProcessLimit: r.ActiveProcessLimit, - ProcessMemoryLimit: r.ProcessMemoryLimit, StdinClosed: r.StdinClosed, - EnvironmentInherited: r.EnvironmentInherited, - NetworkRequested: r.NetworkRequested, PersistentProcess: r.PersistentProcess, - ProductExecutionEnabled: r.ProductExecutionEnabled, - } - return started.Validate() -} - func validControlledArgument(value string) bool { return value != "" && value == strings.TrimSpace(value) && !strings.ContainsRune(value, 0) diff --git a/internal/runner/controlled_execution_test.go b/internal/runner/controlled_execution_test.go index be801bbf..9c812264 100644 --- a/internal/runner/controlled_execution_test.go +++ b/internal/runner/controlled_execution_test.go @@ -9,10 +9,14 @@ import ( ) func TestControlledStartSpecRejectsCallerSelectedCommandShapes(t *testing.T) { - request := controlledExecutionTestRequest(t, ControlledCommandGoVersion) + request := controlledCommandTestRequest(t, ControlledCommandGoVersion) + plan, err := PlanControlledCommand(request) + if err != nil { + t.Fatal(err) + } spec := ControlledStartSpec{ - RequestID: ControlledExecutionRequestID(request.Plan), - PlanID: request.Plan.ID, PlanFingerprint: request.Plan.Fingerprint, + RequestID: ControlledExecutionRequestID(plan), + PlanID: plan.ID, PlanFingerprint: plan.Fingerprint, ExecutableID: "go", Argv: []string{"env", "GOPATH"}, WorkspaceRoot: request.WorkspaceRoot, Timeout: DefaultControlledCommandTimeout, @@ -27,57 +31,27 @@ func TestControlledStartSpecRejectsCallerSelectedCommandShapes(t *testing.T) { } } -func TestControlledStartResultRejectsUnsupportedOutputLimitClaim(t *testing.T) { - result := controlledStartTestResult() - result.OutputLimitExceeded = true - if err := result.Validate(); !errors.Is(err, ErrControlledExecutionBoundary) { - t.Fatalf("unsupported output-limit claim error=%v", err) - } - - data := make([]byte, MaxControlledOutputCaptureBytes) - digest := sha256.Sum256(data) - result.Stdout = ControlledOutput{ - Data: data, ObservedBytes: MaxControlledOutputObservedBytes, - CapturedBytes: len(data), - CapturedPrefixSHA256: hex.EncodeToString(digest[:]), - Truncated: true, - } - if err := result.Validate(); err != nil { - t.Fatalf("supported output-limit claim error=%v", err) - } -} - -func controlledExecutionTestRequest(t *testing.T, - kind ControlledCommandKind, -) ControlledExecutionRequest { - t.Helper() - planRequest := controlledCommandTestRequest(t, kind) - plan, err := PlanControlledCommand(planRequest) - if err != nil { - t.Fatal(err) - } - return ControlledExecutionRequest{ - Plan: plan, WorkspaceRoot: planRequest.WorkspaceRoot, - Interaction: planRequest.Interaction, - CurrentProfile: planRequest.CurrentProfile, - CurrentSurface: planRequest.CurrentSurface, - RequestedBy: "test_operator", OperatorConfirmed: true, - } -} - -func controlledStartTestResult() ControlledStartResult { +func TestControlledExecutionReceiptRejectsUnsupportedOutputLimitClaim(t *testing.T) { now := time.Date(2026, 7, 26, 13, 0, 0, 0, time.UTC) emptyDigest := sha256.Sum256(nil) - empty := ControlledOutput{ - CapturedPrefixSHA256: hex.EncodeToString(emptyDigest[:]), - } - return ControlledStartResult{ - ExitCode: 0, Stdout: empty, Stderr: empty, - StartedAt: now, CompletedAt: now.Add(time.Second), + receipt := ControlledExecutionReceipt{ + RequestID: "controlled-exec-receipt", ProtocolVersion: ControlledExecutionProtocolVersion, + PolicyVersion: ControlledExecutionPolicyVersion, Backend: "windows-fixed-restricted", + StdoutPrefixSHA256: hex.EncodeToString(emptyDigest[:]), StderrPrefixSHA256: hex.EncodeToString(emptyDigest[:]), + StartedAt: now, CompletedAt: now.Add(time.Second), OutputLimitExceeded: true, TreeReaped: true, RestrictedToken: true, LowIntegrityToken: true, - JobAssignedAtCreation: true, KillOnJobClose: true, - ActiveProcessLimit: 1, - ProcessMemoryLimit: MaxControlledProcessMemoryBytes, - StdinClosed: true, ProductExecutionEnabled: true, + JobAssignedAtCreation: true, KillOnJobClose: true, ActiveProcessLimit: 1, + ProcessMemoryLimit: MaxControlledProcessMemoryBytes, StdinClosed: true, ProductExecutionEnabled: true, + } + if err := receipt.Validate(); !errors.Is(err, ErrControlledExecutionBoundary) { + t.Fatalf("unsupported output-limit claim error=%v", err) + } + digest := sha256.Sum256(make([]byte, MaxControlledOutputCaptureBytes)) + receipt.StdoutObservedBytes = MaxControlledOutputObservedBytes + receipt.StdoutCapturedBytes = MaxControlledOutputCaptureBytes + receipt.StdoutPrefixSHA256 = hex.EncodeToString(digest[:]) + receipt.StdoutTruncated = true + if err := receipt.Validate(); err != nil { + t.Fatalf("supported output-limit claim error=%v", err) } } diff --git a/internal/runner/controlled_execution_windows_test.go b/internal/runner/controlled_execution_windows_test.go index 9e08721f..e2961ca4 100644 --- a/internal/runner/controlled_execution_windows_test.go +++ b/internal/runner/controlled_execution_windows_test.go @@ -209,21 +209,24 @@ func TestControlledOutputReaderOwnsAndClosesTransferredHandle(t *testing.T) { } func TestControlledWorkspaceRejectsEscapingRelativeDirectory(t *testing.T) { - request := controlledExecutionTestRequest(t, - ControlledCommandPowerShellWorkspaceList) + request := controlledCommandTestRequest(t, ControlledCommandPowerShellWorkspaceList) + plan, err := PlanControlledCommand(request) + if err != nil { + t.Fatal(err) + } outside := t.TempDir() link := filepath.Join(request.WorkspaceRoot, "escape") if err := os.Symlink(outside, link); err != nil { t.Skipf("directory symlink is unavailable: %v", err) } - request.Plan.RelativePath = "escape" - request.Plan.Argv[7] = encodeControlledRelativePath("escape") - request.Plan.Fingerprint = controlledCommandPlanFingerprint(request.Plan) + plan.RelativePath = "escape" + plan.Argv[7] = encodeControlledRelativePath("escape") + plan.Fingerprint = controlledCommandPlanFingerprint(plan) spec := ControlledStartSpec{ - RequestID: ControlledExecutionRequestID(request.Plan), - PlanID: request.Plan.ID, PlanFingerprint: request.Plan.Fingerprint, - ExecutableID: request.Plan.ExecutableID, - Argv: append([]string(nil), request.Plan.Argv...), + RequestID: ControlledExecutionRequestID(plan), + PlanID: plan.ID, PlanFingerprint: plan.Fingerprint, + ExecutableID: plan.ExecutableID, + Argv: append([]string(nil), plan.Argv...), WorkspaceRoot: request.WorkspaceRoot, Timeout: DefaultControlledCommandTimeout, } diff --git a/internal/runner/host_command.go b/internal/runner/host_command.go index f5c65b98..fa2f100c 100644 --- a/internal/runner/host_command.go +++ b/internal/runner/host_command.go @@ -384,22 +384,6 @@ func HostEnvironmentDigest(environment []string) (string, error) { return digest, nil } -type HostCommandProposalRequest struct { - ID string - RunID string - MissionID string - SessionID string - WorkspaceID string - RootAgentID string - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileRevision int64 - Permission domain.RunExecutionPermissionSnapshot - Spec HostCommandSpec - RequestedBy string - CreatedAt time.Time -} - // HostCommandProposal is deliberately distinct from // controlled_command_proposal.v1. It can exist only for approval mode and // never carries execution authority. @@ -427,51 +411,6 @@ type HostCommandProposal struct { CreatedAt time.Time } -func NewHostCommandProposal( - request HostCommandProposalRequest, -) (HostCommandProposal, error) { - if err := request.Spec.Validate(); err != nil || - request.Permission.Validate() != nil { - return HostCommandProposal{}, ErrHostCommandBoundary - } - request.ID = strings.TrimSpace(request.ID) - request.RunID = strings.TrimSpace(request.RunID) - request.MissionID = strings.TrimSpace(request.MissionID) - request.SessionID = strings.TrimSpace(request.SessionID) - request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) - request.RootAgentID = strings.TrimSpace(request.RootAgentID) - request.InteractionSnapshotID = - strings.TrimSpace(request.InteractionSnapshotID) - request.RequestedBy = strings.TrimSpace(request.RequestedBy) - if request.Permission.Mode != domain.RunExecutionPermissionApproval || - request.Permission.RunID != request.RunID || - request.Permission.MissionID != request.MissionID || - request.RequestedBy != "run_supervisor" { - return HostCommandProposal{}, ErrHostCommandBoundary - } - proposal := HostCommandProposal{ - ID: request.ID, ProtocolVersion: HostCommandProposalProtocolVersion, - PolicyVersion: HostCommandPolicyVersion, - RunID: request.RunID, MissionID: request.MissionID, - SessionID: request.SessionID, WorkspaceID: request.WorkspaceID, - RootAgentID: request.RootAgentID, - InteractionSnapshotID: request.InteractionSnapshotID, - InteractionRevision: request.InteractionRevision, - ExecutionProfileRevision: request.ExecutionProfileRevision, - PermissionSnapshotID: request.Permission.ID, - PermissionRevision: request.Permission.Revision, - PermissionMode: request.Permission.Mode, - Spec: request.Spec, - RequestedBy: request.RequestedBy, - CreatedAt: request.CreatedAt.UTC(), - } - proposal.Fingerprint = HostCommandProposalFingerprint(proposal) - if err := proposal.Validate(); err != nil { - return HostCommandProposal{}, err - } - return proposal, nil -} - func (p HostCommandProposal) Validate() error { for _, value := range []string{ p.ID, p.RunID, p.MissionID, p.SessionID, p.WorkspaceID, p.RootAgentID, @@ -508,48 +447,6 @@ func HostCommandProposalFingerprint(proposal HostCommandProposal) string { return hex.EncodeToString(digest[:]) } -func HostCommandProposalRequestFingerprint( - proposal HostCommandProposal, -) string { - semantic := struct { - ProtocolVersion string - PolicyVersion string - RunID string - MissionID string - SessionID string - WorkspaceID string - RootAgentID string - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileRevision int64 - PermissionSnapshotID string - PermissionRevision int64 - PermissionMode domain.RunExecutionPermissionMode - SpecFingerprint string - RequestedBy string - }{ - ProtocolVersion: proposal.ProtocolVersion, - PolicyVersion: proposal.PolicyVersion, - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - RootAgentID: proposal.RootAgentID, - InteractionSnapshotID: proposal.InteractionSnapshotID, - InteractionRevision: proposal.InteractionRevision, - ExecutionProfileRevision: proposal.ExecutionProfileRevision, - PermissionSnapshotID: proposal.PermissionSnapshotID, - PermissionRevision: proposal.PermissionRevision, - PermissionMode: proposal.PermissionMode, - SpecFingerprint: proposal.Spec.Fingerprint, - RequestedBy: proposal.RequestedBy, - } - encoded, err := json.Marshal(semantic) - if err != nil { - return "" - } - digest := sha256.Sum256(encoded) - return hex.EncodeToString(digest[:]) -} - type HostCommandProposalOperation struct { KeyDigest string RequestFingerprint string @@ -614,40 +511,6 @@ type HostCommandReview struct { CreatedAt time.Time } -func NewHostCommandReview(id string, proposal HostCommandProposal, - decision HostCommandReviewDecision, reviewedBy string, reason string, - operationKeyDigest string, createdAt time.Time, -) (HostCommandReview, error) { - if proposal.Validate() != nil || decision.Validate() != nil { - return HostCommandReview{}, ErrHostCommandBoundary - } - reason = strings.TrimSpace(redact.String(reason)) - if reason == "" { - if decision == HostCommandReviewApprove { - reason = "operator approved this exact one-shot host command" - } else { - reason = "operator denied this one-shot host command" - } - } - review := HostCommandReview{ - ID: strings.TrimSpace(id), - ProtocolVersion: HostCommandReviewProtocolVersion, - PolicyVersion: HostCommandPolicyVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - RunID: proposal.RunID, Decision: decision, - ReviewedBy: strings.TrimSpace(reviewedBy), Reason: reason, - OperationKeyDigest: strings.ToLower(strings.TrimSpace(operationKeyDigest)), - SingleUseExecutionAuthorized: decision == HostCommandReviewApprove, - CreatedAt: createdAt.UTC(), - } - review.RequestFingerprint = HostCommandReviewRequestFingerprint(review) - review.Fingerprint = HostCommandReviewFingerprint(review) - if err := review.Validate(); err != nil { - return HostCommandReview{}, err - } - return review, nil -} - func (r HostCommandReview) Validate() error { for _, value := range []string{ r.ID, r.ProposalID, r.RunID, r.ReviewedBy, @@ -745,38 +608,6 @@ type HostCommandProposalResult struct { SavedOutput *HostCommandSavedOutput `json:"SavedOutput,omitempty"` } -func NewHostCommandProposalResult(id string, proposal HostCommandProposal, - review HostCommandReview, requestID string, status string, - sourceKind string, sourceRef string, contentSHA256 string, - createdAt time.Time, savedOutput ...HostCommandSavedOutput, -) (HostCommandProposalResult, error) { - result := HostCommandProposalResult{ - ID: strings.TrimSpace(id), ProtocolVersion: HostCommandResultProtocolVersion, - PolicyVersion: HostCommandPolicyVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - ReviewID: review.ID, ReviewFingerprint: review.Fingerprint, - RequestID: strings.TrimSpace(requestID), RunID: proposal.RunID, - SessionID: proposal.SessionID, Status: strings.TrimSpace(status), - SourceKind: strings.TrimSpace(sourceKind), SourceRef: strings.TrimSpace(sourceRef), - ContentSHA256: strings.ToLower(strings.TrimSpace(contentSHA256)), - CreatedAt: createdAt.UTC(), - } - if len(savedOutput) > 1 { - return HostCommandProposalResult{}, ErrHostCommandBoundary - } - if len(savedOutput) == 1 { - copy := savedOutput[0] - result.SavedOutput = © - } - result.Fingerprint = HostCommandProposalResultFingerprint(result) - if proposal.Validate() != nil || review.Validate() != nil || - review.ProposalID != proposal.ID || - review.Decision != HostCommandReviewApprove || result.Validate() != nil { - return HostCommandProposalResult{}, ErrHostCommandBoundary - } - return result, nil -} - func (r HostCommandProposalResult) Validate() error { if r.SavedOutput != nil && r.SavedOutput.Validate() != nil { return ErrHostCommandBoundary diff --git a/internal/runner/host_command_saved_output.go b/internal/runner/host_command_saved_output.go index e586e336..2a528d10 100644 --- a/internal/runner/host_command_saved_output.go +++ b/internal/runner/host_command_saved_output.go @@ -23,26 +23,6 @@ type HostCommandSavedOutput struct { Stderr HostCommandSavedStream } -// NewHostCommandSavedOutput projects each actual execution stream before any -// evidence envelope is assembled. It never interprets output as delimiters. -func NewHostCommandSavedOutput(execution HostExecutionResult) HostCommandSavedOutput { - remaining := MaxHostCommandSavedOutputBytes - project := func(output ControlledOutput) HostCommandSavedStream { - text := redact.String(SanitizeCommandEvidence(output.Data)) - truncated := output.Truncated || len(text) > remaining - if len(text) > remaining { - end := remaining - for end > 0 && !utf8.ValidString(text[:end]) { - end-- - } - text = text[:end] - } - remaining -= len(text) - return HostCommandSavedStream{Text: text, Truncated: truncated, Redacted: true} - } - return HostCommandSavedOutput{Stdout: project(execution.Stdout), Stderr: project(execution.Stderr)} -} - func (output HostCommandSavedOutput) Validate() error { if len(output.Stdout.Text)+len(output.Stderr.Text) > MaxHostCommandSavedOutputBytes { return ErrHostCommandBoundary diff --git a/internal/runner/host_command_saved_output_test.go b/internal/runner/host_command_saved_output_test.go index e2864097..1c5c66b1 100644 --- a/internal/runner/host_command_saved_output_test.go +++ b/internal/runner/host_command_saved_output_test.go @@ -6,48 +6,43 @@ import ( "encoding/json" "strings" "testing" - "time" - "unicode/utf8" ) -func TestHostCommandSavedOutputKeepsStreamIdentityWithoutInterpretingMarkers(t *testing.T) { - stdout := "中文\r\nstdout_end\nstderr_begin\nthis is still stdout\n" - secret := "sk-" + strings.Repeat("a", 32) - output := NewHostCommandSavedOutput(HostExecutionResult{ - Stdout: ControlledOutput{Data: []byte(stdout)}, - Stderr: ControlledOutput{Data: []byte("stderr_begin\n" + secret + "\xff\x00")}, - }) - if output.Validate() != nil || output.Stdout.Text != strings.ReplaceAll(stdout, "\r", "\n") || - !strings.HasPrefix(output.Stderr.Text, "stderr_begin\n") || - strings.Contains(output.Stderr.Text, secret) || !strings.ContainsRune(output.Stderr.Text, '\uFFFD') || - output.Stdout.Truncated || output.Stderr.Truncated || !output.Stdout.Redacted || !output.Stderr.Redacted { - t.Fatalf("saved output lost its identity or sanitization: %#v", output) +func TestHostCommandSavedOutputRejectsUnsafeHistoricalStreams(t *testing.T) { + saved := HostCommandSavedOutput{ + Stdout: HostCommandSavedStream{Text: "中文\nstdout_end\nstderr_begin\nthis is still stdout\n", Redacted: true}, + Stderr: HostCommandSavedStream{Text: "stderr_begin\n", Redacted: true}, } -} - -func TestHostCommandSavedOutputBoundsUTF8AndKeepsCaptureTruncation(t *testing.T) { - output := NewHostCommandSavedOutput(HostExecutionResult{ - Stdout: ControlledOutput{Data: []byte(strings.Repeat("中", MaxHostCommandSavedOutputBytes))}, - Stderr: ControlledOutput{Data: []byte("error")}, - }) - if output.Validate() != nil || !utf8.ValidString(output.Stdout.Text) || - len(output.Stdout.Text)+len(output.Stderr.Text) > MaxHostCommandSavedOutputBytes || - !output.Stdout.Truncated || !output.Stderr.Truncated { - t.Fatalf("output did not retain the saving limit: %#v", output) + if err := saved.Validate(); err != nil { + t.Fatal(err) } - output = NewHostCommandSavedOutput(HostExecutionResult{ - Stdout: ControlledOutput{Data: []byte("prefix"), Truncated: true}, - }) - if !output.Stdout.Truncated || output.Stderr.Truncated || output.Stderr.Text != "" { - t.Fatalf("capture/empty stream state changed: %#v", output) + for _, mutate := range []func(*HostCommandSavedOutput){ + func(value *HostCommandSavedOutput) { + value.Stdout.Text = strings.Repeat("x", MaxHostCommandSavedOutputBytes+1) + }, + func(value *HostCommandSavedOutput) { value.Stderr.Text = "invalid\xff" }, + func(value *HostCommandSavedOutput) { value.Stderr.Text = "untrusted\x00control" }, + func(value *HostCommandSavedOutput) { value.Stderr.Text = "sk-" + strings.Repeat("a", 32) }, + func(value *HostCommandSavedOutput) { value.Stdout.Redacted = false }, + } { + tampered := saved + mutate(&tampered) + if tampered.Validate() == nil { + t.Fatal("unsafe saved stream unexpectedly validated") + } } - output.Stdout.Truncated = false - if output.ValidateReceipt(HostExecutionReceipt{StdoutTruncated: true}) == nil { + saved.Stdout.Text = "prefix" + saved.Stderr.Text = "" + if saved.ValidateReceipt(HostExecutionReceipt{StdoutCapturedBytes: 6, StdoutTruncated: true}) == nil { t.Fatal("saving projection concealed truncated capture") } + saved.Stdout.Truncated = true + if err := saved.ValidateReceipt(HostExecutionReceipt{StdoutCapturedBytes: 6, StdoutTruncated: true}); err != nil { + t.Fatal(err) + } } -func TestHostCommandSavedOutputPreservesLegacyJSONAndSealsNewText(t *testing.T) { +func TestHostCommandSavedOutputPreservesLegacyJSONAndSealsSavedText(t *testing.T) { // Exact old field order and absent optional field keep old fingerprint input. legacy := `{"ID":"result-old","ProtocolVersion":"host_command_proposal_result.v1","PolicyVersion":"host_command_policy.v1","ProposalID":"proposal-old","ProposalFingerprint":"old-proposal-fingerprint","ReviewID":"review-old","ReviewFingerprint":"old-review-fingerprint","RequestID":"request-old","RunID":"run-old","SessionID":"session-old","Status":"completed","SourceKind":"go_command_result","SourceRef":"host-command-proposal:proposal-old","ContentSHA256":"old-content-sha","InstructionAuthorized":false,"RawOutputPersisted":false,"AutomaticRetryAllowed":false,"Fingerprint":"","CreatedAt":"2026-09-10T00:00:00Z"}` var old HostCommandProposalResult @@ -61,17 +56,21 @@ func TestHostCommandSavedOutputPreservesLegacyJSONAndSealsNewText(t *testing.T) t.Fatalf("historical fingerprint input changed: %s, %v", encoded, err) } proposal := hostCommandProposalFixture(t) - now := time.Now().UTC() - review, err := NewHostCommandReview("saved-output-review", proposal, HostCommandReviewApprove, - "test_operator", "exact output test", hostCommandTestDigest, now) - if err != nil { - t.Fatal(err) + review := hostCommandReviewFixture(proposal) + result := HostCommandProposalResult{ + ID: "saved-output-result", ProtocolVersion: HostCommandResultProtocolVersion, PolicyVersion: HostCommandPolicyVersion, + ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, ReviewID: review.ID, ReviewFingerprint: review.Fingerprint, + RequestID: "saved-output-request", RunID: proposal.RunID, SessionID: proposal.SessionID, + Status: "completed", SourceKind: "go_command_result", SourceRef: "host-command-proposal:" + proposal.ID, + ContentSHA256: hostCommandTestDigest, CreatedAt: proposal.CreatedAt, + SavedOutput: &HostCommandSavedOutput{ + Stdout: HostCommandSavedStream{Text: "actual", Redacted: true}, + Stderr: HostCommandSavedStream{Redacted: true}, + }, } - output := NewHostCommandSavedOutput(HostExecutionResult{Stdout: ControlledOutput{Data: []byte("actual")}}) - result, err := NewHostCommandProposalResult("saved-output-result", proposal, review, "saved-output-request", - "completed", "go_command_result", "host-command-proposal:"+proposal.ID, hostCommandTestDigest, now, output) - if err != nil || result.Validate() != nil { - t.Fatalf("new saved output was not sealed: %v", err) + result.Fingerprint = HostCommandProposalResultFingerprint(result) + if err := result.Validate(); err != nil { + t.Fatalf("historical saved output did not validate: %v", err) } result.SavedOutput.Stdout.Text = "different" if result.Validate() == nil { diff --git a/internal/runner/host_command_test.go b/internal/runner/host_command_test.go index a5bff35d..fda20ae0 100644 --- a/internal/runner/host_command_test.go +++ b/internal/runner/host_command_test.go @@ -168,11 +168,11 @@ func TestHostCommandProposalAcceptsOnlyCanonicalReviewedShellEnvelopes(t *testin if err := ValidateHostCommandProposalTransport(tamperedSpec); err == nil { t.Fatal("non-canonical shell argv unexpectedly passed approval mode") } - permission := hostCommandApprovalPermission(t) - proposalRequest := hostCommandProposalTestRequest(tamperedSpec, permission, - time.Now().UTC()) - if _, err := NewHostCommandProposal(proposalRequest); err == nil { - t.Fatal("non-canonical shell argv entered the durable proposal domain") + proposal := hostCommandProposalFixture(t) + proposal.Spec = tamperedSpec + proposal.Fingerprint = HostCommandProposalFingerprint(proposal) + if proposal.Validate() == nil { + t.Fatal("non-canonical shell argv unexpectedly validated in a historical proposal") } }) } @@ -181,37 +181,26 @@ func TestHostCommandProposalAcceptsOnlyCanonicalReviewedShellEnvelopes(t *testin } } -func TestHostCommandProposalRequiresApprovalSnapshotAndExactReview(t *testing.T) { +func TestHistoricalHostCommandProposalAndReviewRejectAlteredAuthority(t *testing.T) { proposal := hostCommandProposalFixture(t) - if proposal.ExecutionAuthorized || proposal.InstructionAuthorized || - proposal.CapabilityGrant || proposal.PermissionMode != - domain.RunExecutionPermissionApproval { - t.Fatalf("proposal unexpectedly carries authority: %+v", proposal) + if proposal.Validate() != nil { + t.Fatal("historical proposal did not validate") } - - review, err := NewHostCommandReview( - "host-command-review", proposal, HostCommandReviewApprove, - "cli_operator", "approved after exact command review", - hostCommandTestDigest, time.Now().UTC()) - if err != nil { - t.Fatal(err) - } - if !review.SingleUseExecutionAuthorized || review.CapabilityGrant { - t.Fatalf("unexpected review authority: %+v", review) + review := hostCommandReviewFixture(proposal) + if review.Validate() != nil { + t.Fatal("historical review did not validate") } tampered := review tampered.ProposalFingerprint = hostCommandTestDigest if err := tampered.Validate(); err == nil { t.Fatal("review detached from its proposal unexpectedly validated") } - - for _, reviewer := range []string{ - "agent", "model", "repository", "skill", "supervisor", "run_supervisor", - } { - if _, err := NewHostCommandReview( - "review-"+reviewer, proposal, HostCommandReviewApprove, - reviewer, "must be rejected", hostCommandTestDigest, - time.Now().UTC()); err == nil { + for _, reviewer := range []string{"agent", "model", "repository", "skill", "supervisor", "run_supervisor"} { + tampered := review + tampered.ReviewedBy = reviewer + tampered.RequestFingerprint = HostCommandReviewRequestFingerprint(tampered) + tampered.Fingerprint = HostCommandReviewFingerprint(tampered) + if err := tampered.Validate(); err == nil { t.Fatalf("reserved reviewer %q unexpectedly validated", reviewer) } } @@ -287,91 +276,44 @@ func TestHostPowerShellRejectsOpeningDeclarationsWithoutInspectingCodeValues(t * } } -func TestHostCommandProposalRejectsNonApprovalPermission(t *testing.T) { - now := time.Now().UTC() - mission := domain.Mission{ID: "mission-host-command", CreatedAt: now} - run := domain.Run{ - ID: "run-host-command", MissionID: mission.ID, - Status: domain.RunCreated, CreatedAt: now, - } - permission, err := domain.NewInitialRunExecutionPermissionSnapshot( - "permission-conservative", run, mission, "test_operator", now) - if err != nil { - t.Fatal(err) - } - spec, err := NewHostCommandSpec(hostCommandSpecTestRequest(t)) - if err != nil { - t.Fatal(err) - } - request := hostCommandProposalTestRequest(spec, permission, now) - if _, err := NewHostCommandProposal(request); err == nil { - t.Fatal("conservative permission unexpectedly produced a host command proposal") - } - full, err := permission.Next( - "permission-full", domain.RunExecutionPermissionFullAccess, true, - "test_operator", "test transition", now) - if err != nil { - t.Fatal(err) - } - request.Permission = full - if _, err := NewHostCommandProposal(request); err == nil { - t.Fatal("full-access permission unexpectedly used the approval protocol") +func TestHistoricalHostCommandProposalRejectsNonApprovalPermission(t *testing.T) { + for _, mode := range []domain.RunExecutionPermissionMode{domain.RunExecutionPermissionConservative, domain.RunExecutionPermissionFullAccess} { + proposal := hostCommandProposalFixture(t) + proposal.PermissionMode = mode + proposal.Fingerprint = HostCommandProposalFingerprint(proposal) + if proposal.Validate() == nil { + t.Fatalf("permission %q unexpectedly validated in the retired approval protocol", mode) + } } } func hostCommandProposalFixture(t *testing.T) HostCommandProposal { t.Helper() - now := time.Now().UTC() - permission := hostCommandApprovalPermission(t) spec, err := NewHostCommandSpec(hostCommandSpecTestRequest(t)) if err != nil { t.Fatal(err) } - proposal, err := NewHostCommandProposal( - hostCommandProposalTestRequest(spec, permission, now)) - if err != nil { - t.Fatal(err) + proposal := HostCommandProposal{ + ID: "host-command-proposal", ProtocolVersion: HostCommandProposalProtocolVersion, PolicyVersion: HostCommandPolicyVersion, + RunID: "run-host-proposal", MissionID: "mission-host-proposal", SessionID: "session-host-command", WorkspaceID: "workspace-host-command", + RootAgentID: "agent-root-host-command", InteractionSnapshotID: "interaction-host-command", InteractionRevision: 1, ExecutionProfileRevision: 1, + PermissionSnapshotID: "permission-host-approval", PermissionRevision: 2, PermissionMode: domain.RunExecutionPermissionApproval, + Spec: spec, RequestedBy: "run_supervisor", CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC), } + proposal.Fingerprint = HostCommandProposalFingerprint(proposal) return proposal } -func hostCommandApprovalPermission( - t *testing.T, -) domain.RunExecutionPermissionSnapshot { - t.Helper() - now := time.Now().UTC() - mission := domain.Mission{ID: "mission-host-proposal", CreatedAt: now} - run := domain.Run{ - ID: "run-host-proposal", MissionID: mission.ID, - Status: domain.RunCreated, CreatedAt: now, - } - initial, err := domain.NewInitialRunExecutionPermissionSnapshot( - "permission-host-conservative", run, mission, "test_operator", now) - if err != nil { - t.Fatal(err) - } - permission, err := initial.Next( - "permission-host-approval", domain.RunExecutionPermissionApproval, true, - "test_operator", "approve each exact host command", now) - if err != nil { - t.Fatal(err) - } - return permission -} - -func hostCommandProposalTestRequest(spec HostCommandSpec, - permission domain.RunExecutionPermissionSnapshot, now time.Time, -) HostCommandProposalRequest { - return HostCommandProposalRequest{ - ID: "host-command-proposal", - RunID: permission.RunID, MissionID: permission.MissionID, - SessionID: "session-host-command", WorkspaceID: "workspace-host-command", - RootAgentID: "agent-root-host-command", - InteractionSnapshotID: "interaction-host-command", - InteractionRevision: 1, ExecutionProfileRevision: 1, - Permission: permission, Spec: spec, - RequestedBy: "run_supervisor", CreatedAt: now, - } +func hostCommandReviewFixture(proposal HostCommandProposal) HostCommandReview { + review := HostCommandReview{ + ID: "host-command-review", ProtocolVersion: HostCommandReviewProtocolVersion, PolicyVersion: HostCommandPolicyVersion, + ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, RunID: proposal.RunID, Decision: HostCommandReviewApprove, + ReviewedBy: "cli_operator", Reason: "approved after exact command review", OperationKeyDigest: hostCommandTestDigest, + SingleUseExecutionAuthorized: true, CreatedAt: proposal.CreatedAt, + } + review.RequestFingerprint = HostCommandReviewRequestFingerprint(review) + review.Fingerprint = HostCommandReviewFingerprint(review) + return review } func hostCommandSpecTestRequest(t *testing.T) HostCommandSpecRequest { diff --git a/internal/runner/host_execution.go b/internal/runner/host_execution.go index 8a9b206a..38834431 100644 --- a/internal/runner/host_execution.go +++ b/internal/runner/host_execution.go @@ -4,9 +4,6 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" - "fmt" - "strings" "time" "cyberagent-workbench/internal/domain" @@ -17,8 +14,6 @@ const ( HostExecutionPolicyVersion = "host_command_execution_policy.v1" ) -var ErrHostOutputLimit = errors.New("host command output limit exceeded") - // HostExecutionIntent is the durable write-ahead boundary. It contains the // exact non-secret command envelope but never carries environment values, // output, or authority. A stored intent without a receipt is uncertain and @@ -49,146 +44,6 @@ type HostExecutionIntent struct { CreatedAt time.Time } -func NewApprovedHostExecutionIntent(proposal HostCommandProposal, - review HostCommandReview, operationKeyDigest string, - createdAt time.Time, -) (HostExecutionIntent, error) { - if proposal.Validate() != nil || review.Validate() != nil || - review.ProposalID != proposal.ID || - review.ProposalFingerprint != proposal.Fingerprint || - review.RunID != proposal.RunID || - review.Decision != HostCommandReviewApprove || - !review.SingleUseExecutionAuthorized { - return HostExecutionIntent{}, ErrHostCommandBoundary - } - intent := HostExecutionIntent{ - ProtocolVersion: HostCommandIntentProtocolVersion, - PolicyVersion: HostExecutionPolicyVersion, - OperationKeyDigest: strings.ToLower(strings.TrimSpace(operationKeyDigest)), - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - InteractionSnapshotID: proposal.InteractionSnapshotID, - InteractionRevision: proposal.InteractionRevision, - ExecutionProfileRevision: proposal.ExecutionProfileRevision, - PermissionSnapshotID: proposal.PermissionSnapshotID, - PermissionRevision: proposal.PermissionRevision, - PermissionMode: proposal.PermissionMode, - AuthorizationProposalID: proposal.ID, - AuthorizationProposalFingerprint: proposal.Fingerprint, - AuthorizationReviewID: review.ID, - AuthorizationReviewFingerprint: review.Fingerprint, - Spec: proposal.Spec, RequestedBy: review.ReviewedBy, - NonSandboxed: true, AutomaticRetryAllowed: false, - CreatedAt: createdAt.UTC(), - } - intent.RequestID = HostExecutionRequestID(intent.RunID, - intent.OperationKeyDigest, intent.Spec.Fingerprint) - if err := intent.Validate(); err != nil { - return HostExecutionIntent{}, err - } - return intent, nil -} - -func NewRiskEscalationHostExecutionIntent(proposal RiskEscalationProposal, - authorization RiskEscalationAuthorization, operationKeyDigest string, - createdAt time.Time, -) (HostExecutionIntent, error) { - if proposal.Validate() != nil || authorization.Validate() != nil || - authorization.ProposalID != proposal.ID || - authorization.ProposalFingerprint != proposal.Fingerprint || - authorization.ScopeFingerprint != proposal.Scope.Fingerprint { - return HostExecutionIntent{}, ErrHostCommandBoundary - } - intent := HostExecutionIntent{ - ProtocolVersion: HostCommandIntentProtocolVersion, - PolicyVersion: HostExecutionPolicyVersion, - OperationKeyDigest: strings.ToLower(strings.TrimSpace(operationKeyDigest)), - RunID: proposal.RunID, MissionID: proposal.MissionID, - SessionID: proposal.SessionID, WorkspaceID: proposal.WorkspaceID, - InteractionSnapshotID: proposal.InteractionSnapshotID, - InteractionRevision: proposal.InteractionRevision, - ExecutionProfileRevision: proposal.ExecutionProfileRevision, - PermissionSnapshotID: proposal.PermissionSnapshotID, - PermissionRevision: proposal.PermissionRevision, - PermissionMode: proposal.PermissionMode, - AuthorizationProposalID: proposal.ID, - AuthorizationProposalFingerprint: proposal.Fingerprint, - AuthorizationReviewID: authorization.ApprovalID, - AuthorizationReviewFingerprint: RiskEscalationAuthorizationFingerprint(authorization), - Spec: proposal.Spec, RequestedBy: authorization.ReviewedBy, - NonSandboxed: true, AutomaticRetryAllowed: false, - CreatedAt: createdAt.UTC(), - } - intent.RequestID = HostExecutionRequestID(intent.RunID, - intent.OperationKeyDigest, intent.Spec.Fingerprint) - if err := intent.Validate(); err != nil { - return HostExecutionIntent{}, err - } - return intent, nil -} - -type HostExecutionIntentRequest struct { - OperationKeyDigest string - RunID string - MissionID string - SessionID string - WorkspaceID string - Interaction domain.RunExecutionInteractionSnapshot - Profile domain.RunExecutionProfileSnapshot - Permission domain.RunExecutionPermissionSnapshot - Spec HostCommandSpec - RequestedBy string - CreatedAt time.Time -} - -func NewHostExecutionIntent( - request HostExecutionIntentRequest, -) (HostExecutionIntent, error) { - if request.Spec.Validate() != nil || request.Interaction.Validate() != nil || - request.Profile.Validate() != nil || request.Permission.Validate() != nil { - return HostExecutionIntent{}, ErrHostCommandBoundary - } - if request.Interaction.RunID != request.RunID || - request.Interaction.MissionID != request.MissionID || - request.Profile.RunID != request.RunID || - request.Profile.MissionID != request.MissionID || - request.Permission.RunID != request.RunID || - request.Permission.MissionID != request.MissionID || - request.Interaction.ExecutionProfileRevision != request.Profile.Revision || - !request.Permission.Mode.IncludesFullAccess() { - return HostExecutionIntent{}, fmt.Errorf( - "%w: host execution intent bindings do not match", - ErrHostCommandBoundary) - } - intent := HostExecutionIntent{ - ProtocolVersion: HostCommandIntentProtocolVersion, - PolicyVersion: HostExecutionPolicyVersion, - OperationKeyDigest: strings.ToLower(strings.TrimSpace( - request.OperationKeyDigest)), - RunID: strings.TrimSpace(request.RunID), - MissionID: strings.TrimSpace(request.MissionID), - SessionID: strings.TrimSpace(request.SessionID), - WorkspaceID: strings.TrimSpace(request.WorkspaceID), - InteractionSnapshotID: request.Interaction.ID, - InteractionRevision: request.Interaction.Revision, - ExecutionProfileRevision: request.Profile.Revision, - PermissionSnapshotID: request.Permission.ID, - PermissionRevision: request.Permission.Revision, - PermissionMode: request.Permission.Mode, - Spec: request.Spec, - RequestedBy: strings.TrimSpace(request.RequestedBy), - NonSandboxed: true, - AutomaticRetryAllowed: false, - CreatedAt: request.CreatedAt.UTC(), - } - intent.RequestID = HostExecutionRequestID(intent.RunID, - intent.OperationKeyDigest, intent.Spec.Fingerprint) - if err := intent.Validate(); err != nil { - return HostExecutionIntent{}, err - } - return intent, nil -} - func HostExecutionRequestID( runID string, operationKeyDigest string, @@ -265,151 +120,6 @@ func HostExecutionIntentFingerprint(intent HostExecutionIntent) string { return hex.EncodeToString(digest[:]) } -type HostStartResult struct { - ExitCode int - Stdout ControlledOutput - Stderr ControlledOutput - StartedAt time.Time - CompletedAt time.Time - TimedOut bool - Cancelled bool - OutputLimitExceeded bool - TreeReaped bool - NonSandboxed bool - RestrictedToken bool - LowIntegrityToken bool - JobAssignedAtCreation bool - KillOnJobClose bool - ActiveProcessLimit int - JobMemoryLimit int64 - StdinClosed bool - EnvironmentInherited bool - NetworkRequested bool - PersistentProcess bool - ProductExecutionEnabled bool -} - -func (r HostStartResult) Validate() error { - if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || - !r.TreeReaped || !r.NonSandboxed || - r.RestrictedToken || r.LowIntegrityToken || - !r.JobAssignedAtCreation || !r.KillOnJobClose || - r.ActiveProcessLimit != MaxHostActiveProcesses || - r.JobMemoryLimit != MaxHostProcessMemoryBytes || - !r.StdinClosed || r.EnvironmentInherited || !r.NetworkRequested || - r.PersistentProcess || !r.ProductExecutionEnabled || - (r.TimedOut && r.Cancelled) { - return ErrHostCommandBoundary - } - if err := r.Stdout.Validate(); err != nil { - return err - } - if err := r.Stderr.Validate(); err != nil { - return err - } - if r.OutputLimitExceeded && - r.Stdout.ObservedBytes != MaxControlledOutputObservedBytes && - r.Stderr.ObservedBytes != MaxControlledOutputObservedBytes { - return ErrHostCommandBoundary - } - return nil -} - -type HostExecutionResult struct { - ProtocolVersion string - PolicyVersion string - RequestID string - OperationKeyDigest string - RunID string - MissionID string - SessionID string - WorkspaceID string - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileRevision int64 - PermissionSnapshotID string - PermissionRevision int64 - PermissionMode domain.RunExecutionPermissionMode - AuthorizationProposalID string - AuthorizationProposalFingerprint string - AuthorizationReviewID string - AuthorizationReviewFingerprint string - SpecFingerprint string - Backend string - ExitCode int - Stdout ControlledOutput - Stderr ControlledOutput - StartedAt time.Time - CompletedAt time.Time - TimedOut bool - Cancelled bool - OutputLimitExceeded bool - TreeReaped bool - NonSandboxed bool - RestrictedToken bool - LowIntegrityToken bool - JobAssignedAtCreation bool - KillOnJobClose bool - ActiveProcessLimit int - JobMemoryLimit int64 - StdinClosed bool - EnvironmentInherited bool - NetworkRequested bool - PersistentProcess bool - ProductExecutionEnabled bool -} - -func (r HostExecutionResult) Validate() error { - for _, value := range []string{ - r.RequestID, r.RunID, r.MissionID, r.SessionID, r.WorkspaceID, - r.InteractionSnapshotID, r.PermissionSnapshotID, r.Backend, - } { - if !validIdentity(value) { - return ErrHostCommandBoundary - } - } - if r.ProtocolVersion != HostExecutionProtocolVersion || - r.PolicyVersion != HostExecutionPolicyVersion || - !validSHA256(r.OperationKeyDigest) || - !validSHA256(r.SpecFingerprint) || - r.InteractionRevision <= 0 || r.ExecutionProfileRevision <= 0 || - r.PermissionRevision <= 0 || - (!r.PermissionMode.IncludesFullAccess() && - r.PermissionMode != domain.RunExecutionPermissionApproval && - r.PermissionMode != domain.RunExecutionPermissionWorkspaceAccess) { - return ErrHostCommandBoundary - } - if r.PermissionMode.IncludesFullAccess() { - if r.AuthorizationProposalID != "" || - r.AuthorizationProposalFingerprint != "" || - r.AuthorizationReviewID != "" || - r.AuthorizationReviewFingerprint != "" { - return ErrHostCommandBoundary - } - } else if !validIdentity(r.AuthorizationProposalID) || - !validSHA256(r.AuthorizationProposalFingerprint) || - !validIdentity(r.AuthorizationReviewID) || - !validSHA256(r.AuthorizationReviewFingerprint) { - return ErrHostCommandBoundary - } - return (HostStartResult{ - ExitCode: r.ExitCode, Stdout: r.Stdout, Stderr: r.Stderr, - StartedAt: r.StartedAt, CompletedAt: r.CompletedAt, - TimedOut: r.TimedOut, Cancelled: r.Cancelled, - OutputLimitExceeded: r.OutputLimitExceeded, TreeReaped: r.TreeReaped, - NonSandboxed: r.NonSandboxed, RestrictedToken: r.RestrictedToken, - LowIntegrityToken: r.LowIntegrityToken, - JobAssignedAtCreation: r.JobAssignedAtCreation, - KillOnJobClose: r.KillOnJobClose, - ActiveProcessLimit: r.ActiveProcessLimit, - JobMemoryLimit: r.JobMemoryLimit, StdinClosed: r.StdinClosed, - EnvironmentInherited: r.EnvironmentInherited, - NetworkRequested: r.NetworkRequested, - PersistentProcess: r.PersistentProcess, - ProductExecutionEnabled: r.ProductExecutionEnabled, - }).Validate() -} - // HostExecutionReceipt is metadata-only. Raw output and environment values // are intentionally absent. type HostExecutionReceipt struct { @@ -488,43 +198,3 @@ func (r HostExecutionReceipt) Validate() error { } return nil } - -func ProjectHostExecutionReceipt( - result HostExecutionResult, -) (HostExecutionReceipt, error) { - if err := result.Validate(); err != nil { - return HostExecutionReceipt{}, err - } - receipt := HostExecutionReceipt{ - RequestID: result.RequestID, - ProtocolVersion: HostCommandReceiptProtocolVersion, - PolicyVersion: result.PolicyVersion, Backend: result.Backend, - ExitCode: result.ExitCode, - StdoutObservedBytes: result.Stdout.ObservedBytes, - StdoutCapturedBytes: result.Stdout.CapturedBytes, - StdoutPrefixSHA256: result.Stdout.CapturedPrefixSHA256, - StdoutTruncated: result.Stdout.Truncated, - StderrObservedBytes: result.Stderr.ObservedBytes, - StderrCapturedBytes: result.Stderr.CapturedBytes, - StderrPrefixSHA256: result.Stderr.CapturedPrefixSHA256, - StderrTruncated: result.Stderr.Truncated, - StartedAt: result.StartedAt, CompletedAt: result.CompletedAt, - TimedOut: result.TimedOut, Cancelled: result.Cancelled, - OutputLimitExceeded: result.OutputLimitExceeded, - TreeReaped: result.TreeReaped, NonSandboxed: result.NonSandboxed, - RestrictedToken: result.RestrictedToken, - LowIntegrityToken: result.LowIntegrityToken, - JobAssignedAtCreation: result.JobAssignedAtCreation, - KillOnJobClose: result.KillOnJobClose, - ActiveProcessLimit: result.ActiveProcessLimit, - JobMemoryLimit: result.JobMemoryLimit, StdinClosed: result.StdinClosed, - EnvironmentInherited: result.EnvironmentInherited, - NetworkRequested: result.NetworkRequested, - PersistentProcess: result.PersistentProcess, - ProductExecutionEnabled: result.ProductExecutionEnabled, - } - if err := receipt.Validate(); err != nil { - return HostExecutionReceipt{}, err - } - return receipt, nil -} diff --git a/internal/runner/host_execution_test.go b/internal/runner/host_execution_test.go index d9077132..4371c3ea 100644 --- a/internal/runner/host_execution_test.go +++ b/internal/runner/host_execution_test.go @@ -7,120 +7,34 @@ import ( "cyberagent-workbench/internal/domain" ) -type hostExecutionFixture struct { - intent HostExecutionIntent - environment []string - interaction domain.RunExecutionInteractionSnapshot - profile domain.RunExecutionProfileSnapshot - permission domain.RunExecutionPermissionSnapshot - surface domain.ExecutionSurface -} - -func TestHostExecutionIntentIsDeterministicAndDisablesAutomaticRetry( - t *testing.T, -) { - fixture := newHostExecutionFixture(t) - if fixture.intent.AutomaticRetryAllowed || !fixture.intent.NonSandboxed { - t.Fatalf("unexpected host intent: %+v", fixture.intent) - } - replayed, err := NewHostExecutionIntent(HostExecutionIntentRequest{ - OperationKeyDigest: fixture.intent.OperationKeyDigest, - RunID: fixture.intent.RunID, MissionID: fixture.intent.MissionID, - SessionID: fixture.intent.SessionID, - WorkspaceID: fixture.intent.WorkspaceID, - Interaction: fixture.interaction, Profile: fixture.profile, - Permission: fixture.permission, Spec: fixture.intent.Spec, - RequestedBy: fixture.intent.RequestedBy, - CreatedAt: fixture.intent.CreatedAt, - }) - if err != nil { - t.Fatal(err) - } - if replayed.RequestID != fixture.intent.RequestID { - t.Fatal("same operation did not reproduce the request identity") - } - tampered := fixture.intent - tampered.Spec.Purpose = "tampered" - if err := tampered.Validate(); err == nil { - t.Fatal("tampered host execution intent unexpectedly validated") - } -} - -func newHostExecutionFixture(t *testing.T) hostExecutionFixture { - t.Helper() - at := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) - mission := domain.Mission{ - ID: "mission-host-execution", Goal: "execute exact host command", - Profile: domain.ProfileCode, WorkspaceID: "workspace-host-execution", - Scope: domain.DefaultScope("workspace-host-execution"), - CreatedAt: at, UpdatedAt: at, - } - run := domain.Run{ - ID: "run-host-execution", MissionID: mission.ID, - SessionID: "session-host-execution", Status: domain.RunCreated, - Config: domain.RunConfig{ModelRoute: "mock/default"}, - Budget: domain.DefaultBudget(), CreatedAt: at, UpdatedAt: at, - } - mode, err := domain.NewInitialRunModeSnapshot( - "mode-host-execution", run, mission, domain.ExecutionSurfaceCode, - domain.ExecutionPhaseDeliver, "test_operator", "code", at) - if err != nil { - t.Fatal(err) - } - preview, err := domain.NewInitialRunExecutionProfileSnapshot( - "profile-host-preview", run, mission, "test_operator", "preview", at) - if err != nil { - t.Fatal(err) - } - initialInteraction, err := domain.NewInitialRunExecutionInteractionSnapshot( - "interaction-host-preview", run, mission, mode, preview, - "test_operator", at) - if err != nil { - t.Fatal(err) - } - local, err := preview.Next( - "profile-host-local", domain.RunExecutionProfileLocal, - "test_operator", "local", at.Add(time.Second)) - if err != nil { - t.Fatal(err) - } - interaction, err := initialInteraction.Next( - "interaction-host-controlled", - domain.RunExecutionInteractionControlled, mode, local, - domain.WorkspaceTrustTrusted, true, "test_operator", - "controlled", at.Add(2*time.Second)) - if err != nil { - t.Fatal(err) - } - initialPermission, err := domain.NewInitialRunExecutionPermissionSnapshot( - "permission-host-conservative", run, mission, "test_operator", at) - if err != nil { - t.Fatal(err) - } - permission, err := initialPermission.Next( - "permission-host-full", domain.RunExecutionPermissionFullAccess, - true, "test_operator", "danger full access", at.Add(3*time.Second)) - if err != nil { - t.Fatal(err) - } - specRequest := hostCommandSpecTestRequest(t) - spec, err := NewHostCommandSpec(specRequest) - if err != nil { - t.Fatal(err) - } - intent, err := NewHostExecutionIntent(HostExecutionIntentRequest{ - OperationKeyDigest: hostCommandTestDigest, - RunID: run.ID, MissionID: mission.ID, SessionID: run.SessionID, - WorkspaceID: mission.WorkspaceID, Interaction: interaction, - Profile: local, Permission: permission, Spec: spec, - RequestedBy: "cli_operator", CreatedAt: at.Add(4 * time.Second), - }) - if err != nil { - t.Fatal(err) - } - return hostExecutionFixture{ - intent: intent, environment: specRequest.Environment, - interaction: interaction, profile: local, - permission: permission, surface: mode.Surface, +func TestHistoricalHostExecutionIntentRetainsIdentityAndDisablesAutomaticRetry(t *testing.T) { + spec, err := NewHostCommandSpec(hostCommandSpecTestRequest(t)) + if err != nil { + t.Fatal(err) + } + intent := HostExecutionIntent{ + ProtocolVersion: HostCommandIntentProtocolVersion, PolicyVersion: HostExecutionPolicyVersion, + OperationKeyDigest: hostCommandTestDigest, RunID: "run-host-execution", MissionID: "mission-host-execution", + SessionID: "session-host-execution", WorkspaceID: "workspace-host-execution", + InteractionSnapshotID: "interaction-host-controlled", InteractionRevision: 2, ExecutionProfileRevision: 2, + PermissionSnapshotID: "permission-host-full", PermissionRevision: 2, + PermissionMode: domain.RunExecutionPermissionFullAccess, Spec: spec, + RequestedBy: "cli_operator", NonSandboxed: true, + CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC), + } + intent.RequestID = HostExecutionRequestID(intent.RunID, intent.OperationKeyDigest, intent.Spec.Fingerprint) + if err := intent.Validate(); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*HostExecutionIntent){ + func(value *HostExecutionIntent) { value.AutomaticRetryAllowed = true }, + func(value *HostExecutionIntent) { value.NonSandboxed = false }, + func(value *HostExecutionIntent) { value.Spec.Purpose = "tampered" }, + } { + tampered := intent + mutate(&tampered) + if err := tampered.Validate(); err == nil { + t.Fatal("tampered historical execution intent unexpectedly validated") + } } } diff --git a/internal/runner/once_command.go b/internal/runner/once_command.go index ea364849..0c7cd44c 100644 --- a/internal/runner/once_command.go +++ b/internal/runner/once_command.go @@ -2,182 +2,19 @@ package runner import ( "errors" - "fmt" - "os" "path/filepath" - "sort" - "strconv" "strings" - "time" - "unicode/utf8" - - "cyberagent-workbench/internal/runmutation" ) const ( - OnceCommandProtocolVersion = "once_command.v1" - OnceCommandPolicyVersion = "once_command_policy.v1" - OnceExecutionProtocolVersion = "once_execution.v1" - MaxOnceCommandArguments = 64 - MaxOnceCommandArgumentBytes = 16 * 1024 - MaxOnceCommandArgumentsBytes = 64 * 1024 - MaxOnceCommandEnvironment = 16 - MaxOnceCommandEnvironmentBytes = 16 * 1024 - MaxOnceCommandPurposeRunes = 1200 - MaxOnceCommandTimeout = 10 * time.Minute - MaxOnceOutputBytes = 64 * 1024 + OnceCommandProtocolVersion = "once_command.v1" + OnceCommandPolicyVersion = "once_command_policy.v1" + OnceExecutionProtocolVersion = "once_execution.v1" + MaxOnceOutputBytes = 64 * 1024 ) var ErrOnceCommandBoundary = errors.New("once command boundary is invalid") -// onceShellInterpreters are shell binaries that could reinterpret argv as -// script text. One-shot commands execute directly without a shell, so any of -// these as the executable is rejected outright. -var onceShellInterpreters = map[string]bool{ - "sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, - "ksh": true, "csh": true, "tcsh": true, - "cmd": true, "cmd.exe": true, "powershell": true, "powershell.exe": true, - "pwsh": true, "pwsh.exe": true, "wscript": true, "wscript.exe": true, - "cscript": true, "cscript.exe": true, -} - -// OnceEnvironmentAllowlist is the only environment the one-shot runner ever -// passes to a child. It never loads PowerShell/Bash profiles or inherits the -// agent process environment. -var OnceEnvironmentAllowlist = map[string]bool{ - "SystemRoot": true, "WINDIR": true, "TEMP": true, "TMP": true, -} - -// OnceCommandSpec is the exact, structured request: executable, argv, cwd and -// environment. There is no shell string anywhere in the protocol; each argv -// item is a literal argument to the spawned executable. -type OnceCommandSpec struct { - ProtocolVersion string `json:"protocol_version"` - ExecutablePath string `json:"executable_path"` - Argv []string `json:"argv"` - WorkingDirectory string `json:"working_directory"` - Environment []string `json:"environment"` - TimeoutMilliseconds int64 `json:"timeout_milliseconds"` - Purpose string `json:"purpose"` -} - -// OnceCommandRequest binds a spec to one Run and Workspace. The workspace -// root is a trusted, already-resolved path supplied by the application layer. -type OnceCommandRequest struct { - Spec OnceCommandSpec - RunID string - MissionID string - WorkspaceID string - WorkspaceRoot string - RequestedBy string - OperatorApproved bool -} - -// ValidateOnceCommandSpec enforces the full boundary: shell interpreters, -// workspace escape via cwd symlinks/junctions, hostile argv/env, bounds, and -// purpose limits. It never executes anything. -func ValidateOnceCommandSpec(spec OnceCommandSpec, workspaceRoot string) error { - if spec.ProtocolVersion != OnceCommandProtocolVersion { - return fmt.Errorf("%w: unsupported protocol %q", ErrOnceCommandBoundary, spec.ProtocolVersion) - } - if strings.TrimSpace(workspaceRoot) == "" || strings.ContainsRune(workspaceRoot, 0) { - return fmt.Errorf("%w: workspace root is required", ErrOnceCommandBoundary) - } - resolvedRoot, err := filepath.EvalSymlinks(workspaceRoot) - if err != nil { - return fmt.Errorf("%w: workspace root does not resolve: %v", ErrOnceCommandBoundary, err) - } - if err := validateOnceExecutable(spec.ExecutablePath, resolvedRoot); err != nil { - return err - } - if len(spec.Argv) > MaxOnceCommandArguments { - return fmt.Errorf("%w: argv exceeds %d entries", ErrOnceCommandBoundary, MaxOnceCommandArguments) - } - totalArgs := 0 - for _, argument := range spec.Argv { - if !utf8.ValidString(argument) || strings.ContainsRune(argument, 0) { - return fmt.Errorf("%w: argv entry is not valid UTF-8 without NUL", ErrOnceCommandBoundary) - } - if len(argument) > MaxOnceCommandArgumentBytes { - return fmt.Errorf("%w: argv entry exceeds %d bytes", ErrOnceCommandBoundary, MaxOnceCommandArgumentBytes) - } - totalArgs += len(argument) - } - if totalArgs > MaxOnceCommandArgumentsBytes { - return fmt.Errorf("%w: argv exceeds %d bytes", ErrOnceCommandBoundary, MaxOnceCommandArgumentsBytes) - } - if err := validateOnceWorkingDirectory(spec.WorkingDirectory, resolvedRoot); err != nil { - return err - } - if err := validateOnceEnvironment(spec.Environment); err != nil { - return err - } - if spec.TimeoutMilliseconds < 1 || spec.TimeoutMilliseconds > MaxOnceCommandTimeout.Milliseconds() { - return fmt.Errorf("%w: timeout must be between 1ms and %s", ErrOnceCommandBoundary, MaxOnceCommandTimeout) - } - if strings.TrimSpace(spec.Purpose) == "" || utf8.RuneCountInString(spec.Purpose) > MaxOnceCommandPurposeRunes || - !utf8.ValidString(spec.Purpose) || strings.ContainsRune(spec.Purpose, 0) { - return fmt.Errorf("%w: purpose must be bounded valid UTF-8", ErrOnceCommandBoundary) - } - return nil -} - -func validateOnceExecutable(executablePath, resolvedRoot string) error { - if !filepath.IsAbs(executablePath) || strings.ContainsRune(executablePath, 0) { - return fmt.Errorf("%w: executable must be an absolute path", ErrOnceCommandBoundary) - } - info, err := os.Lstat(executablePath) - if err != nil { - return fmt.Errorf("%w: executable stat: %v", ErrOnceCommandBoundary, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return fmt.Errorf("%w: executable must be a regular file without symlink or junction", ErrOnceCommandBoundary) - } - base := strings.ToLower(filepath.Base(executablePath)) - if onceShellInterpreters[base] { - return fmt.Errorf("%w: shell interpreter %q cannot wrap a one-shot command", ErrOnceCommandBoundary, base) - } - if !onceExecutableExtensionAllowed(base) { - return fmt.Errorf("%w: executable must be a native binary, not a script interpreter target", ErrOnceCommandBoundary) - } - resolvedExecutable, err := filepath.EvalSymlinks(executablePath) - if err != nil { - return fmt.Errorf("%w: executable does not resolve: %v", ErrOnceCommandBoundary, err) - } - resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(resolvedExecutable)) - if err != nil { - return fmt.Errorf("%w: executable directory does not resolve: %v", ErrOnceCommandBoundary, err) - } - if within, err := pathWithin(resolvedDir, resolvedRoot); err != nil { - return fmt.Errorf("%w: %v", ErrOnceCommandBoundary, err) - } else if within { - return fmt.Errorf("%w: workspace files cannot be executed as one-shot commands", ErrOnceCommandBoundary) - } - return nil -} - -func validateOnceWorkingDirectory(workingDirectory, resolvedRoot string) error { - if strings.TrimSpace(workingDirectory) == "" || strings.ContainsRune(workingDirectory, 0) { - return fmt.Errorf("%w: working directory is required", ErrOnceCommandBoundary) - } - joined := workingDirectory - if !filepath.IsAbs(joined) { - joined = filepath.Join(resolvedRoot, joined) - } - resolved, err := filepath.EvalSymlinks(joined) - if err != nil { - return fmt.Errorf("%w: working directory does not resolve: %v", ErrOnceCommandBoundary, err) - } - within, err := pathWithin(resolved, resolvedRoot) - if err != nil { - return fmt.Errorf("%w: %v", ErrOnceCommandBoundary, err) - } - if !within { - return fmt.Errorf("%w: working directory escapes the workspace root", ErrOnceCommandBoundary) - } - return nil -} - func pathWithin(candidate, root string) (bool, error) { candidate = filepath.Clean(candidate) root = filepath.Clean(root) @@ -190,49 +27,3 @@ func pathWithin(candidate, root string) (bool, error) { } return !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..", nil } - -func validateOnceEnvironment(environment []string) error { - if len(environment) > MaxOnceCommandEnvironment { - return fmt.Errorf("%w: environment exceeds %d entries", ErrOnceCommandBoundary, MaxOnceCommandEnvironment) - } - seen := make(map[string]bool, len(environment)) - for _, entry := range environment { - if len(entry) > MaxOnceCommandEnvironmentBytes || strings.ContainsRune(entry, 0) || !utf8.ValidString(entry) { - return fmt.Errorf("%w: environment entry is invalid", ErrOnceCommandBoundary) - } - key, _, found := strings.Cut(entry, "=") - if !found || !OnceEnvironmentAllowlist[key] { - return fmt.Errorf("%w: environment key %q is not in the allowlist", ErrOnceCommandBoundary, key) - } - if seen[key] { - return fmt.Errorf("%w: environment key %q is duplicated", ErrOnceCommandBoundary, key) - } - seen[key] = true - } - return nil -} - -// OnceCommandSpecFingerprint is the immutable digest of the exact command. -func OnceCommandSpecFingerprint(spec OnceCommandSpec) string { - return runmutation.Fingerprint("once_command_spec.v1", - spec.ExecutablePath, strings.Join(spec.Argv, "\x00"), - spec.WorkingDirectory, strings.Join(sortOnceEnvironment(spec.Environment), "\x00"), - strconv.FormatInt(spec.TimeoutMilliseconds, 10), spec.Purpose) -} - -// OnceCommandRequestFingerprint binds the spec to the Run and Workspace. -func OnceCommandRequestFingerprint(runID, workspaceID string, spec OnceCommandSpec) string { - return runmutation.Fingerprint("once_command_request.v1", runID, workspaceID, OnceCommandSpecFingerprint(spec)) -} - -// OnceCommandApprovalFingerprint binds the approved request to the approval -// identity, so parameters cannot change after approval. -func OnceCommandApprovalFingerprint(requestFingerprint, approvalID string) string { - return runmutation.Fingerprint("once_command_approval.v1", requestFingerprint, approvalID) -} - -func sortOnceEnvironment(values []string) []string { - cloned := append([]string(nil), values...) - sort.Strings(cloned) - return cloned -} diff --git a/internal/runner/once_command_test.go b/internal/runner/once_command_test.go deleted file mode 100644 index 23211971..00000000 --- a/internal/runner/once_command_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package runner - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func testExecutable(t *testing.T) string { - t.Helper() - path, err := os.Executable() - if err != nil { - t.Fatal(err) - } - return path -} - -func validSpec(executable, root string) OnceCommandSpec { - return OnceCommandSpec{ - ProtocolVersion: OnceCommandProtocolVersion, - ExecutablePath: executable, - Argv: []string{"one", "two"}, - WorkingDirectory: root, - Environment: nil, - TimeoutMilliseconds: 30_000, - Purpose: "validation fixture", - } -} - -func TestValidateOnceCommandSpecAcceptsStructuredRequest(t *testing.T) { - root := t.TempDir() - spec := validSpec(testExecutable(t), root) - if err := ValidateOnceCommandSpec(spec, root); err != nil { - t.Fatalf("valid spec rejected: %v", err) - } - if OnceCommandSpecFingerprint(spec) == "" || len(OnceCommandSpecFingerprint(spec)) != 64 { - t.Fatal("spec fingerprint missing") - } - if OnceCommandRequestFingerprint("run-1", "ws-1", spec) == OnceCommandRequestFingerprint("run-2", "ws-1", spec) { - t.Fatal("request fingerprint does not bind the Run") - } -} - -func TestValidateOnceCommandSpecFailsClosed(t *testing.T) { - root := t.TempDir() - cases := []struct { - name string - mutate func(*OnceCommandSpec) - }{ - - {name: "shell interpreter", mutate: func(spec *OnceCommandSpec) { - spec.ExecutablePath = filepath.Join(os.TempDir(), "powershell.exe") - _ = os.WriteFile(spec.ExecutablePath, []byte("x"), 0o600) - }}, - {name: "workspace escape", mutate: func(spec *OnceCommandSpec) { - spec.WorkingDirectory = t.TempDir() - }}, - {name: "executable inside workspace", mutate: func(spec *OnceCommandSpec) { - spec.ExecutablePath = filepath.Join(root, "tool.exe") - _ = os.WriteFile(spec.ExecutablePath, []byte("x"), 0o600) - }}, - {name: "env not allowlisted", mutate: func(spec *OnceCommandSpec) { - spec.Environment = []string{"SECRET_KEY=1"} - }}, - {name: "bad utf8 argv", mutate: func(spec *OnceCommandSpec) { - spec.Argv = []string{string([]byte{0xff, 0xfe})} - }}, - {name: "missing purpose", mutate: func(spec *OnceCommandSpec) { spec.Purpose = "" }}, - {name: "timeout zero", mutate: func(spec *OnceCommandSpec) { spec.TimeoutMilliseconds = 0 }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - spec := validSpec(testExecutable(t), root) - tc.mutate(&spec) - if err := ValidateOnceCommandSpec(spec, root); err == nil { - t.Fatalf("hostile spec accepted: %#v", spec) - } - }) - } -} - -func TestValidateOnceCommandSpecRejectsSymlinkWorkingDirectory(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink creation may require privileges") - } - root := t.TempDir() - outside := t.TempDir() - link := filepath.Join(root, "escape") - if err := os.Symlink(outside, link); err != nil { - t.Fatal(err) - } - spec := validSpec(testExecutable(t), root) - spec.WorkingDirectory = link - if err := ValidateOnceCommandSpec(spec, root); err == nil { - t.Fatal("symlinked working directory escaped the workspace") - } -} - -func TestBoundedOnceBufferHashesTheCompleteObservedStream(t *testing.T) { - prefix := strings.Repeat("p", MaxOnceOutputBytes) - left := &boundedOnceBuffer{} - right := &boundedOnceBuffer{} - _, _ = left.Write([]byte(prefix + "left-tail")) - _, _ = right.Write([]byte(prefix + "right-tail")) - leftCapture, rightCapture := left.Capture(), right.Capture() - if leftCapture.CapturedPrefixSHA256 != rightCapture.CapturedPrefixSHA256 || - leftCapture.ObservedSHA256 == rightCapture.ObservedSHA256 || - len(leftCapture.ObservedSHA256) != 64 || len(rightCapture.ObservedSHA256) != 64 || - !leftCapture.Truncated || !rightCapture.Truncated { - t.Fatalf("complete stream digests are not distinct: left=%#v right=%#v", - leftCapture, rightCapture) - } -} - -func TestOnceProcessHelper(t *testing.T) { - switch os.Getenv("CYBERAGENT_ONCE_HELPER") { - case "parent": - marker := os.Getenv("CYBERAGENT_ONCE_MARKER") - command := exec.Command(os.Args[0], "-test.run=^TestOnceProcessHelper$") - command.Env = append([]string(nil), - "CYBERAGENT_ONCE_HELPER=child", - "CYBERAGENT_ONCE_MARKER="+marker, - "SystemRoot="+os.Getenv("SystemRoot"), - ) - if err := command.Start(); err != nil { - _ = os.WriteFile(marker+".start-error", []byte(err.Error()), 0o600) - } - case "child": - time.Sleep(1200 * time.Millisecond) - _ = os.WriteFile(os.Getenv("CYBERAGENT_ONCE_MARKER"), []byte("escaped"), 0o600) - } -} - -func TestPlatformOnceStarterReapsDescendantsAfterSuccessfulParentExit(t *testing.T) { - marker := filepath.Join(t.TempDir(), "descendant-survived") - starter := NewPlatformOnceProcessStarter() - if starter == nil || !starter.Available() { - t.Skip("platform once starter is unavailable") - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - result, err := starter.Start(ctx, OnceStartSpec{ - RequestFingerprint: strings.Repeat("a", 64), - ExecutablePath: testExecutable(t), - Argv: []string{"-test.run=^TestOnceProcessHelper$"}, - WorkingDirectory: t.TempDir(), - Environment: []string{ - "CYBERAGENT_ONCE_HELPER=parent", - "CYBERAGENT_ONCE_MARKER=" + marker, - "SystemRoot=" + os.Getenv("SystemRoot"), - }, - }) - if err != nil || result.ExitCode != 0 || !result.TreeReaped || !result.StdinClosed { - t.Fatalf("starter result=%#v err=%v", result, err) - } - if data, readErr := os.ReadFile(marker + ".start-error"); readErr == nil { - t.Fatalf("helper could not start descendant: %s", data) - } - time.Sleep(1600 * time.Millisecond) - if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { - t.Fatalf("background descendant escaped one-shot authority: %v", statErr) - } -} diff --git a/internal/runner/once_execution.go b/internal/runner/once_execution.go index 8eef897a..6dce9bbe 100644 --- a/internal/runner/once_execution.go +++ b/internal/runner/once_execution.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "fmt" "hash" "strings" "time" @@ -23,24 +22,6 @@ type OnceOutputCapture struct { Truncated bool } -// OnceExecutionResult is untrusted evidence: exit code, bounded redacted -// prefixes, timing, and termination flags. It is deliberately not persisted -// with raw output anywhere. -type OnceExecutionResult struct { - ProtocolVersion string - PolicyVersion string - RequestFingerprint string - ExitCode int - Stdout OnceOutputCapture - Stderr OnceOutputCapture - StartedAt time.Time - CompletedAt time.Time - TimedOut bool - Cancelled bool - TreeReaped bool - StdinClosed bool -} - // OnceStarter starts one process with its full environment replaced by the // allowlisted entries. Implementations must arrange whole-process-tree // termination when ctx is cancelled. @@ -71,19 +52,12 @@ type OnceStartResult struct { } // NewPlatformOnceProcessStarter exposes the same whole-process-tree primitive -// used by OnceExecutor to other Go-owned fixed command families. Callers remain +// shared by Go-owned fixed command families. Callers remain // responsible for validating their closed executable/argv/cwd contract before // invoking Start; the starter owns stdin closure, bounded output, cancellation, // and descendant reaping. func NewPlatformOnceProcessStarter() OnceStarter { return newPlatformOnceStarter() } -// OnceExecutor executes validated one-shot requests. It never inspects or -// persists raw output; the starter returns only bounded redacted projections. - -// Execute validates the request again (defense in depth), then runs it under -// the per-call timeout. Cancellation and timeout must terminate the whole -// process tree, never leaving background children behind. - // boundedOnceBuffer captures at most limit bytes, records the true observed // byte count, marks truncation, and enforces UTF-8 on the retained prefix. type boundedOnceBuffer struct { @@ -135,5 +109,3 @@ func (b *boundedOnceBuffer) Capture() OnceOutputCapture { } return capture } - -var _ = fmt.Sprintf diff --git a/internal/runner/once_execution_other.go b/internal/runner/once_execution_other.go index 924db451..bc9fba8b 100644 --- a/internal/runner/once_execution_other.go +++ b/internal/runner/once_execution_other.go @@ -12,10 +12,6 @@ import ( "time" ) -// onceExecutableExtensionAllowed accepts native Unix executables; the shell -// interpreter denylist is the guard here. -func onceExecutableExtensionAllowed(string) bool { return true } - // unixOnceStarter runs the executable in its own process group so timeout and // cancellation can signal the complete tree. type unixOnceStarter struct{} diff --git a/internal/runner/once_execution_windows.go b/internal/runner/once_execution_windows.go index 693852eb..453a679b 100644 --- a/internal/runner/once_execution_windows.go +++ b/internal/runner/once_execution_windows.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "sort" "strings" "time" @@ -155,12 +154,6 @@ func onceCaptureFromControlled(result controlledOutputResult) OnceOutputCapture return capture } -// onceExecutableExtensionAllowed restricts Windows to native binaries so a -// .bat/.cmd script can never smuggle a cmd.exe shell wrapper. -func onceExecutableExtensionAllowed(base string) bool { - return filepath.Ext(base) == ".exe" || filepath.Ext(base) == ".com" -} - // onceEnvironmentBlock builds a full replacement environment block from the // allowlisted entries only; the agent process environment is never inherited. func onceEnvironmentBlock(values []string) []uint16 { diff --git a/internal/runner/risk_escalation.go b/internal/runner/risk_escalation.go index 8f7111a7..0c2a20a4 100644 --- a/internal/runner/risk_escalation.go +++ b/internal/runner/risk_escalation.go @@ -199,31 +199,6 @@ func (b RiskEscalationResourceBudget) Validate(spec HostCommandSpec) error { return nil } -type RiskEscalationProposalRequest struct { - ID string - RunID string - MissionID string - SessionID string - WorkspaceID string - RootAgentID string - SupervisorTurn int - SupervisorToolCallID string - ToolInvocationID string - ModeSnapshotID string - ModeRevision int64 - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileSnapshotID string - ExecutionProfileRevision int64 - Permission domain.RunExecutionPermissionSnapshot - WorkspaceRootFingerprint string - CapabilityGeneration string - Spec HostCommandSpec - Scope RiskEscalationScope - RequestedBy string - CreatedAt time.Time -} - type RiskEscalationProposal struct { ID string ProtocolVersion string @@ -258,40 +233,6 @@ type RiskEscalationProposal struct { CreatedAt time.Time } -func NewRiskEscalationProposal(request RiskEscalationProposalRequest) ( - RiskEscalationProposal, error, -) { - proposal := RiskEscalationProposal{ - ID: strings.TrimSpace(request.ID), - ProtocolVersion: RiskEscalationProtocolVersion, - PolicyVersion: RiskEscalationPolicyVersion, - RunID: strings.TrimSpace(request.RunID), MissionID: strings.TrimSpace(request.MissionID), - SessionID: strings.TrimSpace(request.SessionID), WorkspaceID: strings.TrimSpace(request.WorkspaceID), - RootAgentID: strings.TrimSpace(request.RootAgentID), SupervisorTurn: request.SupervisorTurn, - SupervisorToolCallID: strings.TrimSpace(request.SupervisorToolCallID), - ToolInvocationID: strings.TrimSpace(request.ToolInvocationID), - ModeSnapshotID: strings.TrimSpace(request.ModeSnapshotID), ModeRevision: request.ModeRevision, - InteractionSnapshotID: strings.TrimSpace(request.InteractionSnapshotID), - InteractionRevision: request.InteractionRevision, - ExecutionProfileSnapshotID: strings.TrimSpace(request.ExecutionProfileSnapshotID), - ExecutionProfileRevision: request.ExecutionProfileRevision, - PermissionSnapshotID: request.Permission.ID, - PermissionRevision: request.Permission.Revision, - PermissionMode: request.Permission.Mode, - WorkspaceRootFingerprint: strings.ToLower(strings.TrimSpace(request.WorkspaceRootFingerprint)), - CapabilityGeneration: strings.ToLower(strings.TrimSpace(request.CapabilityGeneration)), - Spec: request.Spec, Scope: request.Scope, - ResourceBudget: NewRiskEscalationResourceBudget(request.Spec), - RequestedBy: strings.TrimSpace(request.RequestedBy), CreatedAt: request.CreatedAt.UTC(), - } - proposal.Fingerprint = RiskEscalationProposalFingerprint(proposal) - if request.Permission.Validate() != nil || request.Permission.RunID != proposal.RunID || - request.Permission.MissionID != proposal.MissionID || proposal.Validate() != nil { - return RiskEscalationProposal{}, ErrHostCommandBoundary - } - return proposal, nil -} - func (p RiskEscalationProposal) Validate() error { for _, value := range []string{p.ID, p.RunID, p.MissionID, p.SessionID, p.WorkspaceID, p.RootAgentID, p.SupervisorToolCallID, p.ToolInvocationID, @@ -327,61 +268,6 @@ func RiskEscalationProposalFingerprint(proposal RiskEscalationProposal) string { return hex.EncodeToString(digest[:]) } -func RiskEscalationProposalRequestFingerprint(proposal RiskEscalationProposal) string { - // ToolInvocationID identifies one gateway attempt, not the durable semantic - // call. Resuming the same Supervisor call allocates a fresh attempt identity; - // the immutable proposal still retains the original invocation for audit. - semantic := struct { - ProtocolVersion string - PolicyVersion string - RunID string - MissionID string - SessionID string - WorkspaceID string - RootAgentID string - SupervisorTurn int - SupervisorToolCallID string - ModeSnapshotID string - ModeRevision int64 - InteractionSnapshotID string - InteractionRevision int64 - ExecutionProfileSnapshotID string - ExecutionProfileRevision int64 - PermissionSnapshotID string - PermissionRevision int64 - PermissionMode domain.RunExecutionPermissionMode - WorkspaceRootFingerprint string - CapabilityGeneration string - SpecFingerprint string - ScopeFingerprint string - ResourceBudget RiskEscalationResourceBudget - RequestedBy string - }{ - ProtocolVersion: proposal.ProtocolVersion, PolicyVersion: proposal.PolicyVersion, - RunID: proposal.RunID, MissionID: proposal.MissionID, SessionID: proposal.SessionID, - WorkspaceID: proposal.WorkspaceID, RootAgentID: proposal.RootAgentID, - SupervisorTurn: proposal.SupervisorTurn, - SupervisorToolCallID: proposal.SupervisorToolCallID, - ModeSnapshotID: proposal.ModeSnapshotID, ModeRevision: proposal.ModeRevision, - InteractionSnapshotID: proposal.InteractionSnapshotID, - InteractionRevision: proposal.InteractionRevision, - ExecutionProfileSnapshotID: proposal.ExecutionProfileSnapshotID, - ExecutionProfileRevision: proposal.ExecutionProfileRevision, - PermissionSnapshotID: proposal.PermissionSnapshotID, - PermissionRevision: proposal.PermissionRevision, PermissionMode: proposal.PermissionMode, - WorkspaceRootFingerprint: proposal.WorkspaceRootFingerprint, - CapabilityGeneration: proposal.CapabilityGeneration, - SpecFingerprint: proposal.Spec.Fingerprint, ScopeFingerprint: proposal.Scope.Fingerprint, - ResourceBudget: proposal.ResourceBudget, RequestedBy: proposal.RequestedBy, - } - encoded, err := json.Marshal(semantic) - if err != nil { - return "" - } - digest := sha256.Sum256(encoded) - return hex.EncodeToString(digest[:]) -} - type RiskEscalationOperation struct { KeyDigest string RequestFingerprint string @@ -430,27 +316,6 @@ type RiskEscalationAuthorization struct { AuthorizedAt time.Time } -func NewRiskEscalationAuthorization(proposal RiskEscalationProposal, - approvalID string, approvalVersion int64, approvalFingerprint string, - grantID string, grantGeneration int64, grantConsumptionID string, - reviewedBy string, authorizedAt time.Time, -) (RiskEscalationAuthorization, error) { - authorization := RiskEscalationAuthorization{ - ProtocolVersion: RiskEscalationProtocolVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - ApprovalID: strings.TrimSpace(approvalID), ApprovalVersion: approvalVersion, - ApprovalFingerprint: strings.ToLower(strings.TrimSpace(approvalFingerprint)), - GrantID: strings.TrimSpace(grantID), GrantGeneration: grantGeneration, - GrantConsumptionID: strings.TrimSpace(grantConsumptionID), - ScopeFingerprint: proposal.Scope.Fingerprint, - ReviewedBy: strings.TrimSpace(reviewedBy), AuthorizedAt: authorizedAt.UTC(), - } - if proposal.Validate() != nil || authorization.Validate() != nil { - return RiskEscalationAuthorization{}, ErrHostCommandBoundary - } - return authorization, nil -} - func (a RiskEscalationAuthorization) Validate() error { for _, value := range []string{a.ProposalID, a.ApprovalID, a.ReviewedBy} { if !validIdentity(value) { @@ -518,20 +383,6 @@ type RiskEscalationInvalidation struct { CreatedAt time.Time } -func NewRiskEscalationInvalidation(id string, proposalID string, grantID string, - reasonCode string, detail string, createdAt time.Time, -) (RiskEscalationInvalidation, error) { - value := RiskEscalationInvalidation{ID: strings.TrimSpace(id), - ProposalID: strings.TrimSpace(proposalID), GrantID: strings.TrimSpace(grantID), - ReasonCode: strings.TrimSpace(reasonCode), Detail: normalizeRiskText(detail), - CreatedAt: createdAt.UTC()} - value.Fingerprint = RiskEscalationInvalidationFingerprint(value) - if value.Validate() != nil { - return RiskEscalationInvalidation{}, ErrHostCommandBoundary - } - return value, nil -} - func (i RiskEscalationInvalidation) Validate() error { if !validIdentity(i.ID) || !validIdentity(i.ProposalID) || (i.GrantID != "" && !validIdentity(i.GrantID)) || i.Detail == "" || @@ -559,33 +410,6 @@ func RiskEscalationInvalidationFingerprint(value RiskEscalationInvalidation) str return hex.EncodeToString(digest[:]) } -func NewRiskEscalationResult(id string, proposal RiskEscalationProposal, - authorization RiskEscalationAuthorization, requestID string, status string, - errorCode string, sourceKind string, sourceRef string, contentSHA256 string, - uncertain bool, createdAt time.Time, -) (RiskEscalationResult, error) { - result := RiskEscalationResult{ - ID: strings.TrimSpace(id), ProtocolVersion: RiskEscalationProtocolVersion, - ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, - ApprovalID: authorization.ApprovalID, - ApprovalFingerprint: authorization.ApprovalFingerprint, - GrantID: authorization.GrantID, - GrantConsumptionID: authorization.GrantConsumptionID, - RequestID: strings.TrimSpace(requestID), RunID: proposal.RunID, - SessionID: proposal.SessionID, Status: strings.TrimSpace(status), - ErrorCode: strings.TrimSpace(errorCode), SourceKind: strings.TrimSpace(sourceKind), - SourceRef: strings.TrimSpace(sourceRef), - ContentSHA256: strings.ToLower(strings.TrimSpace(contentSHA256)), - Uncertain: uncertain, CreatedAt: createdAt.UTC(), - } - result.Fingerprint = RiskEscalationResultFingerprint(result) - if proposal.Validate() != nil || authorization.Validate() != nil || - authorization.ProposalID != proposal.ID || result.Validate() != nil { - return RiskEscalationResult{}, ErrHostCommandBoundary - } - return result, nil -} - func (r RiskEscalationResult) Validate() error { for _, value := range []string{r.ID, r.ProposalID, r.ApprovalID, r.RequestID, r.RunID, r.SessionID, r.SourceKind, r.SourceRef} { diff --git a/internal/store/historical_host_execution_receipt_test.go b/internal/store/historical_host_execution_receipt_test.go index 1ab7c8d7..1b58edb3 100644 --- a/internal/store/historical_host_execution_receipt_test.go +++ b/internal/store/historical_host_execution_receipt_test.go @@ -12,7 +12,7 @@ import ( // Frozen old-schema migration input. This is data construction, not execution. func seedHistoricalHostExecutionReceipt( ctx context.Context, s *SQLiteStore, - result runner.HostExecutionResult, + intent runner.HostExecutionIntent, receipt runner.HostExecutionReceipt, ) (runner.HostExecutionReceipt, bool, error) { version, err := s.SchemaVersion(ctx) if err != nil { @@ -22,41 +22,27 @@ func seedHistoricalHostExecutionReceipt( return runner.HostExecutionReceipt{}, false, fmt.Errorf("historical receipt fixture rejects schema %d", version) } - if err := result.Validate(); err != nil { + if err := receipt.Validate(); err != nil { return runner.HostExecutionReceipt{}, false, apperror.Wrap(apperror.CodeInvalidArgument, - "host command execution result is invalid", err) - } - receipt, err := runner.ProjectHostExecutionReceipt(result) - if err != nil { - return runner.HostExecutionReceipt{}, false, err + "historical host command receipt is invalid", err) } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { return runner.HostExecutionReceipt{}, false, err } defer func() { _ = tx.Rollback() }() - intent, found, err := getHostExecutionIntent(ctx, tx, result.RequestID) + storedIntent, found, err := getHostExecutionIntent(ctx, tx, receipt.RequestID) if err != nil { return runner.HostExecutionReceipt{}, false, err } - if !found || intent.OperationKeyDigest != result.OperationKeyDigest || - intent.RunID != result.RunID || intent.MissionID != result.MissionID || - intent.SessionID != result.SessionID || - intent.WorkspaceID != result.WorkspaceID || - intent.InteractionSnapshotID != result.InteractionSnapshotID || - intent.InteractionRevision != result.InteractionRevision || - intent.ExecutionProfileRevision != result.ExecutionProfileRevision || - intent.PermissionSnapshotID != result.PermissionSnapshotID || - intent.PermissionRevision != result.PermissionRevision || - intent.PermissionMode != result.PermissionMode || - intent.Spec.Fingerprint != result.SpecFingerprint { + if !found || intent.RequestID != receipt.RequestID || !hostExecutionIntentsEqual(storedIntent, intent) { return runner.HostExecutionReceipt{}, false, apperror.New( apperror.CodeConflict, "host command execution result is not bound to its intent") } existing, exists, err := getHostExecutionReceipt( - ctx, tx, result.RequestID) + ctx, tx, receipt.RequestID) if err != nil { return runner.HostExecutionReceipt{}, false, err } diff --git a/internal/store/historical_proposal_recovery_test.go b/internal/store/historical_proposal_recovery_test.go index 13bdfca0..881f3caf 100644 --- a/internal/store/historical_proposal_recovery_test.go +++ b/internal/store/historical_proposal_recovery_test.go @@ -186,15 +186,31 @@ func TestHistoricalHostProposalUpgradePreservesEvidenceAndContinuation(t *testin if scenario == "denied" { decision = runner.HostCommandReviewDeny } - review, err := runner.NewHostCommandReview("history-host-review", proposal, decision, "operator", "saved review", strings.Repeat("b", 64), now) - if err != nil { + review := runner.HostCommandReview{ID: "history-host-review", ProtocolVersion: runner.HostCommandReviewProtocolVersion, + PolicyVersion: runner.HostCommandPolicyVersion, ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, + RunID: run.ID, Decision: decision, ReviewedBy: "operator", Reason: "saved review", + OperationKeyDigest: strings.Repeat("b", 64), SingleUseExecutionAuthorized: decision == runner.HostCommandReviewApprove, CreatedAt: now} + review.RequestFingerprint = runner.HostCommandReviewRequestFingerprint(review) + review.Fingerprint = runner.HostCommandReviewFingerprint(review) + if err := review.Validate(); err != nil { t.Fatal(err) } historicalProposalExec(t, st, `INSERT INTO host_command_proposal_reviews(id,proposal_id,proposal_fingerprint,run_id,decision,reviewed_by,operation_key_digest,request_fingerprint,single_use_execution_authorized,capability_grant,review_fingerprint,payload_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,0,?,?,?)`, review.ID, proposal.ID, proposal.Fingerprint, run.ID, review.Decision, review.ReviewedBy, review.OperationKeyDigest, review.RequestFingerprint, review.SingleUseExecutionAuthorized, review.Fingerprint, historicalProposalJSON(t, review), ts(now)) var receipt *runner.HostExecutionReceipt if scenario != "denied" { - intent, err := runner.NewApprovedHostExecutionIntent(proposal, review, strings.Repeat("c", 64), now) - if err != nil { + intent := runner.HostExecutionIntent{ + ProtocolVersion: runner.HostCommandIntentProtocolVersion, PolicyVersion: runner.HostExecutionPolicyVersion, + OperationKeyDigest: strings.Repeat("c", 64), RunID: run.ID, MissionID: run.MissionID, + SessionID: run.SessionID, WorkspaceID: proposal.WorkspaceID, + InteractionSnapshotID: proposal.InteractionSnapshotID, InteractionRevision: proposal.InteractionRevision, + ExecutionProfileRevision: proposal.ExecutionProfileRevision, PermissionSnapshotID: proposal.PermissionSnapshotID, + PermissionRevision: proposal.PermissionRevision, PermissionMode: proposal.PermissionMode, + AuthorizationProposalID: proposal.ID, AuthorizationProposalFingerprint: proposal.Fingerprint, + AuthorizationReviewID: review.ID, AuthorizationReviewFingerprint: review.Fingerprint, + Spec: proposal.Spec, RequestedBy: review.ReviewedBy, NonSandboxed: true, CreatedAt: now, + } + intent.RequestID = runner.HostExecutionRequestID(intent.RunID, intent.OperationKeyDigest, intent.Spec.Fingerprint) + if err := intent.Validate(); err != nil { t.Fatal(err) } data := historicalProposalJSON(t, intent) @@ -220,13 +236,21 @@ func TestHistoricalHostProposalUpgradePreservesEvidenceAndContinuation(t *testin if err := tx.Commit(); err != nil { t.Fatal(err) } - result, err := runner.NewHostCommandProposalResult("history-host-result", proposal, review, saved.RequestID, status, evidence.Provenance.SourceKind, evidence.Provenance.SourceRef, session.ContentSHA256(evidence.Content), now) - if err != nil { - t.Fatal(err) + result := runner.HostCommandProposalResult{ + ID: "history-host-result", ProtocolVersion: runner.HostCommandResultProtocolVersion, PolicyVersion: runner.HostCommandPolicyVersion, + ProposalID: proposal.ID, ProposalFingerprint: proposal.Fingerprint, ReviewID: review.ID, ReviewFingerprint: review.Fingerprint, + RequestID: saved.RequestID, RunID: run.ID, SessionID: run.SessionID, Status: status, + SourceKind: evidence.Provenance.SourceKind, SourceRef: evidence.Provenance.SourceRef, + ContentSHA256: session.ContentSHA256(evidence.Content), CreatedAt: now, + SavedOutput: &runner.HostCommandSavedOutput{ + Stdout: runner.HostCommandSavedStream{Text: "verified helper output", Redacted: true}, + Stderr: runner.HostCommandSavedStream{Redacted: true}, + }, } - out := runner.NewHostCommandSavedOutput(runner.HostExecutionResult{Stdout: runner.ControlledOutput{Data: []byte("verified helper output")}}) - result.SavedOutput = &out result.Fingerprint = runner.HostCommandProposalResultFingerprint(result) + if err := result.Validate(); err != nil { + t.Fatal(err) + } receiptJSON := historicalProposalJSON(t, saved) historicalProposalExec(t, st, `INSERT INTO host_command_proposal_results(id,proposal_id,review_id,request_id,run_id,session_id,session_message_id,status,result_fingerprint,receipt_fingerprint,result_json,receipt_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, result.ID, proposal.ID, review.ID, saved.RequestID, run.ID, run.SessionID, message.ID, status, result.Fingerprint, session.ContentSHA256(receiptJSON), historicalProposalJSON(t, result), receiptJSON, ts(now)) } @@ -237,7 +261,7 @@ func TestHistoricalHostProposalUpgradePreservesEvidenceAndContinuation(t *testin if err := st.Close(); err != nil { t.Fatal(err) } - st, err = Open(f.path) + st, err := Open(f.path) if err != nil { t.Fatal(err) } @@ -284,8 +308,19 @@ func TestHistoricalRiskResumeRequiresSavedOutcomeExactTurnAndNoLiveLease(t *test if err != nil { t.Fatal(err) } - p, err := runner.NewRiskEscalationProposal(runner.RiskEscalationProposalRequest{ID: "risk-escalation-history-proposal", RunID: run.ID, MissionID: run.MissionID, SessionID: run.SessionID, WorkspaceID: f.turn.Mission.WorkspaceID, RootAgentID: f.turn.Agent.ID, SupervisorTurn: f.turn.Checkpoint.NextTurn, SupervisorToolCallID: "historical-risk-call", ToolInvocationID: "historical-risk-invocation", ModeSnapshotID: f.mode.ID, ModeRevision: f.mode.Revision, InteractionSnapshotID: f.interaction.ID, InteractionRevision: f.interaction.Revision, ExecutionProfileSnapshotID: f.profile.ID, ExecutionProfileRevision: f.profile.Revision, Permission: f.permission, WorkspaceRootFingerprint: strings.Repeat("a", 64), CapabilityGeneration: strings.Repeat("b", 64), Spec: f.spec, Scope: scope, RequestedBy: "run_supervisor", CreatedAt: now}) - if err != nil { + p := runner.RiskEscalationProposal{ + ID: "risk-escalation-history-proposal", ProtocolVersion: runner.RiskEscalationProtocolVersion, PolicyVersion: runner.RiskEscalationPolicyVersion, + RunID: run.ID, MissionID: run.MissionID, SessionID: run.SessionID, WorkspaceID: f.turn.Mission.WorkspaceID, + RootAgentID: f.turn.Agent.ID, SupervisorTurn: f.turn.Checkpoint.NextTurn, SupervisorToolCallID: "historical-risk-call", + ToolInvocationID: "historical-risk-invocation", ModeSnapshotID: f.mode.ID, ModeRevision: f.mode.Revision, + InteractionSnapshotID: f.interaction.ID, InteractionRevision: f.interaction.Revision, + ExecutionProfileSnapshotID: f.profile.ID, ExecutionProfileRevision: f.profile.Revision, + PermissionSnapshotID: f.permission.ID, PermissionRevision: f.permission.Revision, PermissionMode: f.permission.Mode, + WorkspaceRootFingerprint: strings.Repeat("a", 64), CapabilityGeneration: strings.Repeat("b", 64), + Spec: f.spec, Scope: scope, ResourceBudget: runner.NewRiskEscalationResourceBudget(f.spec), RequestedBy: "run_supervisor", CreatedAt: now, + } + p.Fingerprint = runner.RiskEscalationProposalFingerprint(p) + if err := p.Validate(); err != nil { t.Fatal(err) } if strings.Contains(scenario, "wrong_turn") { @@ -307,12 +342,26 @@ func TestHistoricalRiskResumeRequiresSavedOutcomeExactTurnAndNoLiveLease(t *test } historicalProposalExec(t, st, `INSERT INTO tool_approvals(id,idempotency_key,proposal_id,run_id,session_id,workspace_id,tool_name,action_class,mode,status,request_fingerprint,decision_reason,requested_by,reviewed_by,version,created_at,updated_at,decided_at) VALUES ('historical-risk-approval','historical-risk-key',?,?,?,?,'host_command_propose','risk_escalation','per_call',?,?,'historical decision','run_supervisor',?,1,?,?,?)`, p.ID, run.ID, run.SessionID, p.WorkspaceID, status, strings.Repeat("c", 64), reviewer, ts(now), ts(now), decided) if scenario == "unknown" || scenario == "completed" { - auth, err := runner.NewRiskEscalationAuthorization(p, "historical-risk-approval", 1, strings.Repeat("c", 64), "", 0, "", "operator", now) - if err != nil { + auth := runner.RiskEscalationAuthorization{ + ProtocolVersion: runner.RiskEscalationProtocolVersion, ProposalID: p.ID, ProposalFingerprint: p.Fingerprint, + ApprovalID: "historical-risk-approval", ApprovalVersion: 1, ApprovalFingerprint: strings.Repeat("c", 64), + ScopeFingerprint: p.Scope.Fingerprint, ReviewedBy: "operator", AuthorizedAt: now, + } + if err := auth.Validate(); err != nil { t.Fatal(err) } - intent, err := runner.NewRiskEscalationHostExecutionIntent(p, auth, strings.Repeat("d", 64), now) - if err != nil { + intent := runner.HostExecutionIntent{ + ProtocolVersion: runner.HostCommandIntentProtocolVersion, PolicyVersion: runner.HostExecutionPolicyVersion, + OperationKeyDigest: strings.Repeat("d", 64), RunID: run.ID, MissionID: run.MissionID, SessionID: run.SessionID, + WorkspaceID: p.WorkspaceID, InteractionSnapshotID: p.InteractionSnapshotID, InteractionRevision: p.InteractionRevision, + ExecutionProfileRevision: p.ExecutionProfileRevision, PermissionSnapshotID: p.PermissionSnapshotID, + PermissionRevision: p.PermissionRevision, PermissionMode: p.PermissionMode, + AuthorizationProposalID: p.ID, AuthorizationProposalFingerprint: p.Fingerprint, + AuthorizationReviewID: auth.ApprovalID, AuthorizationReviewFingerprint: runner.RiskEscalationAuthorizationFingerprint(auth), + Spec: p.Spec, RequestedBy: auth.ReviewedBy, NonSandboxed: true, CreatedAt: now, + } + intent.RequestID = runner.HostExecutionRequestID(intent.RunID, intent.OperationKeyDigest, intent.Spec.Fingerprint) + if err := intent.Validate(); err != nil { t.Fatal(err) } data := historicalProposalJSON(t, intent) @@ -331,8 +380,15 @@ func TestHistoricalRiskResumeRequiresSavedOutcomeExactTurnAndNoLiveLease(t *test if err := tx.Commit(); err != nil { t.Fatal(err) } - result, err := runner.NewRiskEscalationResult("history-risk-result", p, auth, intent.RequestID, "completed", "", evidence.Provenance.SourceKind, evidence.Provenance.SourceRef, session.ContentSHA256(evidence.Content), false, now) - if err != nil { + result := runner.RiskEscalationResult{ + ID: "history-risk-result", ProtocolVersion: runner.RiskEscalationProtocolVersion, + ProposalID: p.ID, ProposalFingerprint: p.Fingerprint, ApprovalID: auth.ApprovalID, ApprovalFingerprint: auth.ApprovalFingerprint, + RequestID: intent.RequestID, RunID: run.ID, SessionID: run.SessionID, Status: "completed", + SourceKind: evidence.Provenance.SourceKind, SourceRef: evidence.Provenance.SourceRef, + ContentSHA256: session.ContentSHA256(evidence.Content), CreatedAt: now, + } + result.Fingerprint = runner.RiskEscalationResultFingerprint(result) + if err := result.Validate(); err != nil { t.Fatal(err) } receiptJSON := historicalProposalJSON(t, historicalReceipt(intent.RequestID, now)) diff --git a/internal/store/host_command_executions_test.go b/internal/store/host_command_executions_test.go index 8d6faf37..3867eba2 100644 --- a/internal/store/host_command_executions_test.go +++ b/internal/store/host_command_executions_test.go @@ -68,30 +68,14 @@ func TestLegacyHostCommandExecutionAuditSurvivesUpgradeWithoutNewExecution( rawDigest := sha256.Sum256(rawOutput) emptyDigest := sha256.Sum256(nil) startedAt := time.Date(2026, 7, 30, 14, 0, 0, 0, time.UTC) - result := runner.HostExecutionResult{ - ProtocolVersion: runner.HostExecutionProtocolVersion, - PolicyVersion: runner.HostExecutionPolicyVersion, - RequestID: intent.RequestID, - OperationKeyDigest: intent.OperationKeyDigest, - RunID: intent.RunID, MissionID: intent.MissionID, - SessionID: intent.SessionID, WorkspaceID: intent.WorkspaceID, - InteractionSnapshotID: intent.InteractionSnapshotID, - InteractionRevision: intent.InteractionRevision, - ExecutionProfileRevision: intent.ExecutionProfileRevision, - PermissionSnapshotID: intent.PermissionSnapshotID, - PermissionRevision: intent.PermissionRevision, - PermissionMode: intent.PermissionMode, - SpecFingerprint: intent.Spec.Fingerprint, - Backend: "test-host-backend", - Stdout: runner.ControlledOutput{ - Data: rawOutput, ObservedBytes: int64(len(rawOutput)), - CapturedBytes: len(rawOutput), - CapturedPrefixSHA256: hex.EncodeToString(rawDigest[:]), - }, - Stderr: runner.ControlledOutput{ - CapturedPrefixSHA256: hex.EncodeToString(emptyDigest[:]), - }, - StartedAt: startedAt, CompletedAt: startedAt.Add(time.Second), + savedReceipt := runner.HostExecutionReceipt{ + ProtocolVersion: runner.HostCommandReceiptProtocolVersion, + PolicyVersion: runner.HostExecutionPolicyVersion, RequestID: intent.RequestID, + Backend: "test-host-backend", + StdoutObservedBytes: int64(len(rawOutput)), StdoutCapturedBytes: len(rawOutput), + StdoutPrefixSHA256: hex.EncodeToString(rawDigest[:]), + StderrPrefixSHA256: hex.EncodeToString(emptyDigest[:]), + StartedAt: startedAt, CompletedAt: startedAt.Add(time.Second), TreeReaped: true, NonSandboxed: true, JobAssignedAtCreation: true, KillOnJobClose: true, ActiveProcessLimit: runner.MaxHostActiveProcesses, @@ -99,7 +83,7 @@ func TestLegacyHostCommandExecutionAuditSurvivesUpgradeWithoutNewExecution( StdinClosed: true, NetworkRequested: true, ProductExecutionEnabled: true, } - receipt, replayed, err := seedHistoricalHostExecutionReceipt(ctx, st, result) + receipt, replayed, err := seedHistoricalHostExecutionReceipt(ctx, st, intent, savedReceipt) if err != nil || replayed { t.Fatalf("record replayed=%v receipt=%+v err=%v", replayed, receipt, err) @@ -108,7 +92,7 @@ func TestLegacyHostCommandExecutionAuditSurvivesUpgradeWithoutNewExecution( if err != nil || !found || loaded != receipt { t.Fatalf("loaded found=%v receipt=%+v err=%v", found, loaded, err) } - _, replayed, err = seedHistoricalHostExecutionReceipt(ctx, st, result) + _, replayed, err = seedHistoricalHostExecutionReceipt(ctx, st, intent, savedReceipt) if err != nil || !replayed { t.Fatalf("result replay replayed=%v err=%v", replayed, err) } @@ -302,17 +286,18 @@ func hostExecutionStoreIntent( if err != nil { t.Fatal(err) } - intent, err := runner.NewHostExecutionIntent( - runner.HostExecutionIntentRequest{ - OperationKeyDigest: strings.Repeat("b", 64), - RunID: runRecord.ID, MissionID: mission.ID, - SessionID: runRecord.SessionID, WorkspaceID: workspace.ID, - Interaction: interaction.Interaction, Profile: profile.Profile, - Permission: permission, Spec: spec, - RequestedBy: "test_operator", - CreatedAt: time.Date(2026, 7, 30, 13, 0, 0, 0, time.UTC), - }) - if err != nil { + intent := runner.HostExecutionIntent{ + ProtocolVersion: runner.HostCommandIntentProtocolVersion, PolicyVersion: runner.HostExecutionPolicyVersion, + OperationKeyDigest: strings.Repeat("b", 64), RunID: runRecord.ID, MissionID: mission.ID, + SessionID: runRecord.SessionID, WorkspaceID: workspace.ID, + InteractionSnapshotID: interaction.Interaction.ID, InteractionRevision: interaction.Interaction.Revision, + ExecutionProfileRevision: profile.Profile.Revision, PermissionSnapshotID: permission.ID, + PermissionRevision: permission.Revision, PermissionMode: permission.Mode, + Spec: spec, RequestedBy: "test_operator", NonSandboxed: true, + CreatedAt: time.Date(2026, 7, 30, 13, 0, 0, 0, time.UTC), + } + intent.RequestID = runner.HostExecutionRequestID(intent.RunID, intent.OperationKeyDigest, spec.Fingerprint) + if err := intent.Validate(); err != nil { t.Fatal(err) } return intent, environment diff --git a/internal/store/migration_v142_test.go b/internal/store/migration_v142_test.go index a568cf93..67f928eb 100644 --- a/internal/store/migration_v142_test.go +++ b/internal/store/migration_v142_test.go @@ -26,34 +26,20 @@ func TestSchemaV142MigratesPopulatedHostExecutionChildrenAndAcceptsDebug(t *test } emptyDigest := sha256.Sum256(nil) startedAt := time.Date(2026, 8, 30, 8, 0, 0, 0, time.UTC) - result := runner.HostExecutionResult{ - ProtocolVersion: runner.HostExecutionProtocolVersion, + receipt := runner.HostExecutionReceipt{ + ProtocolVersion: runner.HostCommandReceiptProtocolVersion, PolicyVersion: runner.HostExecutionPolicyVersion, - RequestID: intent.RequestID, OperationKeyDigest: intent.OperationKeyDigest, - RunID: intent.RunID, MissionID: intent.MissionID, - SessionID: intent.SessionID, WorkspaceID: intent.WorkspaceID, - InteractionSnapshotID: intent.InteractionSnapshotID, - InteractionRevision: intent.InteractionRevision, - ExecutionProfileRevision: intent.ExecutionProfileRevision, - PermissionSnapshotID: intent.PermissionSnapshotID, - PermissionRevision: intent.PermissionRevision, - PermissionMode: intent.PermissionMode, - SpecFingerprint: intent.Spec.Fingerprint, - Backend: "migration-v142-test", - Stdout: runner.ControlledOutput{ - CapturedPrefixSHA256: hex.EncodeToString(emptyDigest[:]), - }, - Stderr: runner.ControlledOutput{ - CapturedPrefixSHA256: hex.EncodeToString(emptyDigest[:]), - }, - StartedAt: startedAt, CompletedAt: startedAt.Add(time.Millisecond), + RequestID: intent.RequestID, Backend: "migration-v142-test", + StdoutPrefixSHA256: hex.EncodeToString(emptyDigest[:]), + StderrPrefixSHA256: hex.EncodeToString(emptyDigest[:]), + StartedAt: startedAt, CompletedAt: startedAt.Add(time.Millisecond), TreeReaped: true, NonSandboxed: true, JobAssignedAtCreation: true, KillOnJobClose: true, ActiveProcessLimit: runner.MaxHostActiveProcesses, JobMemoryLimit: runner.MaxHostProcessMemoryBytes, StdinClosed: true, NetworkRequested: true, ProductExecutionEnabled: true, } - if _, replayed, err := seedHistoricalHostExecutionReceipt(ctx, state, result); err != nil || replayed { + if _, replayed, err := seedHistoricalHostExecutionReceipt(ctx, state, intent, receipt); err != nil || replayed { t.Fatalf("record v141 host receipt replayed=%t err=%v", replayed, err) } @@ -77,23 +63,14 @@ func TestSchemaV142MigratesPopulatedHostExecutionChildrenAndAcceptsDebug(t *test if err != nil { t.Fatal(err) } - interaction, err := state.GetRunExecutionInteraction(ctx, intent.RunID) - if err != nil { - t.Fatal(err) - } - profile, err := state.GetRunExecutionProfile(ctx, intent.RunID) - if err != nil { - t.Fatal(err) - } - debugIntent, err := runner.NewHostExecutionIntent(runner.HostExecutionIntentRequest{ - OperationKeyDigest: strings.Repeat("d", 64), - RunID: intent.RunID, MissionID: intent.MissionID, - SessionID: intent.SessionID, WorkspaceID: intent.WorkspaceID, - Interaction: interaction, Profile: profile, - Permission: permission, Spec: intent.Spec, - RequestedBy: "test_operator", CreatedAt: startedAt.Add(time.Second), - }) - if err != nil { + debugIntent := intent + debugIntent.OperationKeyDigest = strings.Repeat("d", 64) + debugIntent.PermissionSnapshotID = permission.ID + debugIntent.PermissionRevision = permission.Revision + debugIntent.PermissionMode = permission.Mode + debugIntent.CreatedAt = startedAt.Add(time.Second) + debugIntent.RequestID = runner.HostExecutionRequestID(debugIntent.RunID, debugIntent.OperationKeyDigest, debugIntent.Spec.Fingerprint) + if err := debugIntent.Validate(); err != nil { t.Fatal(err) } if replayed, err := seedHistoricalHostExecutionIntent(ctx, state, debugIntent); err != nil || replayed { diff --git a/internal/store/migration_v185_test.go b/internal/store/migration_v185_test.go index d3875ecc..8f2e9e37 100644 --- a/internal/store/migration_v185_test.go +++ b/internal/store/migration_v185_test.go @@ -138,8 +138,14 @@ func seedV177FixedCommandHistory(t *testing.T, st *SQLiteStore, job runner.Comma if err != nil { t.Fatal(err) } - intent, err := runner.NewControlledExecutionIntent(plan, "operator", time.Now().UTC()) - if err != nil { + intent := runner.ControlledExecutionIntent{ + ProtocolVersion: runner.ControlledExecutionIntentProtocolVersion, PolicyVersion: runner.ControlledExecutionPolicyVersion, + RequestID: runner.ControlledExecutionRequestID(plan), PlanID: plan.ID, PlanFingerprint: plan.Fingerprint, + RunID: plan.RunID, WorkspaceID: plan.WorkspaceID, InteractionSnapshotID: plan.InteractionSnapshotID, + InteractionRevision: plan.InteractionRevision, ExecutionProfileRevision: plan.ExecutionProfileRevision, + Kind: plan.Kind, RequestedBy: "operator", CreatedAt: time.Now().UTC(), + } + if err := intent.Validate(); err != nil { t.Fatal(err) } if _, err := st.db.ExecContext(ctx, `INSERT INTO @@ -159,41 +165,16 @@ func seedV177FixedCommandHistory(t *testing.T, st *SQLiteStore, job runner.Comma if id == "historical-fixed-unknown" { continue } - result := runner.ControlledExecutionResult{ProtocolVersion: runner.ControlledExecutionProtocolVersion, PolicyVersion: runner.ControlledExecutionPolicyVersion, - RequestID: intent.RequestID, PlanID: plan.ID, PlanFingerprint: plan.Fingerprint, RunID: plan.RunID, WorkspaceID: plan.WorkspaceID, - InteractionSnapshotID: plan.InteractionSnapshotID, InteractionRevision: plan.InteractionRevision, ExecutionProfileRevision: plan.ExecutionProfileRevision, - Kind: plan.Kind, Backend: "historical-fixture", Stdout: runner.ControlledOutput{CapturedPrefixSHA256: testCommandRuntimeDigest("")}, Stderr: runner.ControlledOutput{CapturedPrefixSHA256: testCommandRuntimeDigest("")}, - StartedAt: intent.CreatedAt, CompletedAt: intent.CreatedAt.Add(time.Second), TreeReaped: true, RestrictedToken: true, LowIntegrityToken: true, JobAssignedAtCreation: true, - KillOnJobClose: true, ActiveProcessLimit: 1, ProcessMemoryLimit: runner.MaxControlledProcessMemoryBytes, StdinClosed: true, ProductExecutionEnabled: true} receipt := runner.ControlledExecutionReceipt{ - RequestID: result.RequestID, ProtocolVersion: result.ProtocolVersion, - PolicyVersion: result.PolicyVersion, Backend: result.Backend, - ExitCode: result.ExitCode, - StdoutObservedBytes: result.Stdout.ObservedBytes, - StdoutCapturedBytes: result.Stdout.CapturedBytes, - StdoutPrefixSHA256: result.Stdout.CapturedPrefixSHA256, - StdoutTruncated: result.Stdout.Truncated, - StderrObservedBytes: result.Stderr.ObservedBytes, - StderrCapturedBytes: result.Stderr.CapturedBytes, - StderrPrefixSHA256: result.Stderr.CapturedPrefixSHA256, - StderrTruncated: result.Stderr.Truncated, - StartedAt: result.StartedAt, CompletedAt: result.CompletedAt, - TimedOut: result.TimedOut, Cancelled: result.Cancelled, - OutputLimitExceeded: result.OutputLimitExceeded, - TreeReaped: result.TreeReaped, - RestrictedToken: result.RestrictedToken, - LowIntegrityToken: result.LowIntegrityToken, - JobAssignedAtCreation: result.JobAssignedAtCreation, - KillOnJobClose: result.KillOnJobClose, - ActiveProcessLimit: result.ActiveProcessLimit, - ProcessMemoryLimit: result.ProcessMemoryLimit, - StdinClosed: result.StdinClosed, - EnvironmentInherited: result.EnvironmentInherited, - NetworkRequested: result.NetworkRequested, - PersistentProcess: result.PersistentProcess, - ProductExecutionEnabled: result.ProductExecutionEnabled, + RequestID: intent.RequestID, ProtocolVersion: runner.ControlledExecutionProtocolVersion, + PolicyVersion: runner.ControlledExecutionPolicyVersion, Backend: "historical-fixture", + StdoutPrefixSHA256: testCommandRuntimeDigest(""), StderrPrefixSHA256: testCommandRuntimeDigest(""), + StartedAt: intent.CreatedAt, CompletedAt: intent.CreatedAt.Add(time.Second), TreeReaped: true, + RestrictedToken: true, LowIntegrityToken: true, JobAssignedAtCreation: true, KillOnJobClose: true, + ActiveProcessLimit: 1, ProcessMemoryLimit: runner.MaxControlledProcessMemoryBytes, + StdinClosed: true, ProductExecutionEnabled: true, } - if err := result.Validate(); err != nil { + if err := receipt.Validate(); err != nil { t.Fatal(err) } if _, err := st.db.ExecContext(ctx, `INSERT INTO diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 9cd82be1..ededf0cc 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -542,34 +542,6 @@ func (s *SQLiteStore) GetTask(ctx context.Context, id string) (agent.Task, error return task, nil } -func (s *SQLiteStore) UpdateTaskStatus(ctx context.Context, id string, status string) error { - res, err := s.db.ExecContext(ctx, `UPDATE tasks SET status = ? WHERE id = ?`, status, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return fmt.Errorf("task %q not found", id) - } - return nil -} - -func (s *SQLiteStore) RecordEvent(ctx context.Context, event agent.Event) error { - if event.CreatedAt.IsZero() { - event.CreatedAt = time.Now().UTC() - } - event.Message = redact.String(event.Message) - redactedPayload, err := redactJSONPayload(event.PayloadJSON) - if err != nil { - return err - } - event.PayloadJSON = redactedPayload - _, err = s.db.ExecContext(ctx, `INSERT INTO events (task_id, workspace_id, type, message, payload_json, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - event.TaskID, event.WorkspaceID, event.Type, event.Message, event.PayloadJSON, ts(event.CreatedAt)) - return err -} - func (s *SQLiteStore) ListEventsByTask(ctx context.Context, taskID string) ([]agent.Event, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, workspace_id, type, message, payload_json, created_at FROM events WHERE task_id = ? ORDER BY id`, taskID) diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index da4b72e7..4708383e 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -1751,7 +1751,10 @@ func TestSQLiteStoreTaskAndEvents(t *testing.T) { if err := st.SaveTask(ctx, task); err != nil { t.Fatal(err) } - if err := st.RecordEvent(ctx, agent.Event{TaskID: task.ID, WorkspaceID: task.WorkspaceID, Type: "test.event", Message: "hello"}); err != nil { + // Retired mock-agent events remain readable from existing databases. + if _, err := st.db.ExecContext(ctx, `INSERT INTO events + (task_id, workspace_id, type, message, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, task.ID, task.WorkspaceID, "test.event", "hello", "", ts(task.CreatedAt)); err != nil { t.Fatal(err) } events, err := st.ListEventsByTask(ctx, task.ID) @@ -1761,7 +1764,8 @@ func TestSQLiteStoreTaskAndEvents(t *testing.T) { if len(events) != 1 || events[0].Type != "test.event" { t.Fatalf("unexpected events: %#v", events) } - if err := st.UpdateTaskStatus(ctx, task.ID, agent.StatusCompleted); err != nil { + task.Status = agent.StatusCompleted + if err := st.SaveTask(ctx, task); err != nil { t.Fatal(err) } loaded, err := st.GetTask(ctx, task.ID) @@ -1836,19 +1840,6 @@ func TestSQLiteStoreRedactsSensitiveContent(t *testing.T) { if strings.Contains(loadedTask.Goal, mimoToken[:11]) { t.Fatalf("secret stored in legacy task: %#v", loadedTask) } - if err := st.RecordEvent(ctx, agent.Event{ - TaskID: task.ID, Type: "test.secret", Message: "observed " + mimoToken, - PayloadJSON: `{"token":"` + mimoToken + `"}`, - }); err != nil { - t.Fatal(err) - } - legacyEvents, err := st.ListEventsByTask(ctx, task.ID) - if err != nil { - t.Fatal(err) - } - if len(legacyEvents) != 1 || strings.Contains(legacyEvents[0].Message+legacyEvents[0].PayloadJSON, mimoToken[:11]) { - t.Fatalf("secret stored in legacy event: %#v", legacyEvents) - } saved, err := st.SaveContextSummary(ctx, contextmgr.Summary{ TaskID: "task-secret", Content: "OPENAI_API_KEY=" + openAIToken, diff --git a/internal/tools/file.go b/internal/tools/file.go index 632eacd9..cba7c482 100644 --- a/internal/tools/file.go +++ b/internal/tools/file.go @@ -52,16 +52,6 @@ func (ReadFileTool) Name() string { return "read_file" } -func (ReadFileTool) Schema() Schema { - return Schema{ - Description: "Read a scoped local file.", - Parameters: map[string]string{ - "path": "Workspace-relative path to read", - "max_bytes": "Maximum UTF-8 content bytes to read", - }, - } -} - func (t ReadFileTool) Run(ctx context.Context, call Call) (Result, error) { if ctx == nil { return Result{Stderr: "tool context is required", ExitCode: 1, MIME: "text/plain; charset=utf-8"}, errors.New("tool context is required") @@ -102,16 +92,6 @@ func (ListWorkspaceTool) Name() string { return "list_workspace" } -func (ListWorkspaceTool) Schema() Schema { - return Schema{ - Description: "List files under a workspace-scoped path.", - Parameters: map[string]string{ - "path": "Relative workspace path to list", - "max_depth": "Maximum recursive depth, default 2", - }, - } -} - func (t ListWorkspaceTool) Run(ctx context.Context, call Call) (Result, error) { if ctx == nil { return Result{Stderr: "tool context is required", ExitCode: 1, MIME: "text/plain; charset=utf-8"}, errors.New("tool context is required") diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 060c4794..1b19ed31 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "sort" "strings" "time" ) @@ -14,11 +13,6 @@ const ( MaxExecutionTimeout = 5 * time.Minute ) -type Schema struct { - Description string - Parameters map[string]string -} - type Call struct { Name string Args map[string]string @@ -35,7 +29,6 @@ type Result struct { type Tool interface { Name() string - Schema() Schema Run(ctx context.Context, call Call) (Result, error) } @@ -62,15 +55,6 @@ func (r *Registry) WithExecutionTimeout(timeout time.Duration) *Registry { return r } -func (r *Registry) Names() []string { - names := make([]string, 0, len(r.tools)) - for name := range r.tools { - names = append(names, name) - } - sort.Strings(names) - return names -} - func (r *Registry) Run(ctx context.Context, call Call) (Result, error) { if r == nil { return Result{ExitCode: 1}, errors.New("tool registry is required") diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index d21ea439..ed969f08 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -13,8 +13,6 @@ type blockingTool struct { func (blockingTool) Name() string { return "blocking" } -func (blockingTool) Schema() Schema { return Schema{} } - func (t blockingTool) Run(context.Context, Call) (Result, error) { <-t.release return Result{Stdout: "released"}, nil @@ -62,5 +60,4 @@ func TestRegistryRecoversToolPanic(t *testing.T) { type panicTool struct{} func (panicTool) Name() string { return "panic" } -func (panicTool) Schema() Schema { return Schema{} } func (panicTool) Run(context.Context, Call) (Result, error) { panic("test panic") } diff --git a/protocols/registry.json b/protocols/registry.json index 10d653ed..046c7580 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -437,10 +437,7 @@ "executionauth.authorization.v1", "full-cdp-session-operation.v1", "once_command.v1", - "once_command_approval.v1", "once_command_policy.v1", - "once_command_request.v1", - "once_command_spec.v1", "operator_steering_cancellation_operation.v1", "operator_steering_cancellation_request.v1", "operator_steering_delivery_mode.v1", @@ -483,7 +480,26 @@ "toolcontract.launch.v1", "toolcontract.operation.v1" ], - "retired_identifiers": [] + "retired_identifiers": [ + { + "identifier": "once_command_approval.v1", + "decision": "ADR 0165 retires the Once execution producer; remove its uncalled approval fingerprint helper.", + "migration_evidence": "Stored approval fingerprints remain unchanged in once_command_proposals and are read directly by internal/store/once_command_proposals.go. Historical reads do not regenerate approval fingerprints.", + "rollback": "Restore the helper from Git if a retained reader needs it; keep stored records and the command-runtime.v2 execution path unchanged." + }, + { + "identifier": "once_command_request.v1", + "decision": "ADR 0165 retires the Once execution producer; remove its uncalled request fingerprint helper.", + "migration_evidence": "Stored request and execution-request fingerprints remain unchanged in once_command_proposals and are read directly by internal/store/once_command_proposals.go. Historical reads do not regenerate execution requests.", + "rollback": "Restore the helper from Git if a retained reader needs it; keep stored records and the command-runtime.v2 execution path unchanged." + }, + { + "identifier": "once_command_spec.v1", + "decision": "ADR 0165 retires the Once execution producer; remove its uncalled command-spec fingerprint helper.", + "migration_evidence": "Stored spec fingerprints remain unchanged in once_command_proposals and are read directly by internal/store/once_command_proposals.go. Historical reads do not reconstruct the removed OnceCommandSpec request type.", + "rollback": "Restore the helper from Git if a retained reader needs it; keep stored records and the command-runtime.v2 execution path unchanged." + } + ] }, { "id": "browser-cdp-process-session", @@ -1146,10 +1162,15 @@ "desktop_workspace_launcher_list.v1", "desktop_workspace_open.v1", "prayu.run-navigation.v1", - "prayu.settings.sidebar.width.v1", "v2_file_upload.v1" ], "retired_identifiers": [ + { + "identifier": "prayu.settings.sidebar.width.v1", + "decision": "Remove the unreachable legacy SettingsView and its private sidebar-width preference reader/writer.", + "rebuild_evidence": "web/src/App.tsx already selects the V2 shell. Its settings layout uses --v2-sidebar-width in web/src/v2/styles.css and does not read this legacy key; removing SettingsView preserves that current layout. Existing localStorage values are retained.", + "rollback": "Restore SettingsView from Git to read the unchanged localStorage value. No stored user setting is deleted." + }, { "identifier": "prayu.sidebar.width.v1", "decision": "The authorized unified-shell replacement removed the legacy App sidebar reader/writer. This is presentation retirement only; existing localStorage values are neither deleted nor migrated.", diff --git a/web/src/components/approval-target-isolation.test.tsx b/web/src/components/approval-target-isolation.test.tsx index b22f0b29..94c51559 100644 --- a/web/src/components/approval-target-isolation.test.tsx +++ b/web/src/components/approval-target-isolation.test.tsx @@ -4,12 +4,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { CyberAgentClient } from "../api/client"; -import type { RunDetailView, RunExecutionPermissionControlView, RunExecutionPermissionView, +import type { RunExecutionPermissionView, ThreadExecutionPermissionControlView } from "../api/types"; -import { capabilityReadinessFixture } from "../test/capability-readiness"; import { v2QueryKeys } from "../v2/query-keys"; import { V2PermissionControl } from "../v2/components/permission-control"; -import { ExecutionPermissionPanel } from "./run-permission-settings"; afterEach(() => { cleanup(); window.localStorage.removeItem("prayu.locale.v1"); }); @@ -39,11 +37,6 @@ function threadResult(id: string, full = false): ThreadExecutionPermissionContro current_run_effect: "no_active_run", current_run_synchronized: false, replayed: false }; } -function runDetail(id: string, full = false): RunDetailView { - // This exported permission panel consumes only its Run identity and preference. - return { run: { id }, execution_permission: permission(full) } as RunDetailView; -} - function deferred() { let resolve!: (result: T) => void; let reject!: (error: Error) => void; @@ -51,24 +44,21 @@ function deferred() { return { promise, resolve, reject }; } -type Host = "thread menu" | "thread settings" | "run panel"; +type Host = "thread menu" | "thread settings"; function fixture(host: Host) { const queries = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false }, } }); - const pending = deferred(); + const pending = deferred(); const change = vi.fn((_target: string, _request: unknown, _operationKey: string) => pending.promise); const get = vi.fn(async (id: string) => threadResult(id)); const client = { hasExecutionPermissionControl: true, getThreadExecutionPermission: get, - changeThreadExecutionPermission: change, postControl: change } as unknown as CyberAgentClient; + changeThreadExecutionPermission: change } as unknown as CyberAgentClient; for (const id of ["target-A", "target-B"]) { queries.setQueryData(v2QueryKeys.permission(id), threadResult(id)); - queries.setQueryData(["run", id], runDetail(id)); } const view = (id: string) => - {host === "run panel" - ? - : } + ; const mounted = render(view("target-A")); const user = userEvent.setup(); @@ -78,17 +68,13 @@ function fixture(host: Host) { return within(screen.getByRole("dialog", { name: "启用完全访问权限?" })) .getByRole("button", { name: "确认启用" }); }; - const targetMode = (id: string) => host === "run panel" - ? queries.getQueryData(["run", id])!.execution_permission.approval_mode - : queries.getQueryData(v2QueryKeys.permission(id))!.execution_permission.approval_mode; - const fullResult = () => host === "run panel" - ? { execution_permission: permission(true), replayed: false } - : threadResult("target-A", true); + const targetMode = (id: string) => queries.getQueryData(v2QueryKeys.permission(id))!.execution_permission.approval_mode; + const fullResult = () => threadResult("target-A", true); return { queries, pending, change, get, user, openFull, targetMode, fullResult, switchToB: () => mounted.rerender(view("target-B")) }; } -describe.each(["thread menu", "thread settings", "run panel"] as const)("%s target isolation", (host) => { +describe.each(["thread menu", "thread settings"] as const)("%s target isolation", (host) => { it("cancels A's Full confirmation when B has the same cached preference", async () => { const f = fixture(host); const oldConfirm = await f.openFull(); @@ -109,8 +95,7 @@ describe.each(["thread menu", "thread settings", "run panel"] as const)("%s targ const f = fixture(host); await f.user.click(await f.openFull()); await waitFor(() => expect(f.change).toHaveBeenCalledTimes(1)); - expect(f.change.mock.calls[0]![0]).toBe(host === "run panel" - ? "/runs/target-A/execution-permission" : "target-A"); + expect(f.change.mock.calls[0]![0]).toBe("target-A"); f.switchToB(); await act(async () => { f.pending.resolve(f.fullResult()); }); await waitFor(() => expect(f.targetMode("target-A")).toBe("full")); diff --git a/web/src/components/archived-threads-settings.test.tsx b/web/src/components/archived-threads-settings.test.tsx deleted file mode 100644 index 8ca0b41d..00000000 --- a/web/src/components/archived-threads-settings.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import type { CyberAgentClient } from "../api/client"; -import type { ThreadView } from "../api/types"; -import { ArchivedThreadsSettings } from "./archived-threads-settings"; - -function archivedThread(id: string, title: string, version: number): ThreadView { - return { - id, - protocol_version: "thread.v1", - workspace_id: `workspace-${id}`, - mission_id: `mission-${id}`, - title, - status: "archived", - last_run_id: `run-${id}`, - version, - composer_state: "unavailable", - archived_at: "2026-08-28T02:00:00Z", - created_at: "2026-08-28T00:00:00Z", - updated_at: "2026-08-28T02:00:00Z", - }; -} - -function renderArchivedSettings(threads: ThreadView[]) { - const getPage = vi.fn().mockResolvedValue({ - items: threads, - page: { limit: 50 }, - requestID: "request-archived-threads", - }); - const transitionThread = vi.fn().mockImplementation((threadID: string, - action: "archive" | "restore" | "delete") => Promise.resolve({ - version: "thread_lifecycle.v1", - thread: { - ...threads.find((thread) => thread.id === threadID), - status: action === "restore" ? "active" : "deleted", - }, - capability_grant: false, - })); - const client = { getPage, transitionThread } as unknown as CyberAgentClient; - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - ); - return { getPage, transitionThread }; -} - -describe("ArchivedThreadsSettings", () => { - it("lists archived Threads and filters them without changing lifecycle state", async () => { - const controls = renderArchivedSettings([ - archivedThread("thread-alpha", "Alpha investigation", 7), - archivedThread("thread-beta", "Beta delivery", 11), - ]); - - expect(await screen.findByRole("heading", { name: "Archived chats" })) - .toBeInTheDocument(); - expect(await screen.findByText("Alpha investigation")).toBeInTheDocument(); - expect(screen.getByText("Beta delivery")).toBeInTheDocument(); - expect(controls.getPage).toHaveBeenCalledWith("/threads", - { limit: 50, status: "archived" }, "", expect.any(AbortSignal)); - - fireEvent.change(screen.getByRole("searchbox", { name: "Search archived chats" }), - { target: { value: "beta" } }); - expect(screen.queryByText("Alpha investigation")).not.toBeInTheDocument(); - expect(screen.getByText("Beta delivery")).toBeInTheDocument(); - expect(controls.transitionThread).not.toHaveBeenCalled(); - }); - - it("restores and confirms deletion through canonical Thread transitions with exact versions", - async () => { - const controls = renderArchivedSettings([ - archivedThread("thread-restore", "Restore me", 7), - archivedThread("thread-delete", "Delete me", 11), - ]); - expect(await screen.findByText("Restore me")).toBeInTheDocument(); - - fireEvent.click(screen.getAllByRole("button", { name: "Restore" })[0]); - await waitFor(() => expect(controls.transitionThread).toHaveBeenCalledWith( - "thread-restore", "restore", - { version: "thread_lifecycle.v1", expected_version: 7 }, - expect.stringMatching(/^settings-thread-restore-/u), - )); - - fireEvent.click(screen.getByRole("button", { name: "Delete Delete me" })); - const dialog = screen.getByRole("dialog", { name: "Delete archived chat" }); - expect(within(dialog).getByText("Delete me")).toBeInTheDocument(); - expect(controls.transitionThread).toHaveBeenCalledTimes(1); - fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); - - await waitFor(() => expect(controls.transitionThread).toHaveBeenCalledWith( - "thread-delete", "delete", - { version: "thread_lifecycle.v1", expected_version: 11 }, - expect.stringMatching(/^settings-thread-delete-/u), - )); - }); -}); diff --git a/web/src/components/archived-threads-settings.tsx b/web/src/components/archived-threads-settings.tsx deleted file mode 100644 index d5153fbb..00000000 --- a/web/src/components/archived-threads-settings.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ArchiveRestore, MessagesSquare, Search, Trash2, X } from "lucide-react"; -import type { CyberAgentClient } from "../api/client"; -import type { ThreadView } from "../api/types"; -import { usePagedResource } from "../hooks/use-paged-resource"; -import { formatDate } from "../lib/format"; -import { useLocale } from "../lib/locale"; -import { useModalFocusTrap } from "../hooks/use-modal-focus-trap"; -import { ErrorState, LoadMoreButton, LoadingState } from "./common"; - -type ThreadAction = "restore" | "delete"; - -export function ArchivedThreadsSettings({ client }: { client: CyberAgentClient }) { - const { t } = useLocale(); - const queryClient = useQueryClient(); - const [search, setSearch] = useState(""); - const [deleteCandidate, setDeleteCandidate] = useState(null); - const query = usePagedResource(client, ["threads", "archived"], "/threads", - { limit: 50, status: "archived" }, true); - const threads = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], - [query.data]); - const normalizedSearch = search.trim().toLocaleLowerCase(); - const visible = useMemo(() => threads.filter((thread) => !normalizedSearch || - `${thread.title} ${thread.id} ${thread.workspace_id ?? ""}`.toLocaleLowerCase() - .includes(normalizedSearch)), [normalizedSearch, threads]); - const transition = useMutation({ - mutationFn: ({ thread, action }: { thread: ThreadView; action: ThreadAction }) => - client.transitionThread(thread.id, action, { - version: "thread_lifecycle.v1", expected_version: thread.version, - }, `settings-thread-${action}-${globalThis.crypto.randomUUID()}`), - onSuccess: (result) => { - setDeleteCandidate(null); - void queryClient.invalidateQueries({ queryKey: ["threads"] }); - void queryClient.invalidateQueries({ queryKey: ["thread", result.thread.id] }); - }, - }); - const deleteDialogRef = useModalFocusTrap(Boolean(deleteCandidate), - () => setDeleteCandidate(null), transition.isPending); - - return
-
-

{t("已归档的聊天", "Archived chats")}

-

{t("归档会隐藏 Thread,但保留消息、运行记录与审计证据。", - "Archiving hides a Thread while retaining messages, runs, and audit evidence.")}

- {threads.length} {t("个 Thread", "Threads")} -
- - {query.isLoading && } - {query.isError && } - {!query.isLoading && !query.isError && visible.length === 0 && -
} -
- {visible.map((thread) =>
-
)} -
- void query.fetchNextPage()} /> - {transition.isError && !deleteCandidate &&

- {transition.error instanceof Error ? transition.error.message : - t("更新归档状态失败", "Could not update archived chat")}

} - {deleteCandidate &&
-
-
-

{t("删除已归档的聊天", "Delete archived chat")}

- {deleteCandidate.title}
-
-
-

{t("此 Thread 将从应用列表中删除。为了诊断与审计,本地消息和运行记录不会被擦除。", - "This Thread will be removed from app lists. Local messages and run records remain for diagnostics and audit.")}

- {transition.isError &&

- {transition.error instanceof Error ? transition.error.message : - t("删除失败", "Could not delete")}

} -
-
- - -
-
-
} -
; -} diff --git a/web/src/components/execution-permission.test.tsx b/web/src/components/execution-permission.test.tsx index ba99291d..a6b8e054 100644 --- a/web/src/components/execution-permission.test.tsx +++ b/web/src/components/execution-permission.test.tsx @@ -5,8 +5,7 @@ import { APIRequestError, CyberAgentClient } from "../api/client"; import type { RunDetailView } from "../api/types"; import { capabilityReadinessFixture, patchCapabilityReadiness } from "../test/capability-readiness"; -import { BrowserCDPPermissionPanel, ExecutionPermissionPanel, - RunPermissionSettings, StandardCodeReadinessPanel } from "./run-permission-settings"; +import { StandardCodeReadinessPanel } from "./run-permission-settings"; vi.mock("../lib/locale", () => ({ useLocale: () => ({ locale: "zh-CN", setLocale: () => undefined, @@ -92,185 +91,6 @@ function standardCodeReadyReadiness() { }); } -describe("ExecutionPermissionPanel", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("keeps only three preferences and disables Full with the exact host unavailability reason", async () => { - const unavailable = detail(); - unavailable.execution_permission.full_activation = "unavailable"; - unavailable.execution_permission.full_unavailable_reason = "Full adapter unavailable: host policy; restart required"; - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - render( - - ); - const choices = within(screen.getByRole("group", { name: "执行权限档位" })); - expect(choices.getAllByRole("button")).toHaveLength(3); - expect(choices.getByRole("button", { name: "请求批准" })).toBeDisabled(); - expect(choices.getByRole("button", { name: "请求批准" })).toHaveAttribute("aria-pressed", "true"); - expect(choices.getByRole("button", { name: "帮我批准" })).toBeEnabled(); - const full = choices.getByRole("button", { name: "完全访问权限" }); - expect(full).toBeDisabled(); - expect(full).toHaveAccessibleDescription(expect.stringContaining( - unavailable.execution_permission.full_unavailable_reason)); - expect(screen.getByText(unavailable.execution_permission.full_unavailable_reason)).toBeVisible(); - await userEvent.setup().click(full); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("writes Auto as a preference without a Workspace or host authority grant", async () => { - const selected = { - ...detail().execution_permission, - protocol_version: "run_execution_permission.v2", policy_version: "execution_permission_policy.v2", - mode: "auto", approval_mode: "auto", approval_policy: "per_operation", - command_scope: "per_operation", filesystem_scope: "per_operation", network_scope: "per_operation", - required_gate: "operation_authority", revision: 2, - }; - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - version: "api.v1", request_id: "req-auto-preference", - data: { execution_permission: selected, replayed: false }, - }), { status: 202, headers: { "Content-Type": "application/json" } })); - vi.stubGlobal("fetch", fetchMock); - render( - - ); - expect(fetchMock).not.toHaveBeenCalled(); - expect(screen.getByText(/影响未知、敏感数据外发、破坏性或共享写入操作不会/u)).toBeVisible(); - await userEvent.setup().click(screen.getByRole("button", { name: "帮我批准" })); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("/api/v1/runs/run-1/execution-permission"); - expect(init.method).toBe("POST"); - expect(new Headers(init.headers).get("Authorization")).toBe("Bearer control"); - expect(new Headers(init.headers).get("Idempotency-Key")).toMatch(/^run-approval-preference-/u); - expect(JSON.parse(String(init.body))).toEqual({ - mode: "auto", confirm_full: false, reason: "Run approval preference selection", - }); - }); - - it("requires an explicit confirmation before selecting Full and cancellation never writes", async () => { - const selected = { - ...detail().execution_permission, - protocol_version: "run_execution_permission.v2", policy_version: "execution_permission_policy.v2", - mode: "full", approval_mode: "full", full_activation: "active", approval_policy: "per_operation", - command_scope: "per_operation", filesystem_scope: "per_operation", network_scope: "per_operation", - risk_tier: "high", required_gate: "operation_authority", operator_confirmed: true, revision: 2, - }; - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ - version: "api.v1", request_id: "req-permission", - data: { execution_permission: selected, replayed: false }, - }), { status: 202, headers: { "Content-Type": "application/json" } }))); - vi.stubGlobal("fetch", fetchMock); - const user = userEvent.setup(); - render( - - ); - await user.click(screen.getByRole("button", { name: "完全访问权限" })); - expect(fetchMock).not.toHaveBeenCalled(); - let dialog = within(screen.getByRole("dialog", { name: "启用完全访问权限?" })); - expect(dialog.getByText(/实际可用范围仍受操作系统、供应商和运行环境限制/u)).toBeVisible(); - await user.click(dialog.getByRole("button", { name: "取消" })); - expect(fetchMock).not.toHaveBeenCalled(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "完全访问权限" })); - dialog = within(screen.getByRole("dialog", { name: "启用完全访问权限?" })); - await user.click(dialog.getByRole("button", { name: "确认启用" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("/api/v1/runs/run-1/execution-permission"); - expect(init.method).toBe("POST"); - expect(new Headers(init.headers).get("Authorization")).toBe("Bearer control"); - expect(JSON.parse(String(init.body))).toEqual({ - mode: "full", confirm_full: true, reason: "Run approval preference selection", - }); - }); - - it("drops an elevated confirmation when the selected Run changes", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const client = new CyberAgentClient("read", "/api/v1", "control", { - runControlEnabled: true, - executionPermissionControlEnabled: true, - }); - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY } }, - }); - const runOne = detail(); - const runTwo = { - ...detail(), - run: { ...detail().run, id: "run-2" }, - } as RunDetailView; - queryClient.setQueryData(["run", "run-1"], runOne); - queryClient.setQueryData(["run", "run-2"], runTwo); - queryClient.setQueryData(["run", "run-1", "capability-readiness"], - capabilityReadinessFixture("run-1")); - queryClient.setQueryData(["run", "run-2", "capability-readiness"], - capabilityReadinessFixture("run-2")); - const user = userEvent.setup(); - const view = render( - - ); - - await user.click(screen.getByRole("button", { name: "完全访问权限" })); - const oldConfirm = screen.getByRole("button", { name: "确认启用" }); - expect(oldConfirm).toBeInTheDocument(); - - view.rerender( - - ); - expect(screen.queryByRole("button", { name: "确认启用" })).not.toBeInTheDocument(); - await act(async () => { fireEvent.click(oldConfirm); }); - expect(fetchMock).not.toHaveBeenCalled(); - expect(queryClient.getQueryData(["run", "run-1"])).toEqual(runOne); - expect(queryClient.getQueryData(["run", "run-2"])).toEqual(runTwo); - expect(screen.getByText((_, element) => - element?.tagName === "SPAN" && element.textContent?.startsWith("run-2") === true, - )).toBeInTheDocument(); - }); - - it.each([ - ["read token only", "", true], - ["control gate disabled", "control", false], - ] as const)("cannot write any preference with %s", async (_name, controlToken, enabled) => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const user = userEvent.setup(); - render( - - ); - const buttons = within(screen.getByRole("group", { name: "执行权限档位" })).getAllByRole("button"); - expect(buttons).toHaveLength(3); - for (const button of buttons) { - expect(button).toBeDisabled(); - await user.click(button); - } - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - describe("StandardCodeReadinessPanel", () => { afterEach(() => vi.unstubAllGlobals()); @@ -545,85 +365,3 @@ describe("StandardCodeReadinessPanel", () => { expect(configureStandardCode).toHaveBeenCalledTimes(3); }); }); - -describe("BrowserCDPPermissionPanel", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("shows the sensitive warning and keeps full CDP disabled outside Debug", async () => { - const user = userEvent.setup(); - render( - - ); - await user.click(screen.getByText("高级浏览器调试")); - expect(screen.getByText("高度敏感权限")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /完整 CDP/ })).toBeDisabled(); - expect(screen.getByText(/浏览器启动、CDP 传输与运行时授权仍保持关闭/)) - .toBeInTheDocument(); - }); - - it("requires confirmation and sends only the typed full-debug selection", async () => { - const debugDetail = { - ...detail(), - execution_permission: { - ...detail().execution_permission, - mode: "debug" as const, approval_mode: "full" as const, - }, - browser_cdp_permission: { - ...detail().browser_cdp_permission, - runtime: { - ...detail().browser_cdp_permission.runtime, - execution_debug_selected: true, - }, - }, - } as RunDetailView; - const selected = { - ...debugDetail.browser_cdp_permission, - mode: "full_debug" as const, - request_capture_allowed: true, request_mutation_allowed: true, - request_replay_allowed: true, cookie_access_allowed: true, - arbitrary_method_allowed: true, risk_tier: "high" as const, - required_gate: "full_cdp_debug" as const, operator_confirmed: true, revision: 2, - }; - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - version: "api.v1", request_id: "req-browser-cdp", - data: { browser_cdp_permission: selected, replayed: false }, - }), { status: 202, headers: { "Content-Type": "application/json" } })); - vi.stubGlobal("fetch", fetchMock); - const user = userEvent.setup(); - const readiness = patchCapabilityReadiness(capabilityReadinessFixture(), - "browser_cdp_permissions", "full_debug", { - selectable: true, runtime_available: true, - blocked_by: [], remediation: [], restart_required: false, - }); - render( - - ); - - await user.click(screen.getByText("高级浏览器调试")); - await user.click(screen.getByRole("button", { name: /完整 CDP/ })); - expect(fetchMock).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "确认" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain("/runs/run-1/browser-cdp-permission"); - expect(JSON.parse(String(init.body))).toEqual({ - mode: "full_debug", - reason: "settings browser CDP permission selection", - confirm_full_cdp_debug: true, - }); - }); -}); diff --git a/web/src/components/first-run-onboarding.test.tsx b/web/src/components/first-run-onboarding.test.tsx deleted file mode 100644 index a75b817d..00000000 --- a/web/src/components/first-run-onboarding.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import type { CyberAgentClient } from "../api/client"; -import type { StandardCodePresetControlView } from "../api/types"; -import { FirstRunOnboarding } from "./first-run-onboarding"; - -const desktopBridge = vi.hoisted(() => ({ - importWorkspace: vi.fn(), -})); - -vi.mock("../lib/desktop-bridge", () => ({ - desktopWorkspaceImportEnabled: () => true, - importDesktopWorkspace: desktopBridge.importWorkspace, -})); - -vi.mock("./model-availability-dialog", () => ({ - ModelAvailabilityWorkspace: () =>
Provider model controls
, -})); - -function standardCodeResult(overrides: Partial = {}): - StandardCodePresetControlView { - return { - protocol_version: "standard_code_preset.v1", status: "blocked", - workspace_id: "workspace-1", action: "configure", backend_intent: "auto", - selected_backend: "local", selection_reason: "auto_local_ready", - local_readiness: { backend: "local", available: true, blocked_by: [], remediation: [] }, - docker_readiness: { backend: "docker", available: false, - blocked_by: ["docker_unavailable"], remediation: ["install_or_start_docker"] }, - blocked_by: ["workspace_untrusted"], next_steps: ["confirm_workspace_trust"], - trust_required: true, trust_digest: "a".repeat(64), drydock_ready: false, - network: "disabled", credentials: "none", replayed: false, capability_grant: false, - ...overrides, - }; -} - -function testClient(createStandardCode = vi.fn()) { - return { - modelAvailability: vi.fn().mockResolvedValue({ - providers: [{ name: "openai", status: "available" }], - routes: [{ name: "code", available: true, harness_ready: true }], - }), - createStandardCode, - } as unknown as CyberAgentClient; -} - -function renderOnboarding(client: CyberAgentClient, onComplete = vi.fn()) { - const queryClient = new QueryClient({ defaultOptions: { - queries: { retry: false }, mutations: { retry: false }, - } }); - render( - - ); - return onComplete; -} - -async function reachWorkspace() { - fireEvent.click(screen.getByRole("button", { name: "Continue" })); - await screen.findByText("Provider model controls"); - await waitFor(() => expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled()); - fireEvent.click(screen.getByRole("button", { name: "Continue" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled()); - fireEvent.click(screen.getByRole("button", { name: "Continue" })); - await screen.findByRole("heading", { name: "Choose a Workspace and describe the goal" }); -} - -describe("FirstRunOnboarding", () => { - beforeEach(() => { - desktopBridge.importWorkspace.mockReset().mockResolvedValue({ - id: "workspace-1", name: "project", created_at: "2026-08-25T00:00:00Z", - }); - }); - - it("requires an explicit folder choice and exact trust confirmation before creating a Run", async () => { - const createStandardCode = vi.fn() - .mockResolvedValueOnce(standardCodeResult()) - .mockResolvedValueOnce(standardCodeResult({ - status: "configured", run_id: "run-standard-code", trust_required: false, - trust_digest: undefined, blocked_by: [], next_steps: [], drydock_ready: true, - })); - const onComplete = renderOnboarding(testClient(createStandardCode)); - - expect(desktopBridge.importWorkspace).not.toHaveBeenCalled(); - await reachWorkspace(); - expect(desktopBridge.importWorkspace).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByRole("button", { name: "Choose working folder" })); - await screen.findByText("project"); - fireEvent.change(screen.getByLabelText("Coding goal"), { - target: { value: "Implement the parser" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Check and continue" })); - - await screen.findByRole("heading", { name: "Confirm Workspace trust" }); - expect(screen.getByText("a".repeat(64))).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Confirm and create" })).toBeDisabled(); - fireEvent.click(screen.getByRole("checkbox")); - fireEvent.click(screen.getByRole("button", { name: "Confirm and create" })); - - await screen.findByRole("heading", { name: "Standard Code is ready" }); - expect(createStandardCode).toHaveBeenCalledTimes(2); - expect(createStandardCode.mock.calls[0]?.[0]).toMatchObject({ - workspace_id: "workspace-1", goal: "Implement the parser", - backend_intent: "auto", confirm_workspace_trust: false, - }); - expect(createStandardCode.mock.calls[1]?.[0]).toMatchObject({ - backend_intent: "auto", confirm_workspace_trust: true, - expected_trust_digest: "a".repeat(64), - }); - fireEvent.click(screen.getByRole("button", { name: "Start coding" })); - expect(onComplete).toHaveBeenCalledWith("run-standard-code"); - }); - - it("shows Go-owned blocker remediation and permits an explicit Docker fallback", async () => { - const blocked = standardCodeResult({ - selected_backend: undefined, selection_reason: undefined, - local_readiness: { backend: "local", available: false, - blocked_by: ["sandbox_unproven"], remediation: ["verify_sandbox"] }, - docker_readiness: { backend: "docker", available: true, blocked_by: [], remediation: [] }, - blocked_by: ["sandbox_unproven"], next_steps: ["select_docker"], - trust_required: false, trust_digest: undefined, - }); - const dockerTrust = standardCodeResult({ - backend_intent: "docker", selected_backend: "docker", - selection_reason: "explicit_docker", - }); - const createStandardCode = vi.fn() - .mockResolvedValueOnce(blocked).mockResolvedValueOnce(dockerTrust); - renderOnboarding(testClient(createStandardCode)); - await reachWorkspace(); - fireEvent.click(screen.getByRole("button", { name: "Choose working folder" })); - await screen.findByText("project"); - fireEvent.change(screen.getByLabelText("Coding goal"), { - target: { value: "Implement the parser" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Check and continue" })); - - await screen.findByText("Local sandbox is not verified"); - expect(screen.getByText("Repair and verify the local sandbox")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Use Docker" })); - await screen.findByRole("heading", { name: "Confirm Workspace trust" }); - expect(createStandardCode.mock.calls[1]?.[0]).toMatchObject({ - backend_intent: "docker", confirm_workspace_trust: false, - }); - }); - - it("uses a fresh intent for an explicit backend readiness retry", async () => { - const blocked = standardCodeResult({ - selected_backend: undefined, selection_reason: undefined, - local_readiness: { backend: "local", available: false, - blocked_by: ["backend_not_ready"], remediation: ["retry_backend_readiness"] }, - blocked_by: ["backend_not_ready"], next_steps: ["retry_readiness"], - trust_required: false, trust_digest: undefined, - }); - const createStandardCode = vi.fn().mockResolvedValue(blocked); - renderOnboarding(testClient(createStandardCode)); - await reachWorkspace(); - fireEvent.click(screen.getByRole("button", { name: "Choose working folder" })); - await screen.findByText("project"); - fireEvent.change(screen.getByLabelText("Coding goal"), { - target: { value: "Implement the parser" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Check and continue" })); - - await screen.findByText("Execution backend is not ready"); - fireEvent.click(screen.getByRole("button", { name: "Retry check" })); - await waitFor(() => expect(createStandardCode).toHaveBeenCalledTimes(2)); - expect(createStandardCode.mock.calls[0]?.[0]).toEqual(createStandardCode.mock.calls[1]?.[0]); - expect(createStandardCode.mock.calls[0]?.[1]).not.toBe(createStandardCode.mock.calls[1]?.[1]); - }); - - it("keeps Chinese IME composition in the bounded goal without submitting early", async () => { - const createStandardCode = vi.fn().mockResolvedValue(standardCodeResult()); - renderOnboarding(testClient(createStandardCode)); - await reachWorkspace(); - fireEvent.click(screen.getByRole("button", { name: "Choose working folder" })); - await screen.findByText("project"); - - const goal = screen.getByLabelText("Coding goal"); - fireEvent.compositionStart(goal); - fireEvent.change(goal, { target: { value: "修复" } }); - expect(createStandardCode).not.toHaveBeenCalled(); - fireEvent.compositionEnd(goal, { data: "修复登录页面" }); - fireEvent.change(goal, { target: { value: "修复登录页面" } }); - - expect(goal).toHaveValue("修复登录页面"); - expect(screen.getByRole("button", { name: "Check and continue" })).toBeEnabled(); - expect(createStandardCode).not.toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/first-run-onboarding.tsx b/web/src/components/first-run-onboarding.tsx deleted file mode 100644 index ebf9934d..00000000 --- a/web/src/components/first-run-onboarding.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ChevronLeft, ChevronRight, FolderPlus, LoaderCircle, - RefreshCw, ShieldCheck, X } from "lucide-react"; -import type { CyberAgentClient } from "../api/client"; -import type { StandardCodePresetControlRequestView, - StandardCodePresetControlView, WorkspaceView } from "../api/types"; -import { useModalFocusTrap } from "../hooks/use-modal-focus-trap"; -import { desktopWorkspaceImportEnabled, importDesktopWorkspace } from "../lib/desktop-bridge"; -import { useLocale } from "../lib/locale"; -import { ModelAvailabilityWorkspace } from "./model-availability-dialog"; - -type BackendIntent = StandardCodePresetControlRequestView["backend_intent"]; - -interface RetryIntent { - fingerprint: string; - key: string; -} - -const blockerLabels: Record = { - run_not_quiescent: ["Run 尚未暂停", "Run is not paused"], - execution_lease_active: ["执行租约仍在释放", "Execution lease is still releasing"], - startup_gate_closed: ["启动时能力门未开启", "Startup capability gate is closed"], - capability_not_implemented: ["当前版本尚未实现", "Not implemented by this version"], - surface_mismatch: ["当前 Run 工作面不兼容", "Current Run surface is incompatible"], - profile_mismatch: ["执行档位不兼容", "Execution profile is incompatible"], - permission_mismatch: ["权限档位不兼容", "Permission profile is incompatible"], - workspace_untrusted: ["工作区尚未受信任", "Workspace trust is required"], - sandbox_unproven: ["本地沙箱尚未通过验证", "Local sandbox is not verified"], - docker_unavailable: ["Docker 不可用", "Docker is unavailable"], - backend_not_ready: ["执行后端尚未就绪", "Execution backend is not ready"], -}; - -const remediationLabels: Record = { - pause_run: ["暂停 Run 后重试", "Pause the Run and retry"], - create_new_run: ["创建新的 Run", "Create a new Run"], - wait_for_execution_lease: ["等待执行租约释放", "Wait for the execution lease to release"], - restart_with_startup_gate: ["使用所需启动能力重新启动", "Restart with the required startup gate"], - upgrade_application: ["升级到支持该能力的版本", "Upgrade to a version that supports this capability"], - select_required_surface: ["选择所需工作面", "Select the required surface"], - select_required_profile: ["选择所需执行档位", "Select the required execution profile"], - select_required_permission: ["选择所需权限档位", "Select the required permission profile"], - trust_workspace: ["核对并确认工作区信任", "Review and confirm Workspace trust"], - verify_sandbox: ["修复并重新验证本地沙箱", "Repair and verify the local sandbox"], - install_or_start_docker: ["安装或启动 Docker", "Install or start Docker"], - retry_backend_readiness: ["重新检查后端就绪状态", "Retry backend readiness"], -}; - -export function FirstRunOnboarding({ client, open, onDismiss, onComplete }: { - client: CyberAgentClient; - open: boolean; - onDismiss: () => void; - onComplete: (runID: string) => void; -}) { - const { locale, setLocale, t } = useLocale(); - const [step, setStep] = useState(0); - const [workspaceID, setWorkspaceID] = useState(""); - const [workspaceName, setWorkspaceName] = useState(""); - const [goal, setGoal] = useState(""); - const [backendIntent, setBackendIntent] = useState("auto"); - const [result, setResult] = useState(null); - const [trustReviewed, setTrustReviewed] = useState(false); - const retryIntent = useRef(null); - const queryClient = useQueryClient(); - const nativeWorkspaceImport = desktopWorkspaceImportEnabled(); - const availability = useQuery({ - queryKey: ["models", "availability"], - queryFn: ({ signal }) => client.modelAvailability(signal), - enabled: open && step >= 1 && step <= 2, - }); - const workspaces = useQuery({ - queryKey: ["workspaces"], - queryFn: ({ signal }) => client.getPage( - "/workspaces", { limit: 100 }, "", signal), - enabled: open && step === 3 && !nativeWorkspaceImport, - }); - const workspaceImport = useMutation({ - mutationFn: importDesktopWorkspace, - onSuccess: (workspace) => { - if (!workspace) return; - setWorkspaceID(workspace.id); - setWorkspaceName(workspace.name); - setResult(null); - setTrustReviewed(false); - retryIntent.current = null; - void queryClient.invalidateQueries({ queryKey: ["workspaces"] }); - }, - }); - const preset = useMutation({ - mutationFn: ({ request, key }: { - request: StandardCodePresetControlRequestView; - key: string; - }) => client.createStandardCode(request, key), - onSuccess: (next) => { - setResult(next); - setTrustReviewed(false); - if (next.status === "configured" && next.run_id) { - void Promise.all([ - queryClient.invalidateQueries({ queryKey: ["onboarding", "runs"] }), - queryClient.invalidateQueries({ queryKey: ["runs"] }), - queryClient.invalidateQueries({ queryKey: ["sessions"] }), - queryClient.invalidateQueries({ queryKey: ["threads"] }), - ]); - setStep(6); - } else if (next.trust_required && next.trust_digest) { - setStep(5); - } else { - setStep(4); - } - }, - }); - const busy = preset.isPending || workspaceImport.isPending; - const dialogRef = useModalFocusTrap(open, onDismiss, busy); - - useEffect(() => { - if (!nativeWorkspaceImport && !workspaceID && workspaces.data?.items[0]) { - setWorkspaceID(workspaces.data.items[0].id); - setWorkspaceName(workspaces.data.items[0].name); - } - }, [nativeWorkspaceImport, workspaceID, workspaces.data]); - - useEffect(() => { - if (!open) { - setStep(0); - setResult(null); - setTrustReviewed(false); - retryIntent.current = null; - } - }, [open]); - - if (!open) return null; - - const providerReady = availability.data?.providers.some( - (provider) => provider.status === "available") === true; - const harnessReady = availability.data?.routes.some( - (route) => route.available && route.harness_ready) === true; - const normalizedGoal = goal.trim(); - const goalBytes = new TextEncoder().encode(normalizedGoal).byteLength; - const workspaceReady = workspaceID !== "" && goalBytes > 0 && goalBytes <= 4096; - const stepLabels = [t("语言", "Language"), t("Provider 与凭证", "Provider & credential"), - t("Harness 验证", "Harness qualification"), t("工作区", "Workspace"), - t("后端就绪", "Backend readiness"), t("信任确认", "Trust"), - t("完成", "Complete")]; - - const operationKey = (request: StandardCodePresetControlRequestView) => { - const fingerprint = JSON.stringify(request); - if (retryIntent.current?.fingerprint !== fingerprint) { - retryIntent.current = { fingerprint, - key: `web-standard-code-onboarding-${globalThis.crypto.randomUUID()}` }; - } - return retryIntent.current.key; - }; - const configure = (intent: BackendIntent, trustDigest = "") => { - const request: StandardCodePresetControlRequestView = { - version: "standard_code_preset.v1", - workspace_id: workspaceID, - goal: normalizedGoal, - backend_intent: intent, - confirm_workspace_trust: trustDigest !== "", - ...(trustDigest ? { expected_trust_digest: trustDigest } : {}), - }; - setBackendIntent(intent); - preset.mutate({ request, key: operationKey(request) }); - }; - const next = () => { - if (step < 3) { - setStep((current) => current + 1); - return; - } - if (step === 3 && workspaceReady) { - setResult(null); - setStep(4); - configure("auto"); - } - }; - const back = () => { - if (busy || step === 0 || step === 6) return; - preset.reset(); - retryIntent.current = null; - setStep((current) => current - 1); - }; - const primaryDisabled = busy || - (step === 1 && !providerReady) || (step === 2 && !harnessReady) || - (step === 3 && !workspaceReady); - - return
-
-
-
-

{t("首次安全设置", "Safe first-time setup")}

- Standard Code · {step + 1}/{stepLabels.length}
- -
-
    - {stepLabels.map((label, index) =>
  1. {index < step ? - {label}
  2. )} -
-
- {step === 0 &&
-

{t("选择界面语言", "Choose your language")}

-

{t("语言选择只保存在本机界面设置中,不包含凭证或控制令牌。", - "Language is stored only as a local UI preference; credentials and control tokens are never stored here.")}

-
- - -
-
} - {(step === 1 || step === 2) &&
-

{step === 1 - ? t("连接 Provider", "Connect a Provider") - : t("验证模型 Harness", "Qualify the model Harness")}

-

{step === 1 - ? t("凭证仅写入操作系统凭证管理器。至少一个 Provider 可用后才能继续。", - "Credentials are written only to the OS credential manager. At least one Provider must be available.") - : t("运行两次调用的合成验证;至少一条代码路由必须显示 Harness ready。", - "Run the two-call synthetic qualification. At least one code route must report Harness ready.")}

- - {availability.isError &&

- {errorMessage(availability.error)}

} - {!availability.isLoading && step === 1 && !providerReady && -

{t("尚无可用 Provider。保存凭证并运行诊断。", - "No Provider is available yet. Store a credential and run diagnostics.")}

} - {!availability.isLoading && step === 2 && !harnessReady && -

{t("尚无已通过验证的 Harness 路由。", - "No Harness-qualified route is available yet.")}

} -
} - {step === 3 &&
-

{t("选择工作区并描述目标", "Choose a Workspace and describe the goal")}

-

{t("文件夹路径由原生窗口处理,不会暴露给网页渲染器。选择文件夹不会自动信任它。", - "The native window handles the folder path; it is not exposed to the web renderer. Choosing it does not trust it.")}

- {nativeWorkspaceImport ?
- {t("工作区", "Workspace")} - -
: } -