Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ open build/desktop/Prayu.app
| v104 | 签名 Skill 包、团队 Catalog 与固定 URL/Git 导入(publisher 信任/撤销、版本 pin、审计) | signed skill packages, team catalog, and pinned URL/Git imports (publisher trust/revoke, version pins, audit) |
| v105 | 工作区一次性命令提案(不可变参数、审批指纹、操作者执行) | workspace one-shot command proposals (immutable parameters, approval fingerprints, operator execution) |
| v106 | typed 本地 Git 写操作台账(绑定指纹、幂等、回读收据) | typed local Git mutation operations (binding fingerprints, idempotency, readback receipts) |
| v107 | 网络作用域远端 Git 与 PR 操作台账(host/port/protocol/TTL/Run 绑定、脱敏收据) | network-scoped remote Git and PR operations (host/port/protocol/TTL/Run binding, redacted receipts) |

</details>

Expand Down
44 changes: 44 additions & 0 deletions docs/adr/0109-remote-git-pr-credential-network.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR 0109: 远端 Git、Pull Request 与凭证/网络授权

Date: 2026-08-16

## Status

Accepted for the network-scoped remote workflow described below. Desktop
审阅/取消流程与 HTTP/OpenAPI 作为后续 PR 落地。

## 背景 / Context

Prayu 没有 fetch/pull/push 与 PR 产品工作流;网络与凭证必须与本地 mutation
分离,经 Go 的 Scope/Credential/Approval/审计执行,网络默认关闭。

## 决策 / Decision

### 1. 封闭 typed 远端操作 + 网络 Scope

repository_remote.v1 五个操作:fetch、pull_ff(仅 fast-forward)、push_branch
(仅新分支,ls-remote 探测已存在即拒绝)、create_pr、update_pr。force push、
远端分支删除、protected branch 改写不可表达。仅 HTTPS、拒绝 loopback、URL 内
凭据/query/fragment;host/port/protocol/TTL/Run 全部进入 request_fingerprint 与
台账(schema v107)。

### 2. 凭证只按引用存在

spec 只携带 credential NAME;secret 经 credential.Store 按名解析,git 路径经
临时 GIT_ASKPASS + GIT_PASSWORD 子进程环境变量(用后即删),GitHub API 路径仅
Authorization: Bearer 头。argv/日志/SQLite/Activity/OpenAPI/模型上下文均无明文
(测试断言)。stderr 自动脱敏。

### 3. 网络 kill-switch 与可解释错误

http/https.proxy 置空、core.sshCommand 置空(禁 SSH/ProxyCommand)、
protocol.ext.allow=never(禁协议 wrapper);PR API 仅 github.com,401/403 限流/
422/404/网络失败均映射为可解释错误码。

## 后果 / Consequences

- 操作者可用 cyberagent git-remote 在 Run 上下文中执行网络作用域远端操作,
全程脱敏审计。
- 非目标:组织级 OAuth/SSO/RBAC、自动合并 PR、模型读取原始 credential、任意
协议包装器。

