diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md index ba5cd5b..6668ecd 100644 --- a/.version/changelog/Unreleased.md +++ b/.version/changelog/Unreleased.md @@ -4,7 +4,9 @@ ## 新增 -暂无 +- 新增 `ggc acp run`:以 agent 模式执行单轮 prompt(`--agent` / `--permit ask|auto|deny` / `--timeout` / `--client-fs`) +- 新增 ACP client 侧沙箱 FS(`fs/read_text_file` / `fs/write_text_file`),路径限定在 Cwd 内,越界拒绝 +- 新增 ACP 权限策略 ask|auto|deny(交互确认 / 自动放行 / 自动拒绝)及 session/update 进度行渲染 ## 修复 @@ -12,7 +14,8 @@ ## 变更 -暂无 +- `ggc changelog draft` 默认改走 ACP agent 模式(`--provider acp`;可用 `--provider copilot` 回退旧会话),并在结束后校验 Unreleased.md 是否被改动 +- ACP agent 解析改为显式名(flag / `FASTGIT_ACP_AGENT` / `acp.default`)未命中时立即失败,不再静默回退到其它候选 ## 文档 @@ -20,12 +23,17 @@ ## 影响范围 -暂无 +- `ggc changelog draft` 默认依赖可用 ACP agent,并默认开启 client FS(沙箱限定仓库目录) +- `ggc acp run` 与 draft 共用权限策略与沙箱 FS 能力 +- 显式指定不存在的 agent 名会直接报错,不再自动探测其它 agent ## 验证建议 -暂无 +- `ggc acp run "ping" --agent --permit ask` 验证连通与权限交互 +- `ggc changelog draft` 确认 `.version/changelog/Unreleased.md` 被更新;再用 `--provider copilot` 验证旧路径 +- `go test ./pkg/acpclient ./pkg/acpui ./cmds/chglogcmd` 覆盖 FS 沙箱、权限策略与 draft agent 模式 ## 回滚建议 -暂无 +- draft 使用 `--provider copilot` 恢复旧 Copilot 会话路径 +- 代码层回退本次提交(`feat(chglog): run changelog draft through ACP agents by default`) diff --git a/cmds/acpcmd/cmd.go b/cmds/acpcmd/cmd.go index 448f2b8..336e133 100644 --- a/cmds/acpcmd/cmd.go +++ b/cmds/acpcmd/cmd.go @@ -3,24 +3,43 @@ package acpcmd import ( "context" "fmt" + "os" "strconv" "strings" "time" + acp "github.com/coder/acp-go-sdk" + "github.com/pubgo/fastgit/configs" "github.com/pubgo/fastgit/pkg/acpclient" "github.com/pubgo/fastgit/pkg/acpconfig" + "github.com/pubgo/fastgit/pkg/acpui" "github.com/pubgo/redant" "github.com/samber/lo" ) type flagOptions struct { - command string - args string - env string - prompt string - timeout string - clear bool + command string + args string + env string + prompt string + timeout string + agent string + permit string + clientFS bool + clear bool +} + +// permissionHandler 按策略名构造权限回调: ask|auto|deny。 +func permissionHandler(mode string) (acpclient.PermissionHandler, error) { + return acpui.Permission(mode) +} + +func runTimeout(seconds string) time.Duration { + if secs, err := strconv.Atoi(strings.TrimSpace(seconds)); err == nil && secs > 0 { + return time.Duration(secs) * time.Second + } + return acpclient.DefaultTimeout } func parseAgentFlags(flags *flagOptions) (acpconfig.AgentConfig, error) { @@ -158,6 +177,58 @@ func New() *redant.Command { return nil }, }, + { + Use: "run", + Short: "run one agent-mode prompt through an acp agent: run [--agent name] [--permit ask|auto|deny] [--timeout s] [--client-fs]", + Options: []redant.Option{ + {Flag: "agent", Description: "acp agent 名(默认按 acp.default/自动探测)", Value: redant.StringOf(&flags.agent)}, + {Flag: "permit", Description: "权限策略 ask|auto|deny", Value: redant.StringOf(&flags.permit), Default: "ask"}, + {Flag: "timeout", Description: "整轮超时秒数", Value: redant.StringOf(&flags.timeout), Default: "300"}, + {Flag: "client-fs", Description: "声明并处理 client 侧 fs 读写(沙箱限定当前目录)", Value: redant.BoolOf(&flags.clientFS)}, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + parts := make([]string, 0, len(i.Command.Args)) + for _, arg := range i.Command.Args { + parts = append(parts, arg.Value.String()) + } + prompt := strings.TrimSpace(strings.Join(parts, " ")) + if prompt == "" { + return fmt.Errorf("usage: ggc acp run ") + } + + cfg, err := acpconfig.Load() + if err != nil { + return err + } + agent, err := cfg.Resolve(strings.TrimSpace(flags.agent)) + if err != nil { + return err + } + onPermission, err := permissionHandler(flags.permit) + if err != nil { + return err + } + + fmt.Printf("→ acp run agent=%q permit=%s timeout=%s\n", agent.Name, strings.ToLower(strings.TrimSpace(flags.permit)), runTimeout(flags.timeout)) + result, err := acpclient.Run(ctx, agent, acpclient.Options{ + Prompt: prompt, + Timeout: runTimeout(flags.timeout), + OnPermission: onPermission, + ClientFS: flags.clientFS, + OnUpdate: func(update acp.SessionUpdate) error { + if line := acpclient.UpdateLine(update); line != "" { + fmt.Fprintln(os.Stderr, line) + } + return nil + }, + }) + if err != nil { + return err + } + fmt.Println(result.Text) + return nil + }, + }, { Use: "test", Short: "spawn the agent, initialize an ACP session and run a ping prompt", @@ -180,10 +251,7 @@ func New() *redant.Command { return err } - timeout := acpclient.DefaultTimeout - if secs, convErr := strconv.Atoi(strings.TrimSpace(flags.timeout)); convErr == nil && secs > 0 { - timeout = time.Duration(secs) * time.Second - } + timeout := runTimeout(flags.timeout) fmt.Printf("→ testing acp agent %q: %s (timeout %s)\n", agent.Name, agent.Command+" "+strings.Join(agent.Args, " "), timeout) start := time.Now() diff --git a/cmds/chglogcmd/cmd.go b/cmds/chglogcmd/cmd.go index a76e8dd..75f3af5 100644 --- a/cmds/chglogcmd/cmd.go +++ b/cmds/chglogcmd/cmd.go @@ -87,11 +87,16 @@ func newDraftCommand() *redant.Command { streaming bool autoUserAnswer string permissionMode string + provider string + acpAgent string + acpPermit string + acpTimeout string + acpClientFS bool ) return &redant.Command{ Use: "draft", - Short: "使用 Copilot 根据当前改动更新 Unreleased.md", + Short: "根据当前改动更新 Unreleased.md(默认 acp agent 模式,可 --provider copilot)", Options: redant.OptionSet{ {Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)}, {Flag: "base", Description: "diff 基线(默认自动探测)", Value: redant.StringOf(&baseRef)}, @@ -106,6 +111,11 @@ func newDraftCommand() *redant.Command { {Flag: "stream", Description: "启用流式输出", Value: redant.BoolOf(&streaming), Default: "false"}, {Flag: "auto-user-answer", Description: "ask_user 触发时自动回答内容", Value: redant.StringOf(&autoUserAnswer), Default: "继续执行"}, {Flag: "permission-mode", Description: "Copilot 权限策略 ask|allow|deny(默认 deny;可被 config/env 覆盖)", Value: redant.StringOf(&permissionMode)}, + {Flag: "provider", Description: "draft 执行方式 acp|copilot(默认 acp agent 模式)", Value: redant.StringOf(&provider), Default: "acp"}, + {Flag: "agent", Description: "acp agent 名(默认按 acp.default/自动探测)", Value: redant.StringOf(&acpAgent)}, + {Flag: "acp-permit", Description: "acp 权限策略 ask|auto|deny", Value: redant.StringOf(&acpPermit), Default: "ask"}, + {Flag: "acp-timeout", Description: "acp 整轮超时秒数", Value: redant.StringOf(&acpTimeout), Default: "300"}, + {Flag: "acp-client-fs", Description: "声明 client 侧 fs 读写能力(沙箱限定仓库目录,claude 类 agent 依赖此通道落盘)", Value: redant.BoolOf(&acpClientFS), Default: "true"}, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { repoRoot, err := resolveExistingGitRepo(strings.TrimSpace(repoPath)) @@ -153,33 +163,45 @@ func newDraftCommand() *redant.Command { return nil } - mode, err := copilotperm.ResolveMode(strings.TrimSpace(permissionMode), copilotperm.ModeDeny) - if err != nil { - return err - } + switch strings.ToLower(strings.TrimSpace(provider)) { + case "copilot": + mode, err := copilotperm.ResolveMode(strings.TrimSpace(permissionMode), copilotperm.ModeDeny) + if err != nil { + return err + } - return runDraftWithCopilot(ctx, inv, prompt, draftCopilotOptions{ - CLIPath: strings.TrimSpace(cliPath), - LogLevel: strings.TrimSpace(logLevel), - WorkingDir: repoRoot, - GitHubToken: strings.TrimSpace(githubToken), - UseLoggedInUser: useLoggedInUser, - Model: strings.TrimSpace(model), - ReasoningEffort: strings.TrimSpace(reasoningEffort), - Streaming: streaming, - AutoUserAnswer: strings.TrimSpace(autoUserAnswer), - PermissionMode: string(mode), - }) + return runDraftWithCopilot(ctx, inv, prompt, draftCopilotOptions{ + CLIPath: strings.TrimSpace(cliPath), + LogLevel: strings.TrimSpace(logLevel), + WorkingDir: repoRoot, + GitHubToken: strings.TrimSpace(githubToken), + UseLoggedInUser: useLoggedInUser, + Model: strings.TrimSpace(model), + ReasoningEffort: strings.TrimSpace(reasoningEffort), + Streaming: streaming, + AutoUserAnswer: strings.TrimSpace(autoUserAnswer), + PermissionMode: string(mode), + }) + case "", "acp": + return runDraftWithAgent(ctx, inv, prompt, repoRoot, agentParams{ + Agent: strings.TrimSpace(acpAgent), + Permit: strings.TrimSpace(acpPermit), + Timeout: strings.TrimSpace(acpTimeout), + ClientFS: acpClientFS, + }) + default: + return fmt.Errorf("invalid --provider %q, want acp|copilot", provider) + } }, } } func newReleaseCommand() *redant.Command { var ( - repoPath string - version string - nextVersion string - bump string + repoPath string + version string + nextVersion string + bump string dryRun bool skipValidate bool skipBumpCheck bool diff --git a/cmds/chglogcmd/draft.go b/cmds/chglogcmd/draft.go index a0ba06d..d3432db 100644 --- a/cmds/chglogcmd/draft.go +++ b/cmds/chglogcmd/draft.go @@ -5,8 +5,15 @@ import ( "fmt" "os" "os/exec" + "strconv" "strings" + "time" + acp "github.com/coder/acp-go-sdk" + + "github.com/pubgo/fastgit/pkg/acpclient" + "github.com/pubgo/fastgit/pkg/acpconfig" + "github.com/pubgo/fastgit/pkg/acpui" "github.com/pubgo/fastgit/pkg/aiprovider" "github.com/pubgo/fastgit/pkg/copilotperm" "github.com/pubgo/redant" @@ -143,6 +150,73 @@ func runDraftWithCopilot(ctx context.Context, inv *redant.Invocation, prompt str return nil } +// agentParams 是 acp agent 模式 draft 的运行参数。 +type agentParams struct { + Agent string + Permit string + Timeout string + ClientFS bool +} + +// runDraftWithAgent 用 ACP agent(agent 模式)执行 draft:agent 在自己的 +// 会话里读 git log/diff 并把结果写回 Unreleased.md(client fs 沙箱限定仓库目录)。 +func runDraftWithAgent(ctx context.Context, inv *redant.Invocation, prompt, repoRoot string, params agentParams) error { + cfg, err := acpconfig.Load() + if err != nil { + return err + } + agent, err := cfg.Resolve(params.Agent) + if err != nil { + return err + } + onPermission, err := acpui.Permission(params.Permit) + if err != nil { + return err + } + + timeout := 5 * time.Minute + if secs, convErr := strconv.Atoi(params.Timeout); convErr == nil && secs > 0 { + timeout = time.Duration(secs) * time.Second + } + + _, _ = fmt.Fprintf(inv.Stdout, "→ drafting changelog with acp agent %q (permit=%s timeout=%s)\n", agent.Name, defaultString(params.Permit, "ask"), timeout) + + result, err := acpclient.Run(ctx, agent, acpclient.Options{ + Cwd: repoRoot, + Prompt: prompt, + Timeout: timeout, + OnPermission: onPermission, + ClientFS: params.ClientFS, + OnUpdate: func(update acp.SessionUpdate) error { + if line := acpclient.UpdateLine(update); line != "" { + _, _ = fmt.Fprintln(inv.Stdout, line) + } + return nil + }, + }) + if err != nil { + return err + } + + paths := buildPaths(repoRoot) + _, _ = fmt.Fprintf(inv.Stdout, "agent summary:\n%s\n", strings.TrimSpace(result.Text)) + + if status, statusErr := gitOutput(ctx, repoRoot, "status", "--porcelain", "--", paths.UnreleasedFile); statusErr == nil { + if strings.TrimSpace(status) != "" { + _, _ = fmt.Fprintln(inv.Stdout, "draft updated: .version/changelog/Unreleased.md(review: git diff -- .version/changelog/Unreleased.md)") + } else { + _, _ = fmt.Fprintln(inv.Stdout, "warning: Unreleased.md 未被 agent 改动,请检查 agent 输出或改用 --provider copilot") + } + } + + if data, readErr := os.ReadFile(paths.UnreleasedFile); readErr == nil { + if validateErr := ValidateReleaseReadiness(string(data)); validateErr != nil { + _, _ = fmt.Fprintf(inv.Stdout, "note: %v\n", validateErr) + } + } + return nil +} + func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback diff --git a/cmds/chglogcmd/draft_agent_test.go b/cmds/chglogcmd/draft_agent_test.go new file mode 100644 index 0000000..98cd6b4 --- /dev/null +++ b/cmds/chglogcmd/draft_agent_test.go @@ -0,0 +1,190 @@ +package chglogcmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + acp "github.com/coder/acp-go-sdk" + "github.com/pubgo/fastgit/pkg/acpconfig" + "github.com/pubgo/redant" + "github.com/stretchr/testify/require" +) + +const draftFakeAgentMarker = "FASTGIT_DRAFT_FAKE_AGENT" + +// TestDraftFakeAgentProcess doubles as a fake ACP agent that writes the +// changelog file through client fs and replies with a summary. +func TestDraftFakeAgentProcess(t *testing.T) { + if os.Getenv(draftFakeAgentMarker) != "1" { + t.Skip("only runs when spawned as a fake acp agent") + } + agent := &draftFakeAgent{target: os.Getenv("FAKE_TARGET_PATH")} + conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin) + agent.conn = conn + <-conn.Done() + os.Exit(0) +} + +type draftFakeAgent struct { + target string + conn *acp.AgentSideConnection +} + +const draftFakeContent = `# [Unreleased] + +## 新增 + +- fake agent 写回的条目 + +## 修复 + +- 无 + +## 变更 + +- 无 + +## 文档 + +- 无 + +## 影响范围 + +- 测试仓库 + +## 验证建议 + +- 跑测试 + +## 回滚建议 + +- 回退提交 +` + +func (a *draftFakeAgent) Initialize(ctx context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) { + return acp.InitializeResponse{ProtocolVersion: acp.ProtocolVersionNumber}, nil +} + +func (a *draftFakeAgent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) { + return acp.NewSessionResponse{SessionId: "draft-fake"}, nil +} + +func (a *draftFakeAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) { + if _, err := a.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{ + SessionId: params.SessionId, Path: a.target, Content: draftFakeContent, + }); err != nil { + return acp.PromptResponse{}, err + } + if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: params.SessionId, + Update: acp.UpdateAgentMessageText("updated unreleased"), + }); err != nil { + return acp.PromptResponse{}, err + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil +} + +func (a *draftFakeAgent) Authenticate(ctx context.Context, params acp.AuthenticateRequest) (acp.AuthenticateResponse, error) { + return acp.AuthenticateResponse{}, acp.NewMethodNotFound("authenticate") +} + +func (a *draftFakeAgent) Logout(ctx context.Context, params acp.LogoutRequest) (acp.LogoutResponse, error) { + return acp.LogoutResponse{}, acp.NewMethodNotFound("logout") +} + +func (a *draftFakeAgent) Cancel(ctx context.Context, params acp.CancelNotification) error { return nil } + +func (a *draftFakeAgent) CloseSession(ctx context.Context, params acp.CloseSessionRequest) (acp.CloseSessionResponse, error) { + return acp.CloseSessionResponse{}, nil +} + +func (a *draftFakeAgent) ListSessions(ctx context.Context, params acp.ListSessionsRequest) (acp.ListSessionsResponse, error) { + return acp.ListSessionsResponse{}, acp.NewMethodNotFound("session/list") +} + +func (a *draftFakeAgent) ResumeSession(ctx context.Context, params acp.ResumeSessionRequest) (acp.ResumeSessionResponse, error) { + return acp.ResumeSessionResponse{}, acp.NewMethodNotFound("session/resume") +} + +func (a *draftFakeAgent) SetSessionConfigOption(ctx context.Context, params acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) { + return acp.SetSessionConfigOptionResponse{}, acp.NewMethodNotFound("session/set_config_option") +} + +func (a *draftFakeAgent) SetSessionMode(ctx context.Context, params acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) { + return acp.SetSessionModeResponse{}, acp.NewMethodNotFound("session/set_mode") +} + +func setupDraftRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-q", "--initial-branch=main") + runGit(t, repo, "commit", "-q", "--allow-empty", "-m", "init") + if _, err := ensureChangelogScaffold(repo, scaffoldOptions{Version: "v0.0.1", CreateVersionIfMissing: true}); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "-A") + runGit(t, repo, "commit", "-q", "-m", "scaffold changelog") + return repo +} + +func overrideACPConfig(t *testing.T, body string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) + old := acpconfig.ConfigPathFn + acpconfig.ConfigPathFn = func() string { return path } + t.Cleanup(func() { acpconfig.ConfigPathFn = old }) +} + +func TestRunDraftWithAgentHappyPath(t *testing.T) { + repo := setupDraftRepo(t) + paths := buildPaths(repo) + exe, err := os.Executable() + require.NoError(t, err) + + overrideACPConfig(t, "acp:\n default: draftfake\n agents:\n draftfake:\n command: "+exe+"\n args: [\"-test.run=TestDraftFakeAgentProcess\", \"-test.timeout=120s\"]\n env:\n "+draftFakeAgentMarker+": \"1\"\n FAKE_TARGET_PATH: \""+paths.UnreleasedFile+"\"\n") + + buf := &bytes.Buffer{} + inv := &redant.Invocation{Stdout: buf, Stderr: buf} + err = runDraftWithAgent(context.Background(), inv, "update the changelog", repo, agentParams{ + Permit: "deny", // fake agent never requests permission + Timeout: "60", + ClientFS: true, + }) + require.NoError(t, err) + + out := buf.String() + require.Contains(t, out, "draft updated") + require.NotContains(t, out, "note:") + + data, err := os.ReadFile(paths.UnreleasedFile) + require.NoError(t, err) + require.Contains(t, string(data), "fake agent 写回的条目") +} + +func TestRunDraftWithAgentNoAgentConfigured(t *testing.T) { + repo := setupDraftRepo(t) + overrideACPConfig(t, "version:\n name: v0.0.1\n") + t.Setenv(acpconfig.EnvAgentVar, "ghost-agent") + + buf := &bytes.Buffer{} + inv := &redant.Invocation{Stdout: buf, Stderr: buf} + err := runDraftWithAgent(context.Background(), inv, "update", repo, agentParams{Timeout: "30"}) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "ghost-agent")) +} + +func TestRunDraftWithAgentUnchangedFileWarns(t *testing.T) { + repo := setupDraftRepo(t) + overrideACPConfig(t, "acp:\n agents:\n broken:\n command: sh\n args: [\"-c\", \"exit 7\"]\n") + + buf := &bytes.Buffer{} + inv := &redant.Invocation{Stdout: buf, Stderr: buf} + err := runDraftWithAgent(context.Background(), inv, "update", repo, agentParams{Timeout: "10"}) + require.Error(t, err) // fails at initialize: not an acp agent + require.Contains(t, err.Error(), "broken") +} diff --git a/pkg/acpclient/client.go b/pkg/acpclient/client.go index 01245ce..9078b77 100644 --- a/pkg/acpclient/client.go +++ b/pkg/acpclient/client.go @@ -35,6 +35,11 @@ type Options struct { Timeout time.Duration // OnUpdate 可选,收到每个 session/update 时回调。 OnUpdate func(update acp.SessionUpdate) error + // OnPermission 可选,nil 表示自动拒绝 agent 的权限请求(只读场景)。 + OnPermission PermissionHandler + // ClientFS 为 true 时声明并处理 fs/read_text_file、fs/write_text_file, + // 路径沙箱限定在 Cwd 内。 + ClientFS bool } // Result 是一次 prompt 轮次的输出。 @@ -46,27 +51,38 @@ type Result struct { // DefaultTimeout 是单轮 prompt 的默认上限。 const DefaultTimeout = 2 * time.Minute -// AgentName implements acp.Client, collecting agent_message chunks. +// collector implements acp.Client, collecting agent_message chunks. type collector struct { - mu sync.Mutex - text strings.Builder - denials []string - onUpdate func(update acp.SessionUpdate) error + mu sync.Mutex + text strings.Builder + denials []string + onUpdate func(update acp.SessionUpdate) error + onPermission PermissionHandler + fs *fsHandler } var _ acp.Client = (*collector)(nil) func (c *collector) ReadTextFile(ctx context.Context, params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) { + if c != nil && c.fs != nil { + return c.fs.read(params) + } return acp.ReadTextFileResponse{}, acp.NewMethodNotFound(acp.ClientMethodFsReadTextFile) } func (c *collector) WriteTextFile(ctx context.Context, params acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) { + if c != nil && c.fs != nil { + return c.fs.write(params) + } return acp.WriteTextFileResponse{}, acp.NewMethodNotFound(acp.ClientMethodFsWriteTextFile) } -// RequestPermission 自动拒绝:commit 生成场景把所有上下文直接放进 prompt, -// 正常不应触发权限请求;一旦触发说明 agent 想做额外操作,直接 cancel。 +// RequestPermission 默认自动拒绝:commit 生成等只读场景把所有上下文直接放进 +// prompt,不应触发权限请求;配置了 OnPermission 时交由策略决定(agent 模式)。 func (c *collector) RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + if c != nil && c.onPermission != nil { + return c.onPermission(ctx, params) + } title := "" if params.ToolCall.Title != nil { title = strings.TrimSpace(*params.ToolCall.Title) @@ -170,13 +186,21 @@ func Run(ctx context.Context, agent Agent, opts Options) (Result, error) { _ = cmd.Wait() }() - client := &collector{onUpdate: opts.OnUpdate} + client := &collector{onUpdate: opts.OnUpdate, onPermission: opts.OnPermission} + if opts.ClientFS { + client.fs = &fsHandler{root: cwd} + } conn := acp.NewClientSideConnection(client, stdin, stdout) if _, err := conn.Initialize(runCtx, acp.InitializeRequest{ - ProtocolVersion: acp.ProtocolVersionNumber, - ClientInfo: &acp.Implementation{Name: "fastgit", Version: versionString()}, - ClientCapabilities: acp.ClientCapabilities{}, + ProtocolVersion: acp.ProtocolVersionNumber, + ClientInfo: &acp.Implementation{Name: "fastgit", Version: versionString()}, + ClientCapabilities: acp.ClientCapabilities{ + Fs: acp.FileSystemCapabilities{ + ReadTextFile: opts.ClientFS, + WriteTextFile: opts.ClientFS, + }, + }, }); err != nil { return Result{}, wrapRunError(agent, "initialize", err, &stderr) } diff --git a/pkg/acpclient/client_test.go b/pkg/acpclient/client_test.go index f9d8bcd..d2a6074 100644 --- a/pkg/acpclient/client_test.go +++ b/pkg/acpclient/client_test.go @@ -3,6 +3,7 @@ package acpclient import ( "context" "os" + "path/filepath" "strings" "testing" "time" @@ -80,6 +81,57 @@ func (a *fakeAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.P case <-time.After(30 * time.Second): return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil } + case "fs-write": + if _, err := a.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{ + SessionId: params.SessionId, Path: "out.txt", Content: "hello fs", + }); err != nil { + return acp.PromptResponse{}, err + } + if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: params.SessionId, + Update: acp.UpdateAgentMessageText("wrote out.txt"), + }); err != nil { + return acp.PromptResponse{}, err + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil + case "fs-escape": + _, err := a.conn.WriteTextFile(ctx, acp.WriteTextFileRequest{ + SessionId: params.SessionId, Path: "../escape.txt", Content: "nope", + }) + text := "sandbox-error" + if err == nil { + text = "sandbox-bypassed" + } + if e := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: params.SessionId, + Update: acp.UpdateAgentMessageText(text), + }); e != nil { + return acp.PromptResponse{}, e + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil + case "ask-allow": + resp, err := a.conn.RequestPermission(ctx, acp.RequestPermissionRequest{ + SessionId: params.SessionId, + ToolCall: acp.ToolCallUpdate{ToolCallId: "t1", Title: acp.Ptr("edit Unreleased.md")}, + Options: []acp.PermissionOption{ + {OptionId: "al", Name: "Always allow", Kind: acp.PermissionOptionKindAllowAlways}, + {OptionId: "on", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}, + }, + }) + if err != nil { + return acp.PromptResponse{}, err + } + text := "cancelled" + if resp.Outcome.Selected != nil { + text = "selected:" + string(resp.Outcome.Selected.OptionId) + } + if e := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: params.SessionId, + Update: acp.UpdateAgentMessageText(text), + }); e != nil { + return acp.PromptResponse{}, e + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil default: // reply text := a.reply if text == "" { @@ -177,6 +229,66 @@ func TestRunEmptyCommand(t *testing.T) { require.Contains(t, err.Error(), "command is empty") } +func TestRunClientFSWriteSandboxed(t *testing.T) { + dir := t.TempDir() + agent := newFakeAgent("fs-write", "") + result, err := Run(context.Background(), agent, Options{Prompt: "write", Cwd: dir, ClientFS: true, Timeout: 60 * time.Second}) + require.NoError(t, err) + require.Equal(t, "wrote out.txt", result.Text) + + data, err := os.ReadFile(filepath.Join(dir, "out.txt")) + require.NoError(t, err) + require.Equal(t, "hello fs", string(data)) +} + +func TestRunClientFSEscapeDenied(t *testing.T) { + dir := t.TempDir() + agent := newFakeAgent("fs-escape", "") + result, err := Run(context.Background(), agent, Options{Prompt: "escape", Cwd: dir, ClientFS: true, Timeout: 60 * time.Second}) + require.NoError(t, err) + require.Equal(t, "sandbox-error", result.Text) + require.NoFileExists(t, filepath.Join(filepath.Dir(dir), "escape.txt")) +} + +func TestRunClientFSNotAdvertised(t *testing.T) { + dir := t.TempDir() + agent := newFakeAgent("fs-write", "") + _, err := Run(context.Background(), agent, Options{Prompt: "write", Cwd: dir, Timeout: 60 * time.Second}) + require.Error(t, err) // agent got method-not-found and failed the prompt +} + +func TestRunPermissionAllowOnce(t *testing.T) { + agent := newFakeAgent("ask-allow", "") + result, err := Run(context.Background(), agent, Options{ + Prompt: "edit it", + Timeout: 60 * time.Second, + OnPermission: PermissionAllowOnce, + }) + require.NoError(t, err) + require.Equal(t, "selected:on", result.Text) // allow_once preferred over allow_always +} + +func TestRunPermissionInteractiveDeny(t *testing.T) { + agent := newFakeAgent("ask-allow", "") + result, err := Run(context.Background(), agent, Options{ + Prompt: "edit it", + Timeout: 60 * time.Second, + OnPermission: PermissionInteractive(func(title string, options []acp.PermissionOption) string { + require.Equal(t, "edit Unreleased.md", title) + return "" // reject + }), + }) + require.NoError(t, err) + require.Equal(t, "cancelled", result.Text) +} + +func TestPermissionAllowOnceNoAllowOption(t *testing.T) { + _, err := PermissionAllowOnce(context.Background(), acp.RequestPermissionRequest{ + Options: []acp.PermissionOption{{OptionId: "no", Name: "No", Kind: acp.PermissionOptionKindRejectOnce}}, + }) + require.NoError(t, err) +} + func TestBuildPrompt(t *testing.T) { require.Equal(t, "user", buildPrompt(Options{Prompt: "user"})) require.Equal(t, "sys", buildPrompt(Options{System: "sys"})) diff --git a/pkg/acpclient/fs.go b/pkg/acpclient/fs.go new file mode 100644 index 0000000..76926f5 --- /dev/null +++ b/pkg/acpclient/fs.go @@ -0,0 +1,92 @@ +package acpclient + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + acp "github.com/coder/acp-go-sdk" +) + +// fsHandler 实现 ACP client 侧 fs/read_text_file、fs/write_text_file, +// 所有路径限定在 root(repo cwd)内,越界返回错误。 +type fsHandler struct{ root string } + +func (f *fsHandler) read(params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) { + path, err := f.resolve(params.Path) + if err != nil { + return acp.ReadTextFileResponse{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return acp.ReadTextFileResponse{}, err + } + + content := string(data) + if params.Line != nil || params.Limit != nil { + lines := strings.Split(content, "\n") + start := 0 + if params.Line != nil && *params.Line > 0 { + start = clamp(*params.Line-1, 0, len(lines)) + } + end := len(lines) + if params.Limit != nil && *params.Limit > 0 && start+*params.Limit < end { + end = start + *params.Limit + } + if start > end { + start = end + } + content = strings.Join(lines[start:end], "\n") + } + return acp.ReadTextFileResponse{Content: content}, nil +} + +func (f *fsHandler) write(params acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) { + path, err := f.resolve(params.Path) + if err != nil { + return acp.WriteTextFileResponse{}, err + } + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o644); err != nil { + return acp.WriteTextFileResponse{}, err + } + } + if err := os.WriteFile(path, []byte(params.Content), 0o644); err != nil { + return acp.WriteTextFileResponse{}, err + } + return acp.WriteTextFileResponse{}, nil +} + +func (f *fsHandler) resolve(raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "", fmt.Errorf("fs: empty path") + } + root, err := filepath.Abs(f.root) + if err != nil { + return "", err + } + path := raw + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("fs: path %q outside sandbox %q", raw, root) + } + return path, nil +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/pkg/acpclient/policy.go b/pkg/acpclient/policy.go new file mode 100644 index 0000000..c9a79f9 --- /dev/null +++ b/pkg/acpclient/policy.go @@ -0,0 +1,64 @@ +package acpclient + +import ( + "context" + + acp "github.com/coder/acp-go-sdk" +) + +// PermissionHandler 决定如何回应 agent 的 session/request_permission。 +// Options.OnPermission 为 nil 时维持自动拒绝(commit 生成等只读场景)。 +type PermissionHandler func(ctx context.Context, req acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) + +// PermissionAllowOnce 是自动放行策略:优先 allow_once,其次 allow_always, +// 都没有则取消。适合 CI/批处理的 agent 模式。 +func PermissionAllowOnce(ctx context.Context, req acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + cancelled := acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()} + var fallback *acp.PermissionOption + for i := range req.Options { + switch req.Options[i].Kind { + case acp.PermissionOptionKindAllowOnce: + return selected(req.Options[i]), nil + case acp.PermissionOptionKindAllowAlways: + if fallback == nil { + o := req.Options[i] + fallback = &o + } + } + } + if fallback != nil { + return selected(*fallback), nil + } + return cancelled, nil +} + +// PermissionInteractive 用注入的 ask 回调向用户确认。ask 返回选中的 +// optionId;返回空字符串视为拒绝(取消)。 +func PermissionInteractive(ask func(title string, options []acp.PermissionOption) string) PermissionHandler { + return func(ctx context.Context, req acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + title := "" + if req.ToolCall.Title != nil { + title = *req.ToolCall.Title + } + if title == "" { + title = string(req.ToolCall.ToolCallId) + } + if ask == nil { + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil + } + if chosen := ask(title, req.Options); chosen != "" { + for _, opt := range req.Options { + if string(opt.OptionId) == chosen { + return selected(opt), nil + } + } + } + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil + } +} + +func selected(option acp.PermissionOption) acp.RequestPermissionResponse { + return acp.RequestPermissionResponse{ + Outcome: acp.NewRequestPermissionOutcomeSelected(option.OptionId), + } +} diff --git a/pkg/acpclient/updates.go b/pkg/acpclient/updates.go new file mode 100644 index 0000000..354a80a --- /dev/null +++ b/pkg/acpclient/updates.go @@ -0,0 +1,36 @@ +package acpclient + +import ( + "fmt" + + acp "github.com/coder/acp-go-sdk" +) + +// UpdateLine renders a session/update into a short one-line progress string. +// Returns empty string for updates not worth showing. +func UpdateLine(update acp.SessionUpdate) string { + switch { + case update.ToolCall != nil: + status := string(update.ToolCall.Status) + if status == "" { + status = "pending" + } + return fmt.Sprintf("🔧 %s [%s]", update.ToolCall.Title, status) + case update.ToolCallUpdate != nil: + title := "" + if update.ToolCallUpdate.Title != nil { + title = " " + *update.ToolCallUpdate.Title + } + status := "" + if update.ToolCallUpdate.Status != nil { + status = string(*update.ToolCallUpdate.Status) + } + return fmt.Sprintf("🔧 %s%s [%s]", update.ToolCallUpdate.ToolCallId, title, status) + case update.Plan != nil: + return fmt.Sprintf("📋 plan: %d entries", len(update.Plan.Entries)) + case update.AgentThoughtChunk != nil: + return "" + default: + return "" + } +} diff --git a/pkg/acpconfig/config.go b/pkg/acpconfig/config.go index 8953fb7..14f1792 100644 --- a/pkg/acpconfig/config.go +++ b/pkg/acpconfig/config.go @@ -69,30 +69,23 @@ func Load() (*Config, error) { return cfg, nil } -// Resolve picks the agent to use. Precedence: explicit name (want), then -// FASTGIT_ACP_AGENT env, then config default, then the only configured agent, -// then an installed catalog agent. +// Resolve picks the agent to use. Explicit selections fail fast: the want +// argument, then FASTGIT_ACP_AGENT, then acp.default must resolve if set. +// With nothing configured it falls back to the only configured agent, then to +// auto-detecting an installed catalog agent. func (c *Config) Resolve(want string) (acpclient.Agent, error) { - names := []string{} - if w := strings.TrimSpace(want); w != "" { - names = append(names, w) - } - if e := strings.TrimSpace(os.Getenv(EnvAgentVar)); e != "" { - names = append(names, e) - } - if d := strings.TrimSpace(c.Default); d != "" { - names = append(names, d) - } - - // 显式指定过名字(flag/env/default)时,任一命中即返回;全部未命中直接报错, - // 不回退到自动探测,避免静默用错 agent。 - for _, name := range names { - if agent, ok := c.agentByName(name); ok { + for _, explicit := range []string{ + strings.TrimSpace(want), + strings.TrimSpace(os.Getenv(EnvAgentVar)), + strings.TrimSpace(c.Default), + } { + if explicit == "" { + continue + } + if agent, ok := c.agentByName(explicit); ok { return agent, nil } - } - if len(names) > 0 { - return acpclient.Agent{}, fmt.Errorf("acp agent %q not found in config (acp.agents) or built-in catalog", names[0]) + return acpclient.Agent{}, fmt.Errorf("acp agent %q not found in config (acp.agents) or built-in catalog", explicit) } if len(c.Agents) == 1 { @@ -108,10 +101,7 @@ func (c *Config) Resolve(want string) (acpclient.Agent, error) { } } - if len(names) == 0 && len(c.Agents) == 0 { - return acpclient.Agent{}, fmt.Errorf("no acp agent configured; run `ggc acp add ` or set acp.default in %s", ConfigPathFn()) - } - return acpclient.Agent{}, fmt.Errorf("no usable acp agent found; run `ggc acp list` to see candidates") + return acpclient.Agent{}, fmt.Errorf("no usable acp agent found; run `ggc acp add ` or set acp.default in %s (see `ggc acp list`)", ConfigPathFn()) } func (c *Config) agentByName(name string) (acpclient.Agent, bool) { diff --git a/pkg/acpui/ask.go b/pkg/acpui/ask.go new file mode 100644 index 0000000..5c957a1 --- /dev/null +++ b/pkg/acpui/ask.go @@ -0,0 +1,56 @@ +// Package acpui 提供 ACP 权限请求的终端交互实现(tap 确认)。 +package acpui + +import ( + "context" + "fmt" + "strings" + + acp "github.com/coder/acp-go-sdk" + "github.com/yarlson/tap" + + "github.com/pubgo/fastgit/pkg/acpclient" +) + +// Permission 按模式名构造权限策略: ask(默认,交互)|auto(自动放行)|deny(自动拒绝)。 +func Permission(mode string) (acpclient.PermissionHandler, error) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "ask": + return PermissionAsk(), nil + case "auto": + return acpclient.PermissionAllowOnce, nil + case "deny": + return nil, nil + default: + return nil, fmt.Errorf("invalid permission mode %q, want ask|auto|deny", mode) + } +} + +// PermissionAsk 返回一个交互权限策略:每次 agent 请求权限时列出候选项, +// 由用户选择;选择"拒绝"时取消请求。 +func PermissionAsk() acpclient.PermissionHandler { + return acpclient.PermissionInteractive(func(title string, options []acp.PermissionOption) string { + const denyValue = "__deny__" + items := make([]tap.SelectOption[string], 0, len(options)+1) + for _, opt := range options { + label := opt.Name + if label == "" { + label = string(opt.OptionId) + } + items = append(items, tap.SelectOption[string]{ + Label: label + " (" + string(opt.Kind) + ")", + Value: string(opt.OptionId), + }) + } + items = append(items, tap.SelectOption[string]{Label: "拒绝该请求", Value: denyValue}) + + selected := tap.Select[string](context.Background(), tap.SelectOptions[string]{ + Message: "acp agent 请求权限: " + title, + Options: items, + }) + if selected == denyValue { + return "" + } + return selected + }) +} diff --git a/pkg/acpui/ask_test.go b/pkg/acpui/ask_test.go new file mode 100644 index 0000000..ebc0ad7 --- /dev/null +++ b/pkg/acpui/ask_test.go @@ -0,0 +1,40 @@ +package acpui + +import ( + "context" + "testing" + + acp "github.com/coder/acp-go-sdk" + "github.com/stretchr/testify/require" + + "github.com/pubgo/fastgit/pkg/acpclient" +) + +func TestPermissionModeMapping(t *testing.T) { + h, err := Permission("deny") + require.NoError(t, err) + require.Nil(t, h) + + h, err = Permission("auto") + require.NoError(t, err) + require.NotNil(t, h) + + h, err = Permission("") + require.NoError(t, err) + require.NotNil(t, h) // default ask, handler built without touching tty + + _, err = Permission("bogus") + require.Error(t, err) +} + +func TestPermissionAllowOncePrefersAllowAlwaysWhenNoOnce(t *testing.T) { + resp, err := acpclient.PermissionAllowOnce(context.Background(), acp.RequestPermissionRequest{ + Options: []acp.PermissionOption{ + {OptionId: "always", Name: "Always", Kind: acp.PermissionOptionKindAllowAlways}, + {OptionId: "reject", Name: "Reject", Kind: acp.PermissionOptionKindRejectOnce}, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp.Outcome.Selected) + require.Equal(t, acp.PermissionOptionId("always"), resp.Outcome.Selected.OptionId) +}