14 changes: 14 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,20 @@ The file uses `project_config.v1` (JSON schema in `configs/project-config.schema
At Run creation the normalized effective view and its SHA-256 fingerprint are pinned into the Run config snapshot; editing `.prayu/config.yaml` afterwards never changes a running Run.


## Remote Git and Pull Requests

`cyberagent git-remote` performs network-scoped remote Git and PR operations as typed, review-bound requests (schema v107 ledger):

```powershell
cyberagent git-remote fetch --run <run-id> --remote <https-url> --branch main --ttl 30s --operation-key <key> --confirm
cyberagent git-remote pull --run <run-id> --remote <https-url> --branch main --operation-key <key> --confirm
cyberagent git-remote push-branch --run <run-id> --remote <https-url> --branch feat --operation-key <key> --confirm
cyberagent git-remote create-pr --run <run-id> --remote <https-url> --branch feat --base main --title "feat" --credential github-pat --operation-key <key> --confirm
```

Operations are fetch / pull_ff (fast-forward only) / push_branch (new branches only — an existing remote branch is default-denied) / create_pr / update_pr; force push, remote branch deletion, and protected-branch mutation cannot be expressed. Only HTTPS remotes are accepted (loopback and credential-in-URL rejected); the network scope binds host/port/protocol/TTL/Run into the request fingerprint. Credentials are referenced by name only: the secret resolves from the system credential store, reaches git through a temporary askpass helper plus a child-process environment variable, and reaches the GitHub API only as an Authorization header — never in argv, logs, SQLite, Activity, OpenAPI, or model context (asserted by tests). Proxies, SSH ProxyCommand, and protocol wrappers are switched off; the PR API accepts only github.com with explainable 401/403 rate-limit/422/404 errors. Receipts are redacted and land in `git_remote_operations` plus a `git.remote_completed` Run event.


## Typed Git Mutations

`cyberagent git-op` performs local Git write operations as typed, review-bound mutations (never free-form git argv):
Expand Down
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ func (a *App) dispatch(ctx context.Context, args []string) error {
return a.onceCommandCommand(ctx, args[1:])
case "git-op":
return a.gitOpCommand(ctx, args[1:])
case "git-remote":
return a.gitRemoteCommand(ctx, args[1:])
case "headless":
return a.headlessCommand(ctx, args[1:])
case "run":
Expand Down
83 changes: 83 additions & 0 deletions internal/app/git_remote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package app

import (
"context"
"errors"
"fmt"
"strings"
"time"

"cyberagent-workbench/internal/application"
"cyberagent-workbench/internal/credential"
"cyberagent-workbench/internal/repository"
)

// gitRemoteCommand is the operator path for network-scoped remote Git and
// PR operations. The review (remote/branch/TTL/PR metadata) prints first;
// --confirm executes and prints the redacted receipt.
func (a *App) gitRemoteCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("usage: cyberagent git-remote fetch|pull|push-branch|create-pr|update-pr --run <run-id> --remote <https-url> --branch <name> [--base <name>] [--title <t>] [--body <b>] [--pr-number <n>] [--credential <name>] [--ttl 30s] --operation-key <key> [--confirm]")
}
flags := newFlagSet("git-remote", a.errOut)
runID := flags.String("run", "", "exact Run identity")
operationKey := flags.String("operation-key", "", "stable idempotency key")
remoteURL := flags.String("remote", "", "HTTPS remote URL")
branch := flags.String("branch", "", "target branch")
base := flags.String("base", "", "PR base branch")
title := flags.String("title", "", "PR title")
body := flags.String("body", "", "PR body")
prNumber := flags.Int64("pr-number", 0, "existing PR number for update")
credentialName := flags.String("credential", "", "credential reference name (never the secret)")
ttl := flags.Duration("ttl", 60*time.Second, "network TTL")
confirm := flags.Bool("confirm", false, "confirm execution after reviewing")
if err := flags.Parse(reorderFlags(args[1:], map[string]bool{
"run": true, "operation-key": true, "remote": true, "branch": true,
"base": true, "title": true, "body": true, "pr-number": true,
"credential": true, "ttl": true, "confirm": false,
})); err != nil {
return err
}
if strings.TrimSpace(*runID) == "" || strings.TrimSpace(*remoteURL) == "" ||
strings.TrimSpace(*operationKey) == "" || strings.TrimSpace(*branch) == "" {
return errors.New("usage: cyberagent git-remote <op> --run <run-id> --remote <https-url> --branch <name> --operation-key <key> [--confirm]")
}
spec := repository.RemoteSpec{
ProtocolVersion: repository.RemoteProtocolVersion,
Operation: repository.RemoteOperation(args[0]), RemoteURL: *remoteURL,
Branch: *branch, BaseBranch: *base, PRTitle: *title, PRBody: *body,
PRNumber: *prNumber, CredentialName: *credentialName,
NetworkTTLMillis: ttl.Milliseconds(),
}
if err := a.ensureStore(); err != nil {
return err
}
executor, err := repository.NewRemoteExecutor(credential.NewSystemStore())
if err != nil {
return err
}
service := application.NewGitRemoteService(a.store, executor, repository.NewPRClient(),
credential.NewSystemStore())
fmt.Fprintf(a.out, "operation: %s\nremote: %s\nbranch: %s\nbase_branch: %s\npr_title: %s\ncredential_reference: %s\nnetwork_ttl: %s\n",
spec.Operation, spec.RemoteURL, spec.Branch, spec.BaseBranch, spec.PRTitle,
func() string {
if spec.CredentialName == "" {
return "(none)"
}
return spec.CredentialName
}(), ttl)
if !*confirm {
fmt.Fprintln(a.out, "review_only: true (re-run with --confirm to execute)")
return nil
}
result, err := service.Execute(ctx, application.GitRemoteRequest{
RunID: *runID, OperationKey: *operationKey, Spec: spec, RequestedBy: "cli_operator",
})
if err != nil {
return err
}
fmt.Fprintf(a.out, "operation_id: %s\nreplayed: %t\nremote_host: %s\npost_head: %s\ncommit_id: %s\npull_request_url: %s\npull_request_number: %d\n",
result.Record.ID, result.Replayed, result.Record.RemoteHost, result.Record.PostHead,
result.Record.CommitID, result.Record.PullRequestURL, result.Record.PullRequestNumber)
return nil
}
161 changes: 161 additions & 0 deletions internal/application/git_remote_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package application

import (
"context"
"encoding/json"
"net/url"
"strings"
"time"

"cyberagent-workbench/internal/apperror"
"cyberagent-workbench/internal/credential"
"cyberagent-workbench/internal/domain"
"cyberagent-workbench/internal/gitmutation"
"cyberagent-workbench/internal/idgen"
"cyberagent-workbench/internal/repository"
"cyberagent-workbench/internal/runmutation"
"cyberagent-workbench/internal/session"
)

// GitRemoteStore is the bounded store surface for the remote workflow.
type GitRemoteStore interface {
GetRun(context.Context, string) (domain.Run, error)
GetMission(context.Context, string) (domain.Mission, error)
GetWorkspaceByID(context.Context, string) (session.WorkspaceRecord, error)
CreateRemoteOperation(context.Context, gitmutation.RemoteRecord) (gitmutation.RemoteRecord, bool, error)
CompleteRemoteOperation(context.Context, string, gitmutation.RemoteRecord, time.Time) (gitmutation.RemoteRecord, bool, error)
}

// GitRemoteService owns the review-then-execute flow for network-scoped
// remote Git and PR operations.
type GitRemoteService struct {
store GitRemoteStore
executor *repository.RemoteExecutor
prClient *repository.PRClient
credentials credential.Store
}

func NewGitRemoteService(store GitRemoteStore, executor *repository.RemoteExecutor,
prClient *repository.PRClient, credentials credential.Store,
) *GitRemoteService {
return &GitRemoteService{store: store, executor: executor, prClient: prClient, credentials: credentials}
}

type GitRemoteRequest struct {
RunID string
OperationKey string
Spec repository.RemoteSpec
RequestedBy string
}

type GitRemoteExecuteResult struct {
Record gitmutation.RemoteRecord
Receipt repository.RemoteReceipt
PR repository.PRReceipt
Replayed bool
}

// Execute validates the network scope, resolves the workspace, runs the
// typed operation, and records the redacted receipt plus the run event.
func (s *GitRemoteService) Execute(ctx context.Context, request GitRemoteRequest) (GitRemoteExecuteResult, error) {
if s == nil || s.store == nil || s.executor == nil || !s.executor.Available() {
return GitRemoteExecuteResult{}, apperror.New(apperror.CodeFailedPrecondition,
"remote git service requires a store and an available executor")
}
if err := s.executor.ValidateSpec(request.Spec); err != nil {
return GitRemoteExecuteResult{}, apperror.Wrap(apperror.CodeInvalidArgument,
"remote spec is invalid", err)
}
run, err := s.store.GetRun(ctx, request.RunID)
if err != nil {
return GitRemoteExecuteResult{}, apperror.Normalize(err)
}
mission, err := s.store.GetMission(ctx, run.MissionID)
if err != nil {
return GitRemoteExecuteResult{}, apperror.Normalize(err)
}
workspace, err := s.store.GetWorkspaceByID(ctx, mission.WorkspaceID)
if err != nil {
return GitRemoteExecuteResult{}, apperror.Normalize(err)
}
parsed, err := url.Parse(request.Spec.RemoteURL)
if err != nil {
return GitRemoteExecuteResult{}, apperror.New(apperror.CodeInvalidArgument, "remote URL is invalid")
}
port := parsed.Port()
if port == "" {
port = "443"
}
specJSON, err := json.Marshal(request.Spec)
if err != nil {
return GitRemoteExecuteResult{}, err
}
keyDigest := runmutation.OperationKeyDigest("git_remote_operation.v1", run.ID, request.OperationKey)
requestFingerprint := runmutation.Fingerprint("git_remote_request.v1", run.ID,
workspace.ID, parsed.Hostname(), port, "https", request.Spec.Branch, string(specJSON))
record := gitmutation.RemoteRecord{
ID: idgen.New("git-remote"), ProtocolVersion: repository.RemoteProtocolVersion,
OperationKeyDigest: keyDigest, RequestFingerprint: requestFingerprint,
RunID: run.ID, WorkspaceID: workspace.ID, Operation: gitmutation.RemoteOperation(request.Spec.Operation),
SpecJSON: string(specJSON), RemoteHost: parsed.Hostname(), RemotePort: port,
Protocol: "https", Branch: request.Spec.Branch, CreatedAt: time.Now().UTC(),
}
created, replayed, err := s.store.CreateRemoteOperation(ctx, record)
if err != nil {
return GitRemoteExecuteResult{}, apperror.Normalize(err)
}
if replayed {
return GitRemoteExecuteResult{Record: created, Replayed: true}, nil
}
binding := repository.RemoteBinding{
ProtocolVersion: repository.RemoteProtocolVersion, RunID: run.ID, WorkspaceID: workspace.ID,
RemoteHost: parsed.Hostname(), RemotePort: port, Protocol: "https",
Branch: request.Spec.Branch, NetworkTTLMillis: request.Spec.NetworkTTLMillis,
CredentialName: request.Spec.CredentialName, CapturedAt: time.Now().UTC(),
}
var receipt repository.RemoteReceipt
var pr repository.PRReceipt
if request.Spec.Operation == repository.RemoteCreatePR || request.Spec.Operation == repository.RemoteUpdatePR {
if s.prClient == nil {
return GitRemoteExecuteResult{}, apperror.New(apperror.CodeFailedPrecondition, "PR client is unavailable")
}
token := ""
if request.Spec.CredentialName != "" {
if s.credentials == nil || !s.credentials.Available() {
return GitRemoteExecuteResult{}, apperror.New(apperror.CodeUnavailable, "credential store is unavailable")
}
resolved, found, err := s.credentials.Get(ctx, request.Spec.CredentialName)
if err != nil || !found {
return GitRemoteExecuteResult{}, apperror.New(apperror.CodeUnavailable, "referenced credential is unavailable")
}
token = resolved
}
if request.Spec.Operation == repository.RemoteCreatePR {
pr, err = s.prClient.CreatePR(ctx, request.Spec.RemoteURL, request.Spec.Branch,
request.Spec.BaseBranch, request.Spec.PRTitle, request.Spec.PRBody, token)
} else {
pr, err = s.prClient.UpdatePR(ctx, request.Spec.RemoteURL, request.Spec.PRNumber, request.Spec.PRTitle, request.Spec.PRBody, token)
}
if err != nil {
return GitRemoteExecuteResult{}, err
}
receipt = repository.RemoteReceipt{ProtocolVersion: repository.RemoteProtocolVersion,
Operation: request.Spec.Operation, Branch: request.Spec.Branch, RemoteHost: parsed.Hostname(),
PullRequestURL: pr.URL}
} else {
receipt, err = s.executor.ExecuteGit(ctx, workspace.RootPath, request.Spec, binding, request.OperationKey)
if err != nil {
return GitRemoteExecuteResult{}, err
}
}
completed, _, err := s.store.CompleteRemoteOperation(ctx, created.ID, gitmutation.RemoteRecord{
PostHead: receipt.PostHead, CommitID: receipt.CommitID, PullRequestURL: receipt.PullRequestURL,
PullRequestNumber: pr.Number, StderrPrefix: receipt.StderrPrefix,
}, time.Now().UTC())
if err != nil {
return GitRemoteExecuteResult{}, apperror.Normalize(err)
}
return GitRemoteExecuteResult{Record: completed, Receipt: receipt, PR: pr}, nil
}

var _ = strings.TrimSpace
Loading