feat(agents): add Google Antigravity as a selectable coding agent (#3324)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -31,7 +31,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi, Grok, Hermes
|
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi, Grok, Hermes, Antigravity
|
||||||
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
|
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
|
||||||
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
|
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
|
||||||
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
|
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
|
||||||
@@ -96,6 +96,7 @@ Connect Kandev to GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps to pull
|
|||||||
| **Oh My Pi** | `omp` *(install `@oh-my-pi/pi-coding-agent` with Bun)* |
|
| **Oh My Pi** | `omp` *(install `@oh-my-pi/pi-coding-agent` with Bun)* |
|
||||||
| **Grok** | `grok` *(install `@xai-official/grok` with npm)* |
|
| **Grok** | `grok` *(install `@xai-official/grok` with npm)* |
|
||||||
| **Hermes** | `hermes` *(install with the official Hermes installer)* |
|
| **Hermes** | `hermes` *(install with the official Hermes installer)* |
|
||||||
|
| **Antigravity** | `agy_acp_server.par` / `.exe` *(no automated install; download from the [ACP registry](https://github.com/agentclientprotocol/registry/tree/main/antigravity-acp), extract both archive entries into one directory, and put that directory on PATH)* |
|
||||||
|
|
||||||
> All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is available when an integration provides a passthrough command. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/public/add-agent-cli.md) for a step-by-step guide.
|
> All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is available when an integration provides a passthrough command. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/public/add-agent-cli.md) for a step-by-step guide.
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ There are a few similar tools in this space, and new ones appearing everyday. He
|
|||||||
|
|
||||||
- **Server-first architecture** - The core app runs as a server you can access from any device, including your phone.
|
- **Server-first architecture** - The core app runs as a server you can access from any device, including your phone.
|
||||||
- **Remote runtimes** - Run agents on remote servers via SSH, Docker hosts, and cloud environments, not just your local machine.
|
- **Remote runtimes** - Run agents on remote servers via SSH, Docker hosts, and cloud environments, not just your local machine.
|
||||||
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, Grok, and Hermes side by side. Not locked to one vendor.
|
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, Grok, Hermes, and Antigravity side by side. Not locked to one vendor.
|
||||||
- **CLI passthrough and chat** - Interact with agents through structured chat messages or, where supported, drop into raw CLI mode for full agent TUI capabilities.
|
- **CLI passthrough and chat** - Interact with agents through structured chat messages or, where supported, drop into raw CLI mode for full agent TUI capabilities.
|
||||||
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
|
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//nolint:dupl,goconst // Native-binary ACP agents follow the same minimal scaffold; GOOS names and remote-auth Type/OS literals are shared vocabulary across every peer file by convention.
|
||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/agent/usage"
|
||||||
|
"github.com/kandev/kandev/pkg/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed logos/antigravity_acp_light.svg
|
||||||
|
var antigravityACPLogoLight []byte
|
||||||
|
|
||||||
|
//go:embed logos/antigravity_acp_dark.svg
|
||||||
|
var antigravityACPLogoDark []byte
|
||||||
|
|
||||||
|
// antigravityHarnessPathEnv, when set to a non-empty value, tells the
|
||||||
|
// Antigravity ACP server where to find its harness without searching its own
|
||||||
|
// executable's directory. A set value satisfies discovery's harness check
|
||||||
|
// without inspecting the directory.
|
||||||
|
const antigravityHarnessPathEnv = "ANTIGRAVITY_HARNESS_PATH"
|
||||||
|
|
||||||
|
// antigravityConfigRelDir is the server's config root, home-relative.
|
||||||
|
const antigravityConfigRelDir = ".gemini/antigravity-acp"
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ Agent = (*AntigravityACP)(nil)
|
||||||
|
_ InferenceAgent = (*AntigravityACP)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AntigravityACP implements Agent for Google's standalone Antigravity ACP
|
||||||
|
// server (registry entry "antigravity-acp"), a signed native binary distinct
|
||||||
|
// from the `agy` CLI. Discovery, launch argv, config root, and remote-auth
|
||||||
|
// methods are declared here; the server owns its own credentials, session
|
||||||
|
// storage, and harness subprocess (see
|
||||||
|
// docs/specs/agents/system-design/antigravity-acp-agent.md).
|
||||||
|
type AntigravityACP struct{}
|
||||||
|
|
||||||
|
func NewAntigravityACP() *AntigravityACP {
|
||||||
|
return &AntigravityACP{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) ID() string { return "antigravity-acp" }
|
||||||
|
func (a *AntigravityACP) Name() string { return "Antigravity ACP Agent" }
|
||||||
|
func (a *AntigravityACP) DisplayName() string { return "Antigravity" }
|
||||||
|
func (a *AntigravityACP) Description() string {
|
||||||
|
return "Google's standalone Antigravity ACP server, distributed separately from the agy CLI. " +
|
||||||
|
"Not auto-installed: download the antigravity-acp entry from the ACP registry, extract both " +
|
||||||
|
"archive entries into one directory, and add that directory to PATH. Kandev looks for " +
|
||||||
|
"agy_acp_server.par (agy_acp_server.exe on Windows)."
|
||||||
|
}
|
||||||
|
func (a *AntigravityACP) Enabled() bool { return true }
|
||||||
|
func (a *AntigravityACP) DisplayOrder() int { return 22 }
|
||||||
|
|
||||||
|
func (a *AntigravityACP) Logo(v LogoVariant) []byte {
|
||||||
|
if v == LogoDark {
|
||||||
|
return antigravityACPLogoDark
|
||||||
|
}
|
||||||
|
return antigravityACPLogoLight
|
||||||
|
}
|
||||||
|
|
||||||
|
// antigravityACPBinaryName returns the platform-specific ACP server
|
||||||
|
// executable name. It is defined on every platform and consults nothing but
|
||||||
|
// runtime.GOOS.
|
||||||
|
func antigravityACPBinaryName() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "agy_acp_server.exe"
|
||||||
|
}
|
||||||
|
return "agy_acp_server.par"
|
||||||
|
}
|
||||||
|
|
||||||
|
// antigravityACPArgv returns the deterministic launch argument vector for the
|
||||||
|
// host platform. It is defined and non-empty on every platform, and it
|
||||||
|
// consults only runtime.GOOS, never PATH, the filesystem, or the environment.
|
||||||
|
// argv[0] is built from antigravityACPBinaryName so the launched name can
|
||||||
|
// never drift from the name discovery looked up.
|
||||||
|
func antigravityACPArgv() []string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
return []string{antigravityACPBinaryName(), "--uid="}
|
||||||
|
}
|
||||||
|
return []string{antigravityACPBinaryName()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// antigravityHarnessNames returns the harness executable names to search
|
||||||
|
// for, in first-match-wins order, suffixed for the host platform.
|
||||||
|
func antigravityHarnessNames() []string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return []string{"localharness_external.exe", "localharness.exe"}
|
||||||
|
}
|
||||||
|
return []string{"localharness_external", "localharness"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// antigravityHarnessPresent reports whether one of the harness names exists
|
||||||
|
// as a usable regular file in dir. A directory, a dangling symlink, or an
|
||||||
|
// unstat-able name does not match.
|
||||||
|
func antigravityHarnessPresent(dir string) bool {
|
||||||
|
for _, name := range antigravityHarnessNames() {
|
||||||
|
info, err := os.Stat(filepath.Join(dir, name))
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstalled reports the agent available only when the server executable is
|
||||||
|
// on PATH and its harness sibling can be found beside it (or
|
||||||
|
// ANTIGRAVITY_HARNESS_PATH names one), so a partial install never reads as
|
||||||
|
// healthy.
|
||||||
|
func (a *AntigravityACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path, err := exec.LookPath(antigravityACPBinaryName())
|
||||||
|
if err != nil {
|
||||||
|
return &DiscoveryResult{Available: false}, nil
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
harnessSatisfied := os.Getenv(antigravityHarnessPathEnv) != "" || antigravityHarnessPresent(filepath.Dir(path))
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !harnessSatisfied {
|
||||||
|
return &DiscoveryResult{Available: false}, nil
|
||||||
|
}
|
||||||
|
return &DiscoveryResult{
|
||||||
|
Available: true,
|
||||||
|
MatchedPath: path,
|
||||||
|
SupportsMCP: true,
|
||||||
|
Capabilities: DiscoveryCapabilities{
|
||||||
|
SupportsSessionResume: true,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) BuildCommand(opts CommandOptions) Command {
|
||||||
|
return NewCommand(antigravityACPArgv()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) Runtime() *RuntimeConfig {
|
||||||
|
canRecover := true
|
||||||
|
return &RuntimeConfig{
|
||||||
|
Cmd: NewCommand(antigravityACPArgv()...),
|
||||||
|
WorkingDir: "{workspace}",
|
||||||
|
Env: map[string]string{},
|
||||||
|
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||||
|
Protocol: agent.ProtocolACP,
|
||||||
|
ProjectSkillDir: DefaultProjectSkillDir,
|
||||||
|
UserSkillDir: ".gemini/config/skills",
|
||||||
|
SessionConfig: SessionConfig{
|
||||||
|
NativeSessionResume: true,
|
||||||
|
CanRecover: &canRecover,
|
||||||
|
SessionDirTemplate: "{home}/" + antigravityConfigRelDir,
|
||||||
|
// SessionDirTarget stays empty because the config root has no
|
||||||
|
// container bind mount.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) RemoteAuth() *RemoteAuth {
|
||||||
|
files := []string{
|
||||||
|
antigravityConfigRelDir + "/settings.json",
|
||||||
|
antigravityConfigRelDir + "/acp_token.json",
|
||||||
|
antigravityConfigRelDir + "/acp_business_token.json",
|
||||||
|
}
|
||||||
|
return &RemoteAuth{
|
||||||
|
Methods: []RemoteAuthMethod{
|
||||||
|
{
|
||||||
|
Type: "files",
|
||||||
|
SourceFiles: map[string][]string{
|
||||||
|
"darwin": files,
|
||||||
|
"linux": files,
|
||||||
|
},
|
||||||
|
TargetRelDir: antigravityConfigRelDir,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "env",
|
||||||
|
EnvVar: "GEMINI_API_KEY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "env",
|
||||||
|
EnvVar: "GOOGLE_API_KEY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) InstallScript() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) PermissionSettings() map[string]PermissionSetting {
|
||||||
|
return emptyPermSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) InferenceConfig() *InferenceConfig {
|
||||||
|
return &InferenceConfig{
|
||||||
|
Supported: true,
|
||||||
|
Command: NewCommand(antigravityACPArgv()...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AntigravityACP) BillingType() usage.BillingType { return defaultBillingType() }
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/agent/usage"
|
||||||
|
"github.com/kandev/kandev/pkg/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantAntigravityArgv returns the expected argument vector for the current
|
||||||
|
// host platform, pinned independently of the production switch in
|
||||||
|
// antigravity_acp.go (AC-AGENTS-ANTIGRAVITY-ACP-003.1/.2).
|
||||||
|
func wantAntigravityArgv() []string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return []string{"agy_acp_server.exe"}
|
||||||
|
case "linux":
|
||||||
|
return []string{"agy_acp_server.par", "--uid="}
|
||||||
|
default:
|
||||||
|
return []string{"agy_acp_server.par"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantAntigravityBinaryName() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "agy_acp_server.exe"
|
||||||
|
}
|
||||||
|
return "agy_acp_server.par"
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantAntigravityHarnessNames() []string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return []string{"localharness_external.exe", "localharness.exe"}
|
||||||
|
}
|
||||||
|
return []string{"localharness_external", "localharness"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_IDAndDisplay(t *testing.T) {
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
if got := a.ID(); got != "antigravity-acp" {
|
||||||
|
t.Errorf("ID() = %q, want antigravity-acp", got)
|
||||||
|
}
|
||||||
|
if got := a.Name(); got != "Antigravity ACP Agent" {
|
||||||
|
t.Errorf("Name() = %q, want %q", got, "Antigravity ACP Agent")
|
||||||
|
}
|
||||||
|
if got := a.DisplayName(); got != "Antigravity" {
|
||||||
|
t.Errorf("DisplayName() = %q, want Antigravity", got)
|
||||||
|
}
|
||||||
|
if !a.Enabled() {
|
||||||
|
t.Error("Enabled() = false, want true")
|
||||||
|
}
|
||||||
|
if got := a.DisplayOrder(); got != 22 {
|
||||||
|
t.Errorf("DisplayOrder() = %d, want 22", got)
|
||||||
|
}
|
||||||
|
if agent.ProtocolACP != a.Runtime().Protocol {
|
||||||
|
t.Errorf("Runtime.Protocol = %q, want ACP", a.Runtime().Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntigravityACP_Description covers AC-AGENTS-ANTIGRAVITY-ACP-001.7: the
|
||||||
|
// description must carry install guidance (registry entry, the two archive
|
||||||
|
// entries extracting into one directory, PATH, platform executable name)
|
||||||
|
// since InstallScript() is empty, and must not embed a rotating archive URL
|
||||||
|
// or build stamp.
|
||||||
|
func TestAntigravityACP_Description(t *testing.T) {
|
||||||
|
desc := NewAntigravityACP().Description()
|
||||||
|
for _, want := range []string{"antigravity-acp", "PATH", "agy_acp_server.par", "agy_acp_server.exe"} {
|
||||||
|
if !strings.Contains(desc, want) {
|
||||||
|
t.Errorf("Description() = %q, want it to contain %q", desc, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(desc, "http") {
|
||||||
|
t.Errorf("Description() = %q, must not embed an archive URL", desc)
|
||||||
|
}
|
||||||
|
if strings.Contains(desc, "RC01") || strings.Contains(desc, "20260818") {
|
||||||
|
t.Errorf("Description() = %q, must not embed a rotating build stamp", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_NoCLIPassthrough(t *testing.T) {
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
if _, ok := any(a).(PassthroughAgent); ok {
|
||||||
|
t.Error("AntigravityACP must not implement PassthroughAgent (AC-AGENTS-ANTIGRAVITY-ACP-001.9)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntigravityACP_ArgvIsPlatformSpecific covers
|
||||||
|
// AC-AGENTS-ANTIGRAVITY-ACP-003.1, .2, .3, .4, .5, .6, .7 for the host
|
||||||
|
// platform running this test.
|
||||||
|
func TestAntigravityACP_ArgvIsPlatformSpecific(t *testing.T) {
|
||||||
|
want := wantAntigravityArgv()
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
|
||||||
|
assertArgvEqual(t, "BuildCommand", a.BuildCommand(CommandOptions{}).Args(), want)
|
||||||
|
assertArgvEqual(t, "BuildCommand (repeat)", a.BuildCommand(CommandOptions{}).Args(), want)
|
||||||
|
|
||||||
|
rt := a.Runtime()
|
||||||
|
if rt == nil {
|
||||||
|
t.Fatal("Runtime() returned nil")
|
||||||
|
}
|
||||||
|
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), want)
|
||||||
|
|
||||||
|
ic := a.InferenceConfig()
|
||||||
|
if ic == nil || !ic.Supported {
|
||||||
|
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
|
||||||
|
}
|
||||||
|
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), want)
|
||||||
|
|
||||||
|
// AC-003.6: argv must not vary with model, permission policy,
|
||||||
|
// auto-approve, agent type, or resume target.
|
||||||
|
varied := CommandOptions{
|
||||||
|
Model: "gemini-3-pro",
|
||||||
|
SessionID: "sess-123",
|
||||||
|
AutoApprove: true,
|
||||||
|
PermissionPolicy: "supervised",
|
||||||
|
AgentType: "task",
|
||||||
|
}
|
||||||
|
assertArgvEqual(t, "BuildCommand with varied opts", a.BuildCommand(varied).Args(), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_RuntimeConfig(t *testing.T) {
|
||||||
|
rt := NewAntigravityACP().Runtime()
|
||||||
|
if rt.WorkingDir != "{workspace}" {
|
||||||
|
t.Errorf("WorkingDir = %q, want {workspace}", rt.WorkingDir)
|
||||||
|
}
|
||||||
|
if len(rt.Env) != 0 {
|
||||||
|
t.Errorf("Env = %#v, want empty (inherit execution environment unmodified)", rt.Env)
|
||||||
|
}
|
||||||
|
if len(rt.StripEnv) != 0 {
|
||||||
|
t.Errorf("StripEnv = %#v, want empty", rt.StripEnv)
|
||||||
|
}
|
||||||
|
if rt.ResourceLimits.MemoryMB != 4096 || rt.ResourceLimits.CPUCores != 2.0 || rt.ResourceLimits.Timeout != time.Hour {
|
||||||
|
t.Errorf("ResourceLimits = %+v, want {4096, 2.0, 1h}", rt.ResourceLimits)
|
||||||
|
}
|
||||||
|
if rt.RequiresProcessKill {
|
||||||
|
t.Error("RequiresProcessKill = true, want false (no immediate kill on shutdown)")
|
||||||
|
}
|
||||||
|
if rt.ProjectSkillDir != ".agents/skills" {
|
||||||
|
t.Errorf("ProjectSkillDir = %q, want .agents/skills", rt.ProjectSkillDir)
|
||||||
|
}
|
||||||
|
if rt.UserSkillDir != ".gemini/config/skills" {
|
||||||
|
t.Errorf("UserSkillDir = %q, want .gemini/config/skills", rt.UserSkillDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := rt.SessionConfig
|
||||||
|
if !sc.NativeSessionResume {
|
||||||
|
t.Error("NativeSessionResume = false, want true")
|
||||||
|
}
|
||||||
|
if sc.CanRecover == nil || !*sc.CanRecover {
|
||||||
|
t.Error("CanRecover must be true")
|
||||||
|
}
|
||||||
|
if sc.SessionDirTemplate != "{home}/.gemini/antigravity-acp" {
|
||||||
|
t.Errorf("SessionDirTemplate = %q, want {home}/.gemini/antigravity-acp", sc.SessionDirTemplate)
|
||||||
|
}
|
||||||
|
if sc.SessionDirTarget != "" {
|
||||||
|
t.Errorf("SessionDirTarget = %q, want empty (no container bind mount, AC-004.10)", sc.SessionDirTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_InstallScriptEmpty(t *testing.T) {
|
||||||
|
got := NewAntigravityACP().InstallScript()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("InstallScript() = %q, want empty string (AC-AGENTS-ANTIGRAVITY-ACP-007.1)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_RemoteAuth(t *testing.T) {
|
||||||
|
auth := NewAntigravityACP().RemoteAuth()
|
||||||
|
if auth == nil {
|
||||||
|
t.Fatal("RemoteAuth() returned nil")
|
||||||
|
}
|
||||||
|
if len(auth.Methods) != 3 {
|
||||||
|
t.Fatalf("Methods len = %d, want 3", len(auth.Methods))
|
||||||
|
}
|
||||||
|
|
||||||
|
files := auth.Methods[0]
|
||||||
|
if files.Type != "files" {
|
||||||
|
t.Errorf("Methods[0].Type = %q, want files", files.Type)
|
||||||
|
}
|
||||||
|
if files.TargetRelDir != ".gemini/antigravity-acp" {
|
||||||
|
t.Errorf("TargetRelDir = %q, want .gemini/antigravity-acp", files.TargetRelDir)
|
||||||
|
}
|
||||||
|
wantFiles := []string{
|
||||||
|
".gemini/antigravity-acp/settings.json",
|
||||||
|
".gemini/antigravity-acp/acp_token.json",
|
||||||
|
".gemini/antigravity-acp/acp_business_token.json",
|
||||||
|
}
|
||||||
|
for _, osName := range []string{"darwin", "linux"} {
|
||||||
|
paths := files.SourceFiles[osName]
|
||||||
|
if !slices.Equal(paths, wantFiles) {
|
||||||
|
t.Errorf("SourceFiles[%s] = %#v, want %#v", osName, paths, wantFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env1 := auth.Methods[1]
|
||||||
|
if env1.Type != "env" || env1.EnvVar != "GEMINI_API_KEY" {
|
||||||
|
t.Errorf("Methods[1] = %+v, want env GEMINI_API_KEY", env1)
|
||||||
|
}
|
||||||
|
env2 := auth.Methods[2]
|
||||||
|
if env2.Type != "env" || env2.EnvVar != "GOOGLE_API_KEY" {
|
||||||
|
t.Errorf("Methods[2] = %+v, want env GOOGLE_API_KEY", env2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_LogosNonEmpty(t *testing.T) {
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
if len(a.Logo(LogoLight)) == 0 {
|
||||||
|
t.Error("Logo(LogoLight) is empty")
|
||||||
|
}
|
||||||
|
if len(a.Logo(LogoDark)) == 0 {
|
||||||
|
t.Error("Logo(LogoDark) is empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(a.Logo(LogoLight)), "<svg") {
|
||||||
|
t.Error("Logo(LogoLight) is not SVG")
|
||||||
|
}
|
||||||
|
// Any other variant falls back to the light logo (AC-001.5).
|
||||||
|
if !slices.Equal(a.Logo(LogoVariant(99)), a.Logo(LogoLight)) {
|
||||||
|
t.Error("unknown LogoVariant must fall back to the light logo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DisplayOrderUnique(t *testing.T) {
|
||||||
|
all := []Agent{
|
||||||
|
NewClaudeACP(), NewCodexACP(), NewAuggie(), NewOpenCodeACP(),
|
||||||
|
NewGemini(), NewCopilotACP(), NewAmpACP(), NewQwenACP(),
|
||||||
|
NewIFlowACP(), NewDroidACP(), NewKilocodeACP(), NewPiACP(),
|
||||||
|
NewCursorACP(), NewKimiACP(), NewKiroACP(), NewQoderACP(),
|
||||||
|
NewTraeACP(), NewOmpACP(), NewDevinACP(), NewGrokACP(),
|
||||||
|
NewHermesACP(), NewAntigravityACP(), NewMockAgent(),
|
||||||
|
}
|
||||||
|
seen := map[int]string{}
|
||||||
|
for _, ag := range all {
|
||||||
|
order := ag.DisplayOrder()
|
||||||
|
if other, exists := seen[order]; exists {
|
||||||
|
t.Errorf("DisplayOrder %d collision: %s and %s", order, other, ag.ID())
|
||||||
|
}
|
||||||
|
seen[order] = ag.ID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_PermissionAndBillingDefaults(t *testing.T) {
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
if len(a.PermissionSettings()) != 0 {
|
||||||
|
t.Errorf("PermissionSettings() = %#v, want empty", a.PermissionSettings())
|
||||||
|
}
|
||||||
|
if got := a.BillingType(); got != usage.BillingTypeAPIKey {
|
||||||
|
t.Errorf("BillingType() = %q, want %q", got, usage.BillingTypeAPIKey)
|
||||||
|
}
|
||||||
|
catalog := CatalogPermissionSettings(a)
|
||||||
|
auto, ok := catalog[PermissionKeyAutoApprove]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("catalog missing auto_approve")
|
||||||
|
}
|
||||||
|
if auto.ApplyMethod != PermissionApplyMethodAgentctlAutoApprove {
|
||||||
|
t.Errorf("auto_approve ApplyMethod = %q, want agentctl auto-approve", auto.ApplyMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Discovery / harness resolution (REQ-AGENTS-ANTIGRAVITY-ACP-002) ---
|
||||||
|
|
||||||
|
// writeFakeAntigravityBinary creates an empty regular file named for the
|
||||||
|
// host platform's Antigravity binary in dir, executable on unix.
|
||||||
|
func writeFakeAntigravityBinary(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, wantAntigravityBinaryName())
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake binary: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFakeHarness(t *testing.T, dir, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("harness"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake harness %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryRequiresGlobalBinary(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Available {
|
||||||
|
t.Error("Available=true without the binary on PATH")
|
||||||
|
}
|
||||||
|
if result.MatchedPath != "" {
|
||||||
|
t.Errorf("MatchedPath = %q, want empty", result.MatchedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryFailsClosedWithoutHarness(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
path := writeFakeAntigravityBinary(t, binDir)
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
t.Setenv("ANTIGRAVITY_HARNESS_PATH", "")
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Available {
|
||||||
|
t.Errorf("Available=true with binary present but no harness sibling at %s", path)
|
||||||
|
}
|
||||||
|
if result.MatchedPath != "" {
|
||||||
|
t.Errorf("MatchedPath = %q, want empty (AC-002.5 uses the AC-002.2 outcome)", result.MatchedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryAvailableWithHarnessSibling(t *testing.T) {
|
||||||
|
for _, harnessName := range wantAntigravityHarnessNames() {
|
||||||
|
t.Run(harnessName, func(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
path := writeFakeAntigravityBinary(t, binDir)
|
||||||
|
writeFakeHarness(t, binDir, harnessName)
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Available {
|
||||||
|
t.Fatal("Available=false with binary and harness both present")
|
||||||
|
}
|
||||||
|
resolved, resolveErr := filepath.EvalSymlinks(path)
|
||||||
|
if resolveErr != nil {
|
||||||
|
resolved = path
|
||||||
|
}
|
||||||
|
gotResolved, gotErr := filepath.EvalSymlinks(result.MatchedPath)
|
||||||
|
if gotErr != nil {
|
||||||
|
gotResolved = result.MatchedPath
|
||||||
|
}
|
||||||
|
if gotResolved != resolved {
|
||||||
|
t.Errorf("MatchedPath = %q, want %q", result.MatchedPath, path)
|
||||||
|
}
|
||||||
|
if !result.SupportsMCP {
|
||||||
|
t.Error("SupportsMCP = false, want true")
|
||||||
|
}
|
||||||
|
if !result.Capabilities.SupportsSessionResume {
|
||||||
|
t.Error("SupportsSessionResume = false, want true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryHarnessMustBeRegularFile(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeAntigravityBinary(t, binDir)
|
||||||
|
// A directory named like the harness must not count as a match.
|
||||||
|
if err := os.Mkdir(filepath.Join(binDir, wantAntigravityHarnessNames()[0]), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir fake harness dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
t.Setenv("ANTIGRAVITY_HARNESS_PATH", "")
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Available {
|
||||||
|
t.Error("Available=true when the harness name resolves to a directory, not a regular file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryRejectsNonExecutableHarness(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix execute bits do not control windows executability")
|
||||||
|
}
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeAntigravityBinary(t, binDir)
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(binDir, wantAntigravityHarnessNames()[0]),
|
||||||
|
[]byte("harness"),
|
||||||
|
0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write non-executable fake harness: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
t.Setenv("ANTIGRAVITY_HARNESS_PATH", "")
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Available {
|
||||||
|
t.Error("Available=true when the harness file has no execute permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryHarnessDanglingSymlinkDoesNotMatch(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlink creation needs elevated privileges on windows")
|
||||||
|
}
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeAntigravityBinary(t, binDir)
|
||||||
|
harnessPath := filepath.Join(binDir, wantAntigravityHarnessNames()[0])
|
||||||
|
if err := os.Symlink(filepath.Join(binDir, "does-not-exist"), harnessPath); err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
t.Setenv("ANTIGRAVITY_HARNESS_PATH", "")
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Available {
|
||||||
|
t.Error("Available=true with a dangling symlink standing in for the harness")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryHarnessPathEnvBypassesCheck(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeAntigravityBinary(t, binDir)
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
t.Setenv("ANTIGRAVITY_HARNESS_PATH", filepath.Join(t.TempDir(), "harness-elsewhere"))
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Available {
|
||||||
|
t.Error("Available=false even though ANTIGRAVITY_HARNESS_PATH satisfies the harness check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryContextCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
result, err := NewAntigravityACP().IsInstalled(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("IsInstalled with a cancelled context returned no error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if result != nil && result.Available {
|
||||||
|
t.Error("a cancelled discovery must not report the agent as available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityACP_DiscoveryDeterministic(t *testing.T) {
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeAntigravityBinary(t, binDir)
|
||||||
|
writeFakeHarness(t, binDir, wantAntigravityHarnessNames()[0])
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
a := NewAntigravityACP()
|
||||||
|
first, err := a.IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
second, err := a.IsInstalled(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second IsInstalled error: %v", err)
|
||||||
|
}
|
||||||
|
if first.Available != second.Available || first.MatchedPath != second.MatchedPath {
|
||||||
|
t.Errorf("repeated calls diverged: %+v vs %+v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Antigravity</title><path d="M12 1.5l3.5 6.5H21l-4.5 5 1.75 7.5L12 16.75 5.75 20.5 7.5 13 3 8h5.5L12 1.5z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Antigravity</title><path d="M12 1.5l3.5 6.5H21l-4.5 5 1.75 7.5L12 16.75 5.75 20.5 7.5 13 3 8h5.5L12 1.5z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
@@ -86,6 +86,7 @@ func (r *Registry) LoadDefaults() {
|
|||||||
agents.NewDevinACP(),
|
agents.NewDevinACP(),
|
||||||
agents.NewGrokACP(),
|
agents.NewGrokACP(),
|
||||||
agents.NewHermesACP(),
|
agents.NewHermesACP(),
|
||||||
|
agents.NewAntigravityACP(),
|
||||||
agents.NewMockAgent(),
|
agents.NewMockAgent(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1183,19 +1183,21 @@ func derefString(p *string) string {
|
|||||||
// is not derived from untrusted input — even though the value is
|
// is not derived from untrusted input — even though the value is
|
||||||
// semantically the same as the base name taken from InferenceConfig.Command.
|
// semantically the same as the base name taken from InferenceConfig.Command.
|
||||||
var allowedProbeCommands = map[string]string{
|
var allowedProbeCommands = map[string]string{
|
||||||
"auggie": "auggie",
|
"agy_acp_server.par": "agy_acp_server.par",
|
||||||
"cursor-agent": "cursor-agent",
|
"agy_acp_server.exe": "agy_acp_server.exe",
|
||||||
"devin": "devin",
|
"auggie": "auggie",
|
||||||
"grok": "grok",
|
"cursor-agent": "cursor-agent",
|
||||||
"hermes": "hermes",
|
"devin": "devin",
|
||||||
"kimi": "kimi",
|
"grok": "grok",
|
||||||
"kiro-cli-chat": "kiro-cli-chat",
|
"hermes": "hermes",
|
||||||
"mock-agent": "mock-agent",
|
"kimi": "kimi",
|
||||||
"npx": "npx",
|
"kiro-cli-chat": "kiro-cli-chat",
|
||||||
"omp": "omp",
|
"mock-agent": "mock-agent",
|
||||||
openCodeCommand: openCodeCommand,
|
"npx": "npx",
|
||||||
"qodercli": "qodercli",
|
"omp": "omp",
|
||||||
"traecli": "traecli",
|
openCodeCommand: openCodeCommand,
|
||||||
|
"qodercli": "qodercli",
|
||||||
|
"traecli": "traecli",
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveProbeCommand validates and returns a hard-coded executable name for
|
// resolveProbeCommand validates and returns a hard-coded executable name for
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ type AuthSectionProps = {
|
|||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
baselineSelectedIds: Set<string>;
|
baselineSelectedIds: Set<string>;
|
||||||
onCredentialsChange: (ids: string[]) => void;
|
onCredentialsChange: (ids: string[]) => void;
|
||||||
envSecretId: string | null;
|
agentEnvVars: Record<string, string | null>;
|
||||||
baselineEnvSecretId: string | null;
|
baselineAgentEnvVars: Record<string, string | null>;
|
||||||
onMethodSecretChange: (methodId: string, secretId: string | null) => void;
|
onMethodSecretChange: (methodId: string, secretId: string | null) => void;
|
||||||
secrets: SecretListItem[];
|
secrets: SecretListItem[];
|
||||||
configBundles: AgentConfigBundle[];
|
configBundles: AgentConfigBundle[];
|
||||||
@@ -44,8 +44,8 @@ export function AuthSection({
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
baselineSelectedIds,
|
baselineSelectedIds,
|
||||||
onCredentialsChange,
|
onCredentialsChange,
|
||||||
envSecretId,
|
agentEnvVars,
|
||||||
baselineEnvSecretId,
|
baselineAgentEnvVars,
|
||||||
onMethodSecretChange,
|
onMethodSecretChange,
|
||||||
secrets,
|
secrets,
|
||||||
configBundles,
|
configBundles,
|
||||||
@@ -55,18 +55,25 @@ export function AuthSection({
|
|||||||
isSSH,
|
isSSH,
|
||||||
}: AuthSectionProps) {
|
}: AuthSectionProps) {
|
||||||
const methods = getSpecMethods(spec);
|
const methods = getSpecMethods(spec);
|
||||||
const envMethod = methods.find((m) => m.type === "env");
|
const envMethods = methods.filter((m) => m.type === "env");
|
||||||
|
const envMethodIds = new Set(envMethods.map((m) => m.method_id));
|
||||||
const fileMethod = methods.find((m) => m.type === "files");
|
const fileMethod = methods.find((m) => m.type === "files");
|
||||||
const fileSelected = fileMethod ? selectedIds.has(fileMethod.method_id) : false;
|
const fileSelected = fileMethod ? selectedIds.has(fileMethod.method_id) : false;
|
||||||
const envSelected = Boolean(envMethod && (selectedIds.has(envMethod.method_id) || envSecretId));
|
|
||||||
const baselineFileSelected = fileMethod ? baselineSelectedIds.has(fileMethod.method_id) : false;
|
const baselineFileSelected = fileMethod ? baselineSelectedIds.has(fileMethod.method_id) : false;
|
||||||
const baselineEnvSelected = Boolean(
|
const isEnvSelected = (
|
||||||
envMethod && (baselineSelectedIds.has(envMethod.method_id) || baselineEnvSecretId),
|
m: RemoteAuthMethod,
|
||||||
|
ids: Set<string>,
|
||||||
|
envVars: Record<string, string | null>,
|
||||||
|
) => ids.has(m.method_id) || Boolean(envVars[m.method_id]);
|
||||||
|
const anyEnvSelected = envMethods.some((m) => isEnvSelected(m, selectedIds, agentEnvVars));
|
||||||
|
const anyEnvHasSecret = envMethods.some((m) => Boolean(agentEnvVars[m.method_id]));
|
||||||
|
const envDirty = envMethods.some(
|
||||||
|
(m) =>
|
||||||
|
isEnvSelected(m, selectedIds, agentEnvVars) !==
|
||||||
|
isEnvSelected(m, baselineSelectedIds, baselineAgentEnvVars) ||
|
||||||
|
(agentEnvVars[m.method_id] ?? null) !== (baselineAgentEnvVars[m.method_id] ?? null),
|
||||||
);
|
);
|
||||||
const isDirty =
|
const isDirty = fileSelected !== baselineFileSelected || envDirty;
|
||||||
fileSelected !== baselineFileSelected ||
|
|
||||||
envSelected !== baselineEnvSelected ||
|
|
||||||
envSecretId !== baselineEnvSecretId;
|
|
||||||
const configIsDirty = configBundles.some(
|
const configIsDirty = configBundles.some(
|
||||||
(bundle) => configBundleIds.includes(bundle.id) !== baselineConfigBundleIds.includes(bundle.id),
|
(bundle) => configBundleIds.includes(bundle.id) !== baselineConfigBundleIds.includes(bundle.id),
|
||||||
);
|
);
|
||||||
@@ -74,7 +81,7 @@ export function AuthSection({
|
|||||||
const handleMethodToggle = (methodId: string, checked: boolean) => {
|
const handleMethodToggle = (methodId: string, checked: boolean) => {
|
||||||
const nextSelectedIds = new Set(selectedIds);
|
const nextSelectedIds = new Set(selectedIds);
|
||||||
setMethodSelected(nextSelectedIds, methodId, checked);
|
setMethodSelected(nextSelectedIds, methodId, checked);
|
||||||
if (envMethod?.method_id === methodId && !checked) {
|
if (envMethodIds.has(methodId) && !checked) {
|
||||||
onMethodSecretChange(methodId, null);
|
onMethodSecretChange(methodId, null);
|
||||||
}
|
}
|
||||||
onCredentialsChange([...nextSelectedIds]);
|
onCredentialsChange([...nextSelectedIds]);
|
||||||
@@ -88,8 +95,8 @@ export function AuthSection({
|
|||||||
<span className="font-medium text-sm">{spec.display_name}</span>
|
<span className="font-medium text-sm">{spec.display_name}</span>
|
||||||
<AuthStatusBadge
|
<AuthStatusBadge
|
||||||
fileSelected={fileSelected}
|
fileSelected={fileSelected}
|
||||||
envSelected={envSelected}
|
envSelected={anyEnvSelected}
|
||||||
hasSecret={!!envSecretId}
|
hasSecret={anyEnvHasSecret}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
@@ -97,17 +104,15 @@ export function AuthSection({
|
|||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<AuthOptions
|
<AuthOptions
|
||||||
fileMethod={fileMethod}
|
fileMethod={fileMethod}
|
||||||
envMethod={envMethod}
|
envMethods={envMethods}
|
||||||
fileSelected={fileSelected}
|
fileSelected={fileSelected}
|
||||||
envSelected={envSelected}
|
|
||||||
baselineFileSelected={baselineFileSelected}
|
baselineFileSelected={baselineFileSelected}
|
||||||
baselineEnvSelected={baselineEnvSelected}
|
agentEnvVars={agentEnvVars}
|
||||||
secretId={envSecretId}
|
baselineAgentEnvVars={baselineAgentEnvVars}
|
||||||
baselineSecretId={baselineEnvSecretId}
|
selectedIds={selectedIds}
|
||||||
|
baselineSelectedIds={baselineSelectedIds}
|
||||||
onMethodToggle={handleMethodToggle}
|
onMethodToggle={handleMethodToggle}
|
||||||
onSecretIdChange={(sid) => {
|
onSecretIdChange={(methodId, sid) => onMethodSecretChange(methodId, sid)}
|
||||||
if (envMethod) onMethodSecretChange(envMethod.method_id, sid);
|
|
||||||
}}
|
|
||||||
secrets={secrets}
|
secrets={secrets}
|
||||||
/>
|
/>
|
||||||
{configBundles.length > 0 && (
|
{configBundles.length > 0 && (
|
||||||
@@ -128,30 +133,31 @@ export function AuthSection({
|
|||||||
|
|
||||||
function AuthOptions({
|
function AuthOptions({
|
||||||
fileMethod,
|
fileMethod,
|
||||||
envMethod,
|
envMethods,
|
||||||
fileSelected,
|
fileSelected,
|
||||||
envSelected,
|
|
||||||
baselineFileSelected,
|
baselineFileSelected,
|
||||||
baselineEnvSelected,
|
agentEnvVars,
|
||||||
secretId,
|
baselineAgentEnvVars,
|
||||||
baselineSecretId,
|
selectedIds,
|
||||||
|
baselineSelectedIds,
|
||||||
onMethodToggle,
|
onMethodToggle,
|
||||||
onSecretIdChange,
|
onSecretIdChange,
|
||||||
secrets,
|
secrets,
|
||||||
}: {
|
}: {
|
||||||
fileMethod?: RemoteAuthMethod;
|
fileMethod?: RemoteAuthMethod;
|
||||||
envMethod?: RemoteAuthMethod;
|
envMethods: RemoteAuthMethod[];
|
||||||
fileSelected: boolean;
|
fileSelected: boolean;
|
||||||
envSelected: boolean;
|
|
||||||
baselineFileSelected: boolean;
|
baselineFileSelected: boolean;
|
||||||
baselineEnvSelected: boolean;
|
agentEnvVars: Record<string, string | null>;
|
||||||
secretId: string | null;
|
baselineAgentEnvVars: Record<string, string | null>;
|
||||||
baselineSecretId: string | null;
|
selectedIds: Set<string>;
|
||||||
|
baselineSelectedIds: Set<string>;
|
||||||
onMethodToggle: (methodId: string, checked: boolean) => void;
|
onMethodToggle: (methodId: string, checked: boolean) => void;
|
||||||
onSecretIdChange: (id: string | null) => void;
|
onSecretIdChange: (methodId: string, id: string | null) => void;
|
||||||
secrets: SecretListItem[];
|
secrets: SecretListItem[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showEnvVarInLabel = envMethods.length > 1;
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-label={t("executors:remoteAuthMethod")} className="grid gap-2">
|
<div role="group" aria-label={t("executors:remoteAuthMethod")} className="grid gap-2">
|
||||||
{fileMethod && (
|
{fileMethod && (
|
||||||
@@ -163,18 +169,29 @@ function AuthOptions({
|
|||||||
onCheckedChange={(checked) => onMethodToggle(fileMethod.method_id, checked)}
|
onCheckedChange={(checked) => onMethodToggle(fileMethod.method_id, checked)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{envMethod?.env_var && (
|
{envMethods
|
||||||
<EnvOption
|
.filter((method) => method.env_var)
|
||||||
method={envMethod}
|
.map((method) => {
|
||||||
isSelected={envSelected}
|
const secretId = agentEnvVars[method.method_id] ?? null;
|
||||||
isDirty={envSelected !== baselineEnvSelected || secretId !== baselineSecretId}
|
const baselineSecretId = baselineAgentEnvVars[method.method_id] ?? null;
|
||||||
secretId={secretId}
|
const isSelected = selectedIds.has(method.method_id) || Boolean(secretId);
|
||||||
baselineSecretId={baselineSecretId}
|
const baselineSelected =
|
||||||
onSecretIdChange={onSecretIdChange}
|
baselineSelectedIds.has(method.method_id) || Boolean(baselineSecretId);
|
||||||
secrets={secrets}
|
return (
|
||||||
onCheckedChange={(checked) => onMethodToggle(envMethod.method_id, checked)}
|
<EnvOption
|
||||||
/>
|
key={method.method_id}
|
||||||
)}
|
method={method}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDirty={isSelected !== baselineSelected || secretId !== baselineSecretId}
|
||||||
|
secretId={secretId}
|
||||||
|
baselineSecretId={baselineSecretId}
|
||||||
|
showEnvVarInLabel={showEnvVarInLabel}
|
||||||
|
onSecretIdChange={(sid) => onSecretIdChange(method.method_id, sid)}
|
||||||
|
secrets={secrets}
|
||||||
|
onCheckedChange={(checked) => onMethodToggle(method.method_id, checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -219,6 +236,7 @@ function EnvOption({
|
|||||||
isDirty,
|
isDirty,
|
||||||
secretId,
|
secretId,
|
||||||
baselineSecretId,
|
baselineSecretId,
|
||||||
|
showEnvVarInLabel,
|
||||||
onSecretIdChange,
|
onSecretIdChange,
|
||||||
secrets,
|
secrets,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
@@ -228,21 +246,25 @@ function EnvOption({
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
secretId: string | null;
|
secretId: string | null;
|
||||||
baselineSecretId: string | null;
|
baselineSecretId: string | null;
|
||||||
|
showEnvVarInLabel: boolean;
|
||||||
onSecretIdChange: (id: string | null) => void;
|
onSecretIdChange: (id: string | null) => void;
|
||||||
secrets: SecretListItem[];
|
secrets: SecretListItem[];
|
||||||
onCheckedChange: (checked: boolean) => void;
|
onCheckedChange: (checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const label = showEnvVarInLabel
|
||||||
|
? t("executors:provideSecretForEnvVar", { envVar: method.env_var })
|
||||||
|
: t("executors:provideSecret");
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AuthOption
|
<AuthOption
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
isDirty={isDirty}
|
isDirty={isDirty}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
label={t("executors:provideSecret")}
|
label={label}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-sm font-medium">{t("executors:provideSecret")}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
<Trans i18nKey="executors:setEnvVarViaStoredSecret" values={{ envVar: method.env_var }}>
|
<Trans i18nKey="executors:setEnvVarViaStoredSecret" values={{ envVar: method.env_var }}>
|
||||||
Set <code className="text-[11px] bg-muted px-1 rounded">{method.env_var}</code> via a
|
Set <code className="text-[11px] bg-muted px-1 rounded">{method.env_var}</code> via a
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RemoteCredentialsCard } from "./remote-credentials-card";
|
|||||||
import { AgentConfigOptions } from "./portable-config-bundles";
|
import { AgentConfigOptions } from "./portable-config-bundles";
|
||||||
import { listRemoteCredentials } from "@/lib/api/domains/settings-api";
|
import { listRemoteCredentials } from "@/lib/api/domains/settings-api";
|
||||||
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||||
|
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||||
|
|
||||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||||
listRemoteCredentials: vi.fn(),
|
listRemoteCredentials: vi.fn(),
|
||||||
@@ -29,12 +30,20 @@ function renderRemoteCredentialsCard(
|
|||||||
onChange = vi.fn(),
|
onChange = vi.fn(),
|
||||||
onConfigChange = vi.fn(),
|
onConfigChange = vi.fn(),
|
||||||
initialConfigBundleIds: string[] = [],
|
initialConfigBundleIds: string[] = [],
|
||||||
|
options: {
|
||||||
|
agentEnvVars?: Record<string, string | null>;
|
||||||
|
onAgentEnvVarChange?: (methodId: string, secretId: string | null) => void;
|
||||||
|
secrets?: SecretListItem[];
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
render(
|
render(
|
||||||
<RemoteCredentialsHarness
|
<RemoteCredentialsHarness
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onConfigChange={onConfigChange}
|
onConfigChange={onConfigChange}
|
||||||
initialConfigBundleIds={initialConfigBundleIds}
|
initialConfigBundleIds={initialConfigBundleIds}
|
||||||
|
agentEnvVars={options.agentEnvVars}
|
||||||
|
onAgentEnvVarChange={options.onAgentEnvVarChange}
|
||||||
|
secrets={options.secrets}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,13 +52,20 @@ function RemoteCredentialsHarness({
|
|||||||
onChange,
|
onChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
initialConfigBundleIds,
|
initialConfigBundleIds,
|
||||||
|
agentEnvVars: initialAgentEnvVars = {},
|
||||||
|
onAgentEnvVarChange: onEnvVarChange = () => {},
|
||||||
|
secrets = [],
|
||||||
}: {
|
}: {
|
||||||
onChange: (ids: string[]) => void;
|
onChange: (ids: string[]) => void;
|
||||||
onConfigChange: (ids: string[]) => void;
|
onConfigChange: (ids: string[]) => void;
|
||||||
initialConfigBundleIds: string[];
|
initialConfigBundleIds: string[];
|
||||||
|
agentEnvVars?: Record<string, string | null>;
|
||||||
|
onAgentEnvVarChange?: (methodId: string, secretId: string | null) => void;
|
||||||
|
secrets?: SecretListItem[];
|
||||||
}) {
|
}) {
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [configBundleIds, setConfigBundleIds] = useState(initialConfigBundleIds);
|
const [configBundleIds, setConfigBundleIds] = useState(initialConfigBundleIds);
|
||||||
|
const [agentEnvVars, setAgentEnvVars] = useState(initialAgentEnvVars);
|
||||||
return (
|
return (
|
||||||
<RemoteCredentialsCard
|
<RemoteCredentialsCard
|
||||||
isRemote={true}
|
isRemote={true}
|
||||||
@@ -63,9 +79,12 @@ function RemoteCredentialsHarness({
|
|||||||
setConfigBundleIds(ids);
|
setConfigBundleIds(ids);
|
||||||
onConfigChange(ids);
|
onConfigChange(ids);
|
||||||
}}
|
}}
|
||||||
agentEnvVars={{}}
|
agentEnvVars={agentEnvVars}
|
||||||
onAgentEnvVarChange={() => {}}
|
onAgentEnvVarChange={(methodId, secretId) => {
|
||||||
secrets={[]}
|
setAgentEnvVars((current) => ({ ...current, [methodId]: secretId }));
|
||||||
|
onEnvVarChange(methodId, secretId);
|
||||||
|
}}
|
||||||
|
secrets={secrets}
|
||||||
gitIdentityMode="override"
|
gitIdentityMode="override"
|
||||||
onGitIdentityModeChange={() => {}}
|
onGitIdentityModeChange={() => {}}
|
||||||
gitUserName=""
|
gitUserName=""
|
||||||
@@ -251,6 +270,77 @@ describe("RemoteCredentialsCard auth selections", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("RemoteCredentialsCard with multiple env auth methods", () => {
|
||||||
|
const antigravityAgentName = "Antigravity";
|
||||||
|
const geminiMethodId = "agent:antigravity-acp:env:GEMINI_API_KEY";
|
||||||
|
const googleMethodId = "agent:antigravity-acp:env:GOOGLE_API_KEY";
|
||||||
|
const secrets: SecretListItem[] = [
|
||||||
|
{
|
||||||
|
id: "secret-gemini",
|
||||||
|
name: "Gemini key",
|
||||||
|
has_value: true,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secret-google",
|
||||||
|
name: "Google key",
|
||||||
|
has_value: true,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listRemoteCredentials).mockResolvedValue({
|
||||||
|
auth_specs: [
|
||||||
|
{
|
||||||
|
id: "antigravity-acp",
|
||||||
|
display_name: antigravityAgentName,
|
||||||
|
methods: [
|
||||||
|
{ method_id: geminiMethodId, type: "env", env_var: "GEMINI_API_KEY" },
|
||||||
|
{ method_id: googleMethodId, type: "env", env_var: "GOOGLE_API_KEY" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets both declared env methods be selected independently", async () => {
|
||||||
|
const onAuthChange = vi.fn();
|
||||||
|
const onEnvVarChange = vi.fn();
|
||||||
|
renderRemoteCredentialsCard(onAuthChange, vi.fn(), [], {
|
||||||
|
onAgentEnvVarChange: onEnvVarChange,
|
||||||
|
secrets,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText(antigravityAgentName));
|
||||||
|
const geminiOption = screen.getByRole("checkbox", {
|
||||||
|
name: "Provide secret for GEMINI_API_KEY",
|
||||||
|
});
|
||||||
|
const googleOption = screen.getByRole("checkbox", {
|
||||||
|
name: "Provide secret for GOOGLE_API_KEY",
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(geminiOption);
|
||||||
|
fireEvent.click(googleOption);
|
||||||
|
|
||||||
|
const secretSelects = await screen.findAllByRole("combobox");
|
||||||
|
expect(secretSelects).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(secretSelects[0]);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Gemini key" }));
|
||||||
|
fireEvent.click(secretSelects[1]);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Google key" }));
|
||||||
|
|
||||||
|
expect(geminiOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||||
|
expect(googleOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
||||||
|
expect(onAuthChange).toHaveBeenLastCalledWith([geminiMethodId, googleMethodId]);
|
||||||
|
expect(onEnvVarChange).toHaveBeenNthCalledWith(1, geminiMethodId, "secret-gemini");
|
||||||
|
expect(onEnvVarChange).toHaveBeenNthCalledWith(2, googleMethodId, "secret-google");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const PORTABLE_BUNDLES: AgentConfigBundle[] = [
|
const PORTABLE_BUNDLES: AgentConfigBundle[] = [
|
||||||
{
|
{
|
||||||
id: "claude.settings",
|
id: "claude.settings",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { listRemoteCredentials, type RemoteAuthSpec } from "@/lib/api/domains/settings-api";
|
import { listRemoteCredentials, type RemoteAuthSpec } from "@/lib/api/domains/settings-api";
|
||||||
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api";
|
||||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||||
import { AuthSection, getSpecMethods } from "./remote-auth-section";
|
import { AuthSection } from "./remote-auth-section";
|
||||||
export type { GitIdentityMode, GitIdentityState } from "./git-identity-fields";
|
export type { GitIdentityMode, GitIdentityState } from "./git-identity-fields";
|
||||||
|
|
||||||
type RemoteCredentialsCardProps = {
|
type RemoteCredentialsCardProps = {
|
||||||
@@ -110,30 +110,24 @@ export function RemoteCredentialsCard({
|
|||||||
localGitIdentity={localGitIdentity}
|
localGitIdentity={localGitIdentity}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{agentSections.map(({ spec, configBundles: agentConfigBundles }) => {
|
{agentSections.map(({ spec, configBundles: agentConfigBundles }) => (
|
||||||
const methods = getSpecMethods(spec);
|
<AuthSection
|
||||||
const envMethod = methods.find((m) => m.type === "env");
|
key={spec.id}
|
||||||
return (
|
spec={spec}
|
||||||
<AuthSection
|
selectedIds={selectedSet}
|
||||||
key={spec.id}
|
baselineSelectedIds={baselineSelectedSet}
|
||||||
spec={spec}
|
onCredentialsChange={onChange}
|
||||||
selectedIds={selectedSet}
|
agentEnvVars={agentEnvVars}
|
||||||
baselineSelectedIds={baselineSelectedSet}
|
baselineAgentEnvVars={baselineAgentEnvVars}
|
||||||
onCredentialsChange={onChange}
|
onMethodSecretChange={onAgentEnvVarChange}
|
||||||
envSecretId={envMethod ? (agentEnvVars[envMethod.method_id] ?? null) : null}
|
secrets={secrets}
|
||||||
baselineEnvSecretId={
|
configBundles={agentConfigBundles}
|
||||||
envMethod ? (baselineAgentEnvVars[envMethod.method_id] ?? null) : null
|
configBundleIds={configBundleIds}
|
||||||
}
|
baselineConfigBundleIds={baselineConfigBundleIds}
|
||||||
onMethodSecretChange={onAgentEnvVarChange}
|
onConfigBundleChange={onConfigBundleChange}
|
||||||
secrets={secrets}
|
isSSH={isSSH}
|
||||||
configBundles={agentConfigBundles}
|
/>
|
||||||
configBundleIds={configBundleIds}
|
))}
|
||||||
baselineConfigBundleIds={baselineConfigBundleIds}
|
|
||||||
onConfigBundleChange={onConfigBundleChange}
|
|
||||||
isSSH={isSSH}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"portableConfigNotFoundHint": "This is an editing hint. You can save the profile and add the file later.",
|
"portableConfigNotFoundHint": "This is an editing hint. You can save the profile and add the file later.",
|
||||||
"portableConfigTitle": "Agent configuration",
|
"portableConfigTitle": "Agent configuration",
|
||||||
"provideSecret": "Provide secret",
|
"provideSecret": "Provide secret",
|
||||||
|
"provideSecretForEnvVar": "Provide secret for {{envVar}}",
|
||||||
"quickPresets": "Quick presets",
|
"quickPresets": "Quick presets",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"relatedDockerContainersRemoved_one": "{{count}} related Docker container will also be removed.",
|
"relatedDockerContainersRemoved_one": "{{count}} related Docker container will also be removed.",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"portableConfigNotFoundHint": "Ţĥĩś ĩś àń ēďĩţĩńĝ ĥĩńţ. Ŷōũ ćàń śàvē ţĥē ƥŕōƒĩĺē àńď àďď ţĥē ƒĩĺē ĺàţēŕ.",
|
"portableConfigNotFoundHint": "Ţĥĩś ĩś àń ēďĩţĩńĝ ĥĩńţ. Ŷōũ ćàń śàvē ţĥē ƥŕōƒĩĺē àńď àďď ţĥē ƒĩĺē ĺàţēŕ.",
|
||||||
"portableConfigTitle": "Àĝēńţ ćōńƒĩĝũŕàţĩōń",
|
"portableConfigTitle": "Àĝēńţ ćōńƒĩĝũŕàţĩōń",
|
||||||
"provideSecret": "Ƥŕōvĩďē śēćŕēţ",
|
"provideSecret": "Ƥŕōvĩďē śēćŕēţ",
|
||||||
|
"provideSecretForEnvVar": "Ƥŕōvĩďē śēćŕēţ ƒōŕ {{envVar}}",
|
||||||
"quickPresets": "Qũĩćķ ƥŕēśēţś",
|
"quickPresets": "Qũĩćķ ƥŕēśēţś",
|
||||||
"refresh": "Ŕēƒŕēśĥ",
|
"refresh": "Ŕēƒŕēśĥ",
|
||||||
"relatedDockerContainersRemoved_one": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
|
"relatedDockerContainersRemoved_one": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
"profileSaved": "Perfil guardado",
|
"profileSaved": "Perfil guardado",
|
||||||
"profiles": "Perfis",
|
"profiles": "Perfis",
|
||||||
"provideSecret": "Indicar um segredo",
|
"provideSecret": "Indicar um segredo",
|
||||||
|
"provideSecretForEnvVar": "Indicar um segredo para {{envVar}}",
|
||||||
"quickPresets": "Predefinições rápidas",
|
"quickPresets": "Predefinições rápidas",
|
||||||
"refresh": "Atualizar",
|
"refresh": "Atualizar",
|
||||||
"relatedDockerContainersRemoved_one": "{{count}} contentor Docker relacionado também será removido.",
|
"relatedDockerContainersRemoved_one": "{{count}} contentor Docker relacionado também será removido.",
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
"profileSaved": "配置已保存",
|
"profileSaved": "配置已保存",
|
||||||
"profiles": "配置",
|
"profiles": "配置",
|
||||||
"provideSecret": "提供密钥",
|
"provideSecret": "提供密钥",
|
||||||
|
"provideSecretForEnvVar": "为 {{envVar}} 提供密钥",
|
||||||
"quickPresets": "快速预设",
|
"quickPresets": "快速预设",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"relatedDockerContainersRemoved_one": "还将移除 {{count}} 个相关 Docker 容器。",
|
"relatedDockerContainersRemoved_one": "还将移除 {{count}} 个相关 Docker 容器。",
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
"profileSaved": "設定已儲存",
|
"profileSaved": "設定已儲存",
|
||||||
"profiles": "設定",
|
"profiles": "設定",
|
||||||
"provideSecret": "提供密鑰",
|
"provideSecret": "提供密鑰",
|
||||||
|
"provideSecretForEnvVar": "為 {{envVar}} 提供密鑰",
|
||||||
"quickPresets": "快速預設",
|
"quickPresets": "快速預設",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"relatedDockerContainersRemoved_one": "還將移除 {{count}} 個相關 Docker 容器。",
|
"relatedDockerContainersRemoved_one": "還將移除 {{count}} 個相關 Docker 容器。",
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
"profileSaved": "設定已儲存",
|
"profileSaved": "設定已儲存",
|
||||||
"profiles": "設定",
|
"profiles": "設定",
|
||||||
"provideSecret": "提供金鑰",
|
"provideSecret": "提供金鑰",
|
||||||
|
"provideSecretForEnvVar": "為 {{envVar}} 提供金鑰",
|
||||||
"quickPresets": "快速預設",
|
"quickPresets": "快速預設",
|
||||||
"refresh": "重新整理",
|
"refresh": "重新整理",
|
||||||
"relatedDockerContainersRemoved_one": "還將移除 {{count}} 個相關 Docker 容器。",
|
"relatedDockerContainersRemoved_one": "還將移除 {{count}} 個相關 Docker 容器。",
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ Open **Settings > Agents** (`/settings/agents`). Kandev scans the host on which
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, and Hermes. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, Hermes, and Antigravity. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
||||||
|
|
||||||
Hermes launches with `hermes acp`. Install the required `hermes` executable from its **Settings > Agents** card, which runs the official Hermes installer. Hermes currently supports task and workspace sessions. Office-assigned skill injection is not yet supported.
|
Hermes launches with `hermes acp`. Install the required `hermes` executable from its **Settings > Agents** card, which runs the official Hermes installer. Hermes currently supports task and workspace sessions. Office-assigned skill injection is not yet supported.
|
||||||
|
|
||||||
|
Antigravity has no automated install: Google distributes `agy_acp_server.par` (`agy_acp_server.exe` on Windows) and its `localharness_external` or `localharness` sibling (`localharness_external.exe` or `localharness.exe` on Windows) as a signed archive through the [ACP registry](https://github.com/agentclientprotocol/registry/tree/main/antigravity-acp) rather than npm, so extract both files into one directory and add it to PATH yourself. Kandev fails discovery closed when the harness sibling is missing or not executable, so a partial extraction reports as not installed rather than as a broken session.
|
||||||
|
|
||||||
### Pi command surfaces
|
### Pi command surfaces
|
||||||
|
|
||||||
Pi uses separate executables for its two Kandev modes:
|
Pi uses separate executables for its two Kandev modes:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ surface shared by task and Office consumers.
|
|||||||
|
|
||||||
- [Agent Resume and Runtime Recovery](requirements/agent-resume-runtime-recovery.md)
|
- [Agent Resume and Runtime Recovery](requirements/agent-resume-runtime-recovery.md)
|
||||||
- [Agent Rich Output](requirements/agent-rich-output.md)
|
- [Agent Rich Output](requirements/agent-rich-output.md)
|
||||||
|
- [Google Antigravity ACP Agent](requirements/antigravity-acp-agent.md)
|
||||||
- [Agent Stall Recovery](requirements/agent-stall-recovery.md)
|
- [Agent Stall Recovery](requirements/agent-stall-recovery.md)
|
||||||
- [Collapsible Agent Blocks on the Agents Settings Page](requirements/collapsible-agent-blocks.md)
|
- [Collapsible Agent Blocks on the Agents Settings Page](requirements/collapsible-agent-blocks.md)
|
||||||
- [Cursor Subagent Metadata](requirements/cursor-subagent-metadata.md)
|
- [Cursor Subagent Metadata](requirements/cursor-subagent-metadata.md)
|
||||||
@@ -66,6 +67,7 @@ surface shared by task and Office consumers.
|
|||||||
### System design
|
### System design
|
||||||
|
|
||||||
- [Agent Resume and Runtime Recovery](system-design/agent-resume-runtime-recovery.md)
|
- [Agent Resume and Runtime Recovery](system-design/agent-resume-runtime-recovery.md)
|
||||||
|
- [Google Antigravity ACP Agent](system-design/antigravity-acp-agent.md)
|
||||||
- [Injected Skill Naming](system-design/injected-skill-naming.md)
|
- [Injected Skill Naming](system-design/injected-skill-naming.md)
|
||||||
- [Injected Skill Naming Migration](system-design/injected-skill-naming-migration.md)
|
- [Injected Skill Naming Migration](system-design/injected-skill-naming-migration.md)
|
||||||
- [Dynamic Agent Routing System Design Part 1](system-design/dynamic-agent-routing-01.md)
|
- [Dynamic Agent Routing System Design Part 1](system-design/dynamic-agent-routing-01.md)
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
system: agents
|
||||||
|
created: 2026-09-03
|
||||||
|
owners:
|
||||||
|
- kandev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Google Antigravity ACP agent requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Google publishes a standalone ACP server for Antigravity as a signed native
|
||||||
|
binary, separate from the `agy` CLI. Kandev needs it as a built-in agent so a
|
||||||
|
user can select it for a task, run and resume a session over ACP, and use MCP
|
||||||
|
servers and skills, without inventing a distribution, credential, or
|
||||||
|
session-storage mechanism Google already owns. The agent system owns the durable
|
||||||
|
decision: an agent identity plus its discovery, launch, config-root,
|
||||||
|
authentication, and capability contract; transport and process supervision
|
||||||
|
belong to the runtime. This first layer assumes the operator already installed
|
||||||
|
the server; automated download and remote or containerized provisioning are
|
||||||
|
named exclusions, not silence.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- **ACP server:** The executable in the ACP registry entry `antigravity-acp`
|
||||||
|
(`agy_acp_server.par`, `.exe` on windows). Not the `agy` CLI.
|
||||||
|
- **Harness:** The `localharness_external` executable in the same archive, which
|
||||||
|
the ACP server locates and drives over gRPC.
|
||||||
|
- **Config root:** `<home>/antigravity-acp`, where `<home>` is `$GEMINI_HOME`
|
||||||
|
when set and non-empty and `~/.gemini` otherwise. Holds settings, credentials,
|
||||||
|
trusted workspaces, and conversations. What Kandev declares about it is
|
||||||
|
REQ-AGENTS-ANTIGRAVITY-ACP-004.
|
||||||
|
- **Advertised capabilities:** The `agentCapabilities` and `authMethods` in the
|
||||||
|
ACP `initialize` result.
|
||||||
|
|
||||||
|
## Prior art
|
||||||
|
|
||||||
|
Receipts first, because a leg with no receipt did not run.
|
||||||
|
|
||||||
|
**Wiki leg: did not run, tool unavailable.** Vault
|
||||||
|
`/Users/henry/Documents/henry/wiki`, collection `wiki`, `@henry`-pinned.
|
||||||
|
`obsidian-wiki` and `qmd` are off PATH; the vault returns `Operation not
|
||||||
|
permitted` to `ls` and `grep`.
|
||||||
|
|
||||||
|
**Cross-product leg: did not run, tool unavailable.** The `saas-kb` MCP server
|
||||||
|
and its `search_fsm_docs` tool are not exposed in this session.
|
||||||
|
|
||||||
|
**In-repo prior art: read directly.** Kandev's built-in ACP
|
||||||
|
agents are either managed npm runtimes on a pinned `npx` specification or
|
||||||
|
binaries found on PATH and launched by name. Antigravity is the second kind, and
|
||||||
|
Kandev has no managed non-npm download mechanism, which is why provisioning is
|
||||||
|
excluded below rather than reinvented. It departs from that group three times —
|
||||||
|
discovery checks a second file, the install script is empty, and there is no CLI
|
||||||
|
passthrough — each argued in the design's
|
||||||
|
[prior-art departure](../system-design/antigravity-acp-agent.md#prior-art-departure).
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Values are of two kinds. **Measured** ones come from a manual probing session
|
||||||
|
against build `agy_acp_server_20260818_01_RC01`, darwin-arm64, on 2026-09-03;
|
||||||
|
the artifact was removed afterwards, so this pair summarizes that session rather
|
||||||
|
than transcribing it, and re-checking one means re-downloading that build.
|
||||||
|
**Registry** ones are read from the published `antigravity-acp/agent.json` and
|
||||||
|
were never executed; each use says so.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-001: Built-in Antigravity agent identity
|
||||||
|
|
||||||
|
**Intent:** Give Antigravity a stable catalog identity so a user can select it
|
||||||
|
for a task and profile like any other built-in agent.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.1:** The agent identifier shall be
|
||||||
|
`antigravity-acp`, matching the `agentInfo.name` the ACP server reports.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.2:** The display name shall be `Antigravity`
|
||||||
|
and the catalog name shall be `Antigravity ACP Agent`.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.3:** The agent shall be enabled by default and
|
||||||
|
present in the default registry.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.4:** The display order shall be `22`, distinct
|
||||||
|
from every other built-in agent's, so the display-order sort never reaches its
|
||||||
|
stable-sort tiebreak here.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.5:** The agent shall return a non-empty light
|
||||||
|
and dark logo, and the light one for any other variant.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.6:** The agent shall declare the ACP
|
||||||
|
protocol and shall not be a virtual agent.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.7:** Because REQ-AGENTS-ANTIGRAVITY-ACP-007
|
||||||
|
contributes no install script, the description shall carry the install
|
||||||
|
guidance, stating all four of: the registry entry `antigravity-acp` as the
|
||||||
|
source; that the archive's two entries extract together into one directory;
|
||||||
|
that the directory be on PATH; and the platform executable name from
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.1. It shall embed no archive URL or build stamp,
|
||||||
|
both of which rotate.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.8:** The agent shall declare no agent-specific
|
||||||
|
permission settings; its catalog shall still expose the universal
|
||||||
|
auto-approve setting.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-001.9:** The agent shall not offer a CLI
|
||||||
|
passthrough mode.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-002: Discovery that fails closed on a partial install
|
||||||
|
|
||||||
|
**Intent:** Report Antigravity as installed only when it can run, so a user
|
||||||
|
never starts a session that silently loses capability mid-prompt.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.1:** Discovery shall look up exactly one
|
||||||
|
executable name on the PATH of the Kandev process performing discovery:
|
||||||
|
`agy_acp_server.exe` on windows, `agy_acp_server.par` on every other platform.
|
||||||
|
The name is therefore defined on every platform. Registry-sourced.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.2:** When that executable is not on PATH,
|
||||||
|
discovery shall report unavailable with an empty matched path and no error.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.3:** When the executable is on PATH, discovery
|
||||||
|
shall additionally require a harness in the directory containing it, trying
|
||||||
|
`localharness_external` then `localharness`, first match wins, each
|
||||||
|
`.exe`-suffixed on windows and unsuffixed elsewhere. A name matches a regular
|
||||||
|
file, with an execute bit required on non-windows hosts. A directory, dangling
|
||||||
|
symlink, or unstat-able name does not match; discovery continues.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.4:** When `ANTIGRAVITY_HARNESS_PATH` is set to a
|
||||||
|
non-empty value in the environment discovery observes, the
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.3 check shall be treated as satisfied without
|
||||||
|
inspecting the directory.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.5:** When the executable is present but
|
||||||
|
neither AC-AGENTS-ANTIGRAVITY-ACP-002.3 nor AC-AGENTS-ANTIGRAVITY-ACP-002.4
|
||||||
|
holds, the AC-AGENTS-ANTIGRAVITY-ACP-002.2 outcome shall apply unchanged.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.6:** When discovery reports the agent
|
||||||
|
available, the matched path shall be the path PATH lookup returned, and MCP
|
||||||
|
support and session resume shall both be reported as supported.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.7:** The directory inspected by
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.3 shall be the directory of the path PATH lookup
|
||||||
|
returned, without resolving symbolic links.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.8:** Discovery shall create no files, write no
|
||||||
|
configuration, and start no process, so repeated calls over an unchanged PATH
|
||||||
|
and filesystem return equal results.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-002.9:** When the caller's context is cancelled or
|
||||||
|
expires during discovery, discovery shall return that error rather than
|
||||||
|
reporting the agent as unavailable.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-003: One launch command across every surface
|
||||||
|
|
||||||
|
**Intent:** Keep the session, runtime, and inference commands identical, so a
|
||||||
|
session and a capability probe cannot disagree about how the agent starts.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.1:** On windows the argument vector shall be
|
||||||
|
exactly `agy_acp_server.exe` with no arguments; on darwin and on every platform
|
||||||
|
other than linux and windows, exactly `agy_acp_server.par` with no arguments.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.2:** On linux, the argument vector shall be
|
||||||
|
exactly `agy_acp_server.par --uid=`, matching the published registry entry for
|
||||||
|
both Linux platforms. Registry-sourced, not measured.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.3:** The platform selecting among
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.1, AC-AGENTS-ANTIGRAVITY-ACP-003.1 and
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-003.2 shall be that of the Kandev process building
|
||||||
|
the command — the host, not an executor's target; a remote executor on another
|
||||||
|
platform is a named exclusion below. Those three shall be total: a defined
|
||||||
|
lookup name and a non-empty vector on every platform. Availability is decided
|
||||||
|
by discovery alone, so no platform needs a special case here: anywhere else
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.2 governs the outcome.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.4:** Command construction shall be
|
||||||
|
deterministic: repeated calls on one platform shall produce equal vectors,
|
||||||
|
consulting neither PATH, the filesystem, nor the environment.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.5:** The session, runtime, and one-shot inference
|
||||||
|
commands shall produce the same argument vector for the same platform.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.6:** The argument vector shall not vary with
|
||||||
|
the selected model, permission policy, auto-approve setting, agent type, or
|
||||||
|
resume target.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.7:** This agent's own command construction
|
||||||
|
shall not add `--debug` or `--notices`.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.8:** The working directory shall be the task
|
||||||
|
workspace.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.9:** The runtime shall declare an empty
|
||||||
|
environment map and no stripped variables, so the agent inherits the execution
|
||||||
|
environment unmodified.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.10:** Resource limits shall be 4096 MB memory,
|
||||||
|
2.0 CPU cores, and a one-hour timeout, matching other built-in ACP agents.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.11:** The runtime shall not request an
|
||||||
|
immediate process kill on shutdown.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.12:** One-shot inference shall be supported.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-003.13:** AC-AGENTS-ANTIGRAVITY-ACP-003.1 through
|
||||||
|
003.7 bind this agent's own command construction, not the final launched argv:
|
||||||
|
the shared builder appends the profile's `cli_flags` and any sandbox command
|
||||||
|
prefix to every agent afterwards, and this agent shall declare no filter for
|
||||||
|
either.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-004: Config root, sessions, and skills
|
||||||
|
|
||||||
|
**Intent:** Point Kandev at the directories the server uses, so a long task
|
||||||
|
survives a relaunch and Kandev never writes state it does not own.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.1:** The session directory template shall be
|
||||||
|
`{home}/.gemini/antigravity-acp`.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.2:** Kandev shall not set, override, or read
|
||||||
|
`GEMINI_HOME`. The declared template is the server's default config root, so
|
||||||
|
it holds exactly when `GEMINI_HOME` is unset or empty in the execution
|
||||||
|
environment.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.3:** The agent shall declare native session resume
|
||||||
|
and that a session can be recovered.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.4:** Kandev shall introduce no per-launch
|
||||||
|
variation in the config root: one static template, no `GEMINI_HOME` set or
|
||||||
|
injected by Kandev, and no config-root flag. A relaunch therefore presents the
|
||||||
|
server the root the creating launch saw whenever the execution environment is
|
||||||
|
unchanged, which is the root the server scopes session lookup to.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.5:** The project skill directory shall be
|
||||||
|
`.agents/skills`.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.6:** The user skill directory shall be
|
||||||
|
`.gemini/config/skills`, the first entry of the server's global skill pair.
|
||||||
|
The server searches both entries, so this is a determinism choice rather than
|
||||||
|
a capability one, and it keeps Kandev out of the `agy` CLI's
|
||||||
|
`antigravity-cli/` domain.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.7:** The template nests inside the Gemini
|
||||||
|
agent's `{home}/.gemini`; the two shall remain distinct values, neither
|
||||||
|
aliased, merged, nor special-cased.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.8:** During a session, Kandev shall not
|
||||||
|
create, modify, or delete any file under the config root. Its only write there
|
||||||
|
is the user-initiated remote-auth copy in AC-AGENTS-ANTIGRAVITY-ACP-005.7,
|
||||||
|
which seeds a remote host before a session starts.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.9:** When `GEMINI_HOME` is set and non-empty,
|
||||||
|
the server resolves a config root the declared template does not describe.
|
||||||
|
Kandev shall not detect, read, or reconcile it; that host is a named exclusion
|
||||||
|
below.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-004.10:** The container session-directory target
|
||||||
|
shall be empty, so no bind mount is created for the config root and the
|
||||||
|
declared template has no runtime effect today, matching the other
|
||||||
|
native-binary ACP agents.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-005: Authentication owned by the ACP server
|
||||||
|
|
||||||
|
**Intent:** Surface the server's own authentication contract instead of
|
||||||
|
duplicating it, so an unauthenticated agent reads as "needs login", not "broken".
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.1:** Kandev shall present the authentication
|
||||||
|
methods the server advertises in its `initialize` result rather than a
|
||||||
|
hardcoded list.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.2:** Those methods shall be presented in the order
|
||||||
|
the server returned them, never re-sorted or re-ranked.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.3:** This agent shall declare no auth
|
||||||
|
classification of its own, relying on the shared ACP handling of a `-32000`
|
||||||
|
from a capability probe or session start, which records an
|
||||||
|
authentication-required state rather than a failed one. An unauthenticated
|
||||||
|
probe therefore records that state, not a success carrying an empty model
|
||||||
|
list.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.4:** This agent shall declare no stderr-derived
|
||||||
|
failure rule: when the server answers with a well-formed JSON-RPC error on
|
||||||
|
stdout, the traceback it also writes to stderr for that handled error is not an
|
||||||
|
additional or fatal launch failure.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.5:** Kandev shall surface the message the server
|
||||||
|
returns when an `authenticate` call fails because a required environment
|
||||||
|
variable is absent from the launch environment.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.6:** Kandev shall not mint, refresh, or
|
||||||
|
rewrite credentials; it only copies files the server already wrote per
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-005.7 and passes user-supplied variables.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.7:** Remote authentication shall offer a
|
||||||
|
file-copy method for darwin and linux carrying `settings.json`,
|
||||||
|
`acp_token.json` and `acp_business_token.json`, each under
|
||||||
|
`.gemini/antigravity-acp`, which is also the target directory.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.8:** The file-copy method shall not require every
|
||||||
|
listed file to exist; which token file is present depends on the
|
||||||
|
authentication method the user chose.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-005.9:** Remote authentication shall also offer
|
||||||
|
environment-variable methods for `GEMINI_API_KEY` and `GOOGLE_API_KEY`.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-006: Capabilities read from the agent, not inferred
|
||||||
|
|
||||||
|
**Intent:** Derive behavior from what the server advertises, so an echoed
|
||||||
|
protocol version is never taken for a capability claim.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-006.1:** This agent shall add no
|
||||||
|
`protocolVersion`-derived branch to the shared ACP capability path, which
|
||||||
|
already reads the advertised `agentCapabilities` and `authMethods`; the echoed
|
||||||
|
`protocolVersion` carries no capability information.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-006.2:** The agent shall not declare an assumed
|
||||||
|
HTTP or SSE MCP override, because the server advertises both.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-006.3:** MCP servers shall be passed through ACP
|
||||||
|
`session/new`, and the agent shall not declare a project-local MCP
|
||||||
|
configuration strategy.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-006.4:** The agent shall not declare that it
|
||||||
|
namespaces MCP tool names by server.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-006.5:** Billing type shall be resolved by the
|
||||||
|
shared default rule rather than pinned by this agent.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-007: No automated provisioning
|
||||||
|
|
||||||
|
**Intent:** Contribute no install script, so a remote bootstrap never runs prose
|
||||||
|
as a shell command or pulls a third of a gigabyte per host. Kandev executes that
|
||||||
|
value verbatim as remote shell.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-007.1:** The install script shall be the empty
|
||||||
|
string.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-007.2:** The agent shall therefore contribute no
|
||||||
|
text to remote bootstrap scripts on any executor, and requesting an install job
|
||||||
|
shall fail with the existing empty-install-script error, running nothing.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-007.3:** When an executor cannot find the agent on
|
||||||
|
its PATH, Kandev shall surface whichever failure that executor already
|
||||||
|
produces and add no new preflight: SSH's existing preflight names the agent
|
||||||
|
and the missing binary, elsewhere the spawn failure names the binary alone.
|
||||||
|
Neither shall fall back to another agent, command, or a download.
|
||||||
|
|
||||||
|
### REQ-AGENTS-ANTIGRAVITY-ACP-008: Concurrent sessions
|
||||||
|
|
||||||
|
**Intent:** State that concurrency in the config root belongs to the ACP server,
|
||||||
|
so two tasks run at once and no one adds a lock the server does not expect.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-008.1:** Two concurrent Kandev sessions using this
|
||||||
|
agent shall each launch an independent server process.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-008.2:** Kandev shall not serialize those processes
|
||||||
|
and shall not lock, guard, or arbitrate access to the shared config root;
|
||||||
|
conflicting writes there are the ACP server's responsibility.
|
||||||
|
- **AC-AGENTS-ANTIGRAVITY-ACP-008.3:** Terminating one session shall not terminate
|
||||||
|
the other nor remove shared state under the config root.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Each exclusion is a contract, not silence. The design's
|
||||||
|
[deferred work](../system-design/antigravity-acp-agent.md#deferred-work) records
|
||||||
|
what a follow-up would have to decide for the first three.
|
||||||
|
|
||||||
|
- **Automated download and installation of the ACP server, and therefore
|
||||||
|
containerized, Kubernetes, SSH and Sprites provisioning.** Kandev has no
|
||||||
|
managed non-npm download mechanism, no image ships this binary, and
|
||||||
|
REQ-AGENTS-ANTIGRAVITY-ACP-007 contributes no install script, so those
|
||||||
|
executors report it not found.
|
||||||
|
- **A remote executor whose platform differs from the Kandev host's.** Discovery
|
||||||
|
reads the host's PATH and every command surface is static, so a darwin host
|
||||||
|
driving a hand-provisioned linux remote ships the darwin vector and drops the
|
||||||
|
linux `--uid=` of AC-AGENTS-ANTIGRAVITY-ACP-003.2. Command construction takes
|
||||||
|
no per-executor platform input, so this layer neither detects nor corrects it.
|
||||||
|
REQ-AGENTS-ANTIGRAVITY-ACP-005's remote-auth methods serve the same-platform
|
||||||
|
remote, which is unaffected.
|
||||||
|
- **Any non-default `GEMINI_HOME`, whether ambient or set per task.** The server
|
||||||
|
moves its whole config root while Kandev declares the default root and never
|
||||||
|
reads the variable, so session-dir seeding, the
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-005.7 copy and the AC-AGENTS-ANTIGRAVITY-ACP-004.6
|
||||||
|
user skill directory all address directories the server is not using: resume,
|
||||||
|
remote auth and user skills are undefined there, not merely degraded.
|
||||||
|
- **Application Default Credentials for `agent-platform` remote auth.** A
|
||||||
|
single-variable remote-auth method cannot express `GOOGLE_CLOUD_PROJECT` plus
|
||||||
|
`GOOGLE_CLOUD_LOCATION` with ADC, so AC-AGENTS-ANTIGRAVITY-ACP-005.9 offers
|
||||||
|
only the two API-key methods and an ADC-only remote host has no auth path.
|
||||||
|
- **A passthrough or TUI agent for the `agy` CLI.** `agy` 1.1.25 has no ACP mode,
|
||||||
|
is a different binary, and authenticates into `~/.gemini/antigravity-cli/`
|
||||||
|
rather than the config root, so binding both to one agent would make the
|
||||||
|
passthrough toggle silently switch credential domain and conversation store.
|
||||||
|
- **Model catalog curation.** Models come from the existing ACP probe, which
|
||||||
|
needs authentication first. No static model list, no model flag.
|
||||||
|
- **Windows and Linux verification.** Only darwin-arm64 was executed, so the
|
||||||
|
windows `.exe` name and the linux `--uid=` argument in
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-002.1/003.1/003.2 are registry-sourced and
|
||||||
|
unverified.
|
||||||
|
- **Adding a failure reason to the discovery result.** Distinguishing "installed
|
||||||
|
but missing its harness" in user-facing text would change a discovery contract
|
||||||
|
shared by every agent, so the partial install reports plain unavailable.
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
system: agents
|
||||||
|
requirements:
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-001
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-002
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-003
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-004
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-005
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-006
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-007
|
||||||
|
- REQ-AGENTS-ANTIGRAVITY-ACP-008
|
||||||
|
---
|
||||||
|
|
||||||
|
# Google Antigravity ACP agent system design
|
||||||
|
|
||||||
|
## Purpose and boundaries
|
||||||
|
|
||||||
|
The agent system owns the Antigravity agent's declared identity, discovery
|
||||||
|
predicate, launch argument vector, config-root template, skill directories, and
|
||||||
|
remote-auth methods. Google's ACP server owns its own credentials, session
|
||||||
|
storage, harness subprocess, and model catalogue.
|
||||||
|
|
||||||
|
This design does not own ACP transport, process supervision, capability
|
||||||
|
probing, MCP wiring, or executor provisioning. It constrains only what Kandev
|
||||||
|
declares about this agent and what Kandev may assume about the server.
|
||||||
|
|
||||||
|
Six requirement criteria touch those unowned layers and are deliberately
|
||||||
|
written as *declare-nothing-new* constraints rather than as new behavior:
|
||||||
|
`AC-AGENTS-ANTIGRAVITY-ACP-003.13` on the shared command builder's `cli_flags`
|
||||||
|
and sandbox-prefix append, `AC-AGENTS-ANTIGRAVITY-ACP-005.1` and `.2` on the
|
||||||
|
advertised authentication methods and their order,
|
||||||
|
`AC-AGENTS-ANTIGRAVITY-ACP-005.3` and `.4` on authentication and stderr, and
|
||||||
|
`AC-AGENTS-ANTIGRAVITY-ACP-006.1` on capability derivation. Each is satisfied by
|
||||||
|
this agent adding no agent-specific override, because the shared layer already
|
||||||
|
behaves that way: `CommandBuilder.BuildCommand` appends `cli_flags` for every
|
||||||
|
agent with no per-agent opt-out (see [launch surface](#launch-surface)), the
|
||||||
|
session-start adapter caches the `initialize` result's `authMethods` and
|
||||||
|
re-emits them in that order, `maybeEmitAuthRequired` classifies a `-32000` as
|
||||||
|
auth-required whenever auth methods are cached, and the capability path reads
|
||||||
|
`agentCapabilities`. Build should assert the absence of an override, not
|
||||||
|
re-implement the shared rule. If a future change makes the shared rule
|
||||||
|
insufficient for this agent, that is a change to the runtime's design, not this
|
||||||
|
one.
|
||||||
|
|
||||||
|
**Which layer presents the auth methods.** `AC-AGENTS-ANTIGRAVITY-ACP-005.1` and
|
||||||
|
`.2` bind the session-start adapter: it is the layer that caches the advertised
|
||||||
|
methods when `initialize` returns and re-emits them, in the server's order, when
|
||||||
|
`session/new` answers `-32000`. They do not bind the capability probe: on an
|
||||||
|
authenticated run that path does read the advertised methods, but when
|
||||||
|
`session/new` answers `-32000` it returns before reaching that step. An
|
||||||
|
unauthenticated probe therefore records the auth-required state of
|
||||||
|
`AC-AGENTS-ANTIGRAVITY-ACP-005.3` carrying no method list, which is the shared
|
||||||
|
behavior for every ACP agent rather than something this one declares. Making the
|
||||||
|
probe carry them would change a shared cross-agent path this design does not
|
||||||
|
own, so it is out of scope here rather than implied by `.1`.
|
||||||
|
|
||||||
|
Values here are either **measured** against build
|
||||||
|
`agy_acp_server_20260818_01_RC01`, darwin-arm64, on 2026-09-03, or **registry**
|
||||||
|
values read from `antigravity-acp/agent.json` and never executed; each section
|
||||||
|
says which. The probed artifact was removed after the session and no transcript
|
||||||
|
was retained, so the measured values are a summary of that session and
|
||||||
|
re-checking one means re-downloading that build. Only darwin-arm64 was ever
|
||||||
|
executed: every Windows and Linux value here is registry-sourced. This is a
|
||||||
|
record of an external contract, not a Kandev implementation plan.
|
||||||
|
|
||||||
|
## Requirement mapping
|
||||||
|
|
||||||
|
| Requirement | Design section |
|
||||||
|
| --- | --- |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-001` | [Distribution artifact](#distribution-artifact) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-002` | [Harness resolution](#harness-resolution) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-003` | [Launch surface](#launch-surface) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-004` | [Persistence](#persistence) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-005` | [Security](#security) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-006` | [Data and contracts](#data-and-contracts) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-007` | [Distribution artifact](#distribution-artifact) |
|
||||||
|
| `REQ-AGENTS-ANTIGRAVITY-ACP-008` | [Control flow](#control-flow) |
|
||||||
|
|
||||||
|
## Components and responsibilities
|
||||||
|
|
||||||
|
- **Kandev agent declaration.** Identity, discovery, argument vector, config
|
||||||
|
root, skill directories, remote-auth methods. Static metadata only.
|
||||||
|
- **`agy_acp_server.par` / `.exe`.** Google's ACP server. A Mach-O executable
|
||||||
|
wrapping a packed Python archive; the `.par` suffix is part of the filename,
|
||||||
|
not an interpreter hint, and the file is executed directly. Speaks ACP over
|
||||||
|
stdio.
|
||||||
|
- **`localharness_external`.** A sibling executable the server drives over gRPC
|
||||||
|
using protobuf messages (`localharness_pb2`). Located by the server itself,
|
||||||
|
never by Kandev.
|
||||||
|
|
||||||
|
### Distribution artifact
|
||||||
|
|
||||||
|
The ACP registry entry `antigravity-acp/agent.json` publishes a
|
||||||
|
`distribution.binary` matrix, read verbatim on 2026-09-03:
|
||||||
|
|
||||||
|
| Platform | `cmd` | `args` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `darwin-aarch64` | `./agy_acp_server.par` | none |
|
||||||
|
| `linux-x86_64` | `./agy_acp_server.par` | `["--uid="]` |
|
||||||
|
| `linux-aarch64` | `./agy_acp_server.par` | `["--uid="]` |
|
||||||
|
| `windows-x86_64` | `./agy_acp_server.exe` | none |
|
||||||
|
| `windows-aarch64` | `./agy_acp_server.exe` | none |
|
||||||
|
|
||||||
|
**The executable name carries its suffix on every platform.** There is no
|
||||||
|
`agy_acp_server` without an extension: the archives contain `.par` (darwin,
|
||||||
|
linux) and `.exe` (windows), and the registry invokes exactly those. Kandev's
|
||||||
|
discovery name and argument vector therefore use the suffixed, platform-specific
|
||||||
|
name and require no rename, symlink, or wrapper from the user. An earlier draft
|
||||||
|
of this pair used the bare name and would have made the agent undiscoverable on
|
||||||
|
every platform.
|
||||||
|
|
||||||
|
The darwin-arm64 archive is 314,500,221 bytes,
|
||||||
|
`sha256 f122ca7e7030a27f9649da4cf1a7d80e12c48c5f6118ff35affc34d56cbf83dd`. It
|
||||||
|
holds exactly two archive-root entries:
|
||||||
|
|
||||||
|
| Entry | Uncompressed | Mode |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `agy_acp_server.par` | 792,105,680 | `0755` |
|
||||||
|
| `localharness_external` | 101,551,680 | `0555` |
|
||||||
|
|
||||||
|
An extraction therefore costs roughly 894 MB. The executable is signed
|
||||||
|
`Developer ID Application: Google LLC (EQHXZ8M8AV)` with hardened runtime and a
|
||||||
|
2026-08-19 timestamp, so it runs on macOS without a quarantine prompt when
|
||||||
|
fetched by a non-quarantining downloader.
|
||||||
|
|
||||||
|
The archive URL carries a rotating build stamp
|
||||||
|
(`agy_acp_server_20260818_01_RC01`) and Google publishes no checksum beside it.
|
||||||
|
That, the size, and the fact that Kandev executes `InstallScript()` verbatim as
|
||||||
|
remote shell are the three reasons provisioning is excluded from this layer.
|
||||||
|
|
||||||
|
`agy` 1.1.25, the separate Antigravity CLI, is not an alternative entry point:
|
||||||
|
it exposes no ACP mode in `--help` or `--helpfull`, contains no reference to the
|
||||||
|
ACP server, and authenticates into `~/.gemini/antigravity-cli/`.
|
||||||
|
|
||||||
|
### Prior-art departure
|
||||||
|
|
||||||
|
Kandev's built-in ACP agents that launch a bare binary rather than `npx` are
|
||||||
|
Cursor, Kimi, Kiro, Qoder, Trae, Devin, Grok, Hermes and Omp. Each detects one
|
||||||
|
PATH entry and stops, and each returns a non-empty `InstallScript()` — Kimi,
|
||||||
|
Kiro and Qoder return prose rather than runnable shell, which is the latent form
|
||||||
|
of the hazard below, while Cursor, Devin and Hermes return real installers. This
|
||||||
|
agent departs on three counts, each forced by measured behavior rather than
|
||||||
|
preference.
|
||||||
|
|
||||||
|
**Discovery checks a second file.** With the executable present but its harness
|
||||||
|
absent, the server still starts and still answers `initialize`, logging only
|
||||||
|
`Localharness not found.` to stderr. One-entry detection would therefore report
|
||||||
|
a half-installed agent as healthy and lose capability later, inside a prompt.
|
||||||
|
The predicate checks the sibling directly so the failure surfaces at discovery.
|
||||||
|
See [harness resolution](#harness-resolution) for the search order this mirrors.
|
||||||
|
|
||||||
|
**No CLI passthrough.** Every one of those agents also declares a passthrough
|
||||||
|
config so the user can drop to the raw CLI under a PTY. This server has no such
|
||||||
|
mode: `--helpfull` exposes only `--[no]debug` and `--[no]notices`, there is no
|
||||||
|
subcommand or REPL, and the process speaks ACP over stdio and exits on stdin
|
||||||
|
EOF. Declaring passthrough would surface a toggle that spawns a process the user
|
||||||
|
cannot interact with, so AC-AGENTS-ANTIGRAVITY-ACP-001.9 declines it.
|
||||||
|
|
||||||
|
**The install script is empty.** That value runs verbatim as remote shell on the
|
||||||
|
SSH, Kubernetes and Sprites executors, so prose in it is a command those
|
||||||
|
executors attempt to execute. Because the artifact is also a version-pinned
|
||||||
|
314 MB download expanding to roughly 894 MB, the honest value is the empty
|
||||||
|
string, and the install guidance moves to `Description()`.
|
||||||
|
|
||||||
|
## Data and contracts
|
||||||
|
|
||||||
|
`--helpfull` exposes exactly two flags on darwin, `--[no]debug` and
|
||||||
|
`--[no]notices`. There is no `--uid`, `--model`, `--acp`, or port flag, which is
|
||||||
|
why the argument vector cannot vary with model, permission policy, or resume
|
||||||
|
target.
|
||||||
|
|
||||||
|
`initialize` returns:
|
||||||
|
|
||||||
|
- `agentInfo`: `{name: antigravity-acp, title: Google Antigravity, version:
|
||||||
|
agy_acp_server_20260818_01_RC01}`
|
||||||
|
- `agentCapabilities`: `loadSession: true`; `promptCapabilities` `{image, audio,
|
||||||
|
embeddedContext}`; `mcpCapabilities` `{http: true, sse: true}`;
|
||||||
|
`sessionCapabilities` `{list, resume}`; `auth: {logout}`
|
||||||
|
- `authMethods`: `oauth-personal`, `oauth-business`, `gemini-api-key`,
|
||||||
|
`agent-platform`
|
||||||
|
|
||||||
|
The server echoes the requested `protocolVersion` verbatim and does not clamp:
|
||||||
|
a request of `1` returned `1`, and a request of `99` returned `99`. The echoed
|
||||||
|
value carries no information about what the server supports, which is why
|
||||||
|
capability decisions must read `agentCapabilities` instead.
|
||||||
|
|
||||||
|
## Launch surface
|
||||||
|
|
||||||
|
One argument vector serves the session command, the runtime command, and the
|
||||||
|
one-shot inference command, so no path can drift to a different one:
|
||||||
|
|
||||||
|
| Platform | Argument vector | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| darwin | `agy_acp_server.par` | registry `cmd`; binary measured |
|
||||||
|
| linux | `agy_acp_server.par --uid=` | registry `cmd` + `args`, not measured |
|
||||||
|
| windows | `agy_acp_server.exe` | registry `cmd`, not measured |
|
||||||
|
|
||||||
|
The name is the same one discovery looks up on that platform, which is what
|
||||||
|
keeps a successful detection from producing an unlaunchable command. Selection
|
||||||
|
is by the platform of the Kandev process building the command — the host, not an
|
||||||
|
executor's target. That distinction is real rather than theoretical: discovery
|
||||||
|
calls `exec.LookPath` on the host, while the SSH executor ships the built command
|
||||||
|
verbatim to the remote, so a darwin host driving a hand-provisioned linux remote
|
||||||
|
would send the darwin vector and drop `--uid=`. Nothing here can correct it,
|
||||||
|
because command construction receives no per-executor platform, and the
|
||||||
|
requirements name the mixed-platform remote as an explicit exclusion.
|
||||||
|
|
||||||
|
The rules are also total: every platform yields a defined lookup name and a
|
||||||
|
non-empty vector. There is no "build no command" outcome to express — the command
|
||||||
|
surfaces return a value, not an error — and an empty vector would not be inert,
|
||||||
|
because the SSH preflight skips binary verification when the built command has no
|
||||||
|
arguments, turning a clean early failure into an opaque spawn error later.
|
||||||
|
Availability is gated by discovery alone.
|
||||||
|
|
||||||
|
Nothing else varies the declared vector. `--helpfull` exposes only `--[no]debug`
|
||||||
|
and `--[no]notices` on darwin, so there is no model, permission, auto-approve,
|
||||||
|
agent-type, or resume flag to vary, and this agent's own construction passes
|
||||||
|
neither of the two that exist. The working directory is the task workspace, the
|
||||||
|
environment map is empty with nothing stripped, and shutdown needs no forced kill
|
||||||
|
because the server exits on stdin EOF.
|
||||||
|
|
||||||
|
**Which layer the vector describes.** Everything above describes what this agent
|
||||||
|
*constructs*, which is not the whole of what is launched.
|
||||||
|
`CommandBuilder.BuildCommand` calls the agent's own `BuildCommand` and then
|
||||||
|
appends the profile's configured `cli_flags` to every agent's vector, after which
|
||||||
|
`prependCommandPrefix` prepends any sandbox launcher tokens. No agent has an
|
||||||
|
opt-out from either, and adding one here would change a contract shared by all of
|
||||||
|
them, which [purpose and boundaries](#purpose-and-boundaries) rules out. So
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-003.7 binds this agent's own construction only: a user
|
||||||
|
who puts `--debug` or `--notices` in their profile's `cli_flags` gets it passed,
|
||||||
|
and that is the shared `cli_flags` feature working as designed rather than a
|
||||||
|
defect in this agent. The same boundary is why the "exactly" in
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-003.1 and `.2`, and the list in `.6`, are claims about
|
||||||
|
the declared vector rather than the final argv.
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-003.13 states that binding, so a test asserting it
|
||||||
|
knows which layer to call.
|
||||||
|
|
||||||
|
The linux `--uid=` argument is the one real risk in this table. The darwin build
|
||||||
|
exposes no `uid` flag at all, and absl rejects an unknown flag at startup, so if
|
||||||
|
the linux build matches darwin rather than the registry the agent fails to start
|
||||||
|
there. That argument stays registry-sourced and unverified: the requirements
|
||||||
|
exclude Windows and Linux verification from this layer, so confirming it with
|
||||||
|
`--helpfull` against the linux archive is follow-up work, not a condition of
|
||||||
|
this change.
|
||||||
|
|
||||||
|
## Control flow
|
||||||
|
|
||||||
|
Kandev spawns one server process per session and speaks ACP over its stdio. The
|
||||||
|
server spawns and supervises its own harness subprocess.
|
||||||
|
|
||||||
|
Concurrent sessions run independent processes over a shared config root. Kandev
|
||||||
|
adds no lock: arbitration inside the config root is the server's own concern.
|
||||||
|
|
||||||
|
Closing stdin after `initialize` terminated the process within one second with
|
||||||
|
exit code 0, so graceful shutdown needs no forced process kill.
|
||||||
|
|
||||||
|
## Failure and recovery
|
||||||
|
|
||||||
|
### Harness resolution
|
||||||
|
|
||||||
|
This is the design's central hazard. The server resolves its harness like this:
|
||||||
|
|
||||||
|
1. If `ANTIGRAVITY_HARNESS_PATH` is already set, return immediately.
|
||||||
|
2. Otherwise search `dirname(abspath(sys.argv[0]))`, then
|
||||||
|
`dirname(abspath(sys.executable))`, for `localharness_external` then
|
||||||
|
`localharness` (`.exe` variants on Windows), first match wins.
|
||||||
|
3. If nothing matches, log `Localharness not found.` and continue starting.
|
||||||
|
|
||||||
|
Kandev accepts a candidate only when it is a regular file. On non-Windows
|
||||||
|
hosts, it also requires at least one execute bit. Windows mode bits do not
|
||||||
|
control executable access, so the regular-file check applies there.
|
||||||
|
|
||||||
|
`abspath` does not resolve symlinks, and step 3 is not fatal. Measured:
|
||||||
|
|
||||||
|
| Layout | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Real path, harness sibling present | clean start, no warning |
|
||||||
|
| Real path, harness removed | `Localharness not found.`, still starts |
|
||||||
|
| Symlink from a harness-less PATH directory | `Localharness not found.`, still starts |
|
||||||
|
|
||||||
|
In all three cases `initialize` succeeded. A user who symlinks the executable
|
||||||
|
onto PATH, or extracts only one of the two files, therefore gets an agent that
|
||||||
|
looks healthy at every point Kandev normally checks and loses capability later,
|
||||||
|
inside a prompt. Kandev's discovery predicate closes that gap by checking the
|
||||||
|
sibling directly, and treats a set `ANTIGRAVITY_HARNESS_PATH` as satisfying it
|
||||||
|
because step 1 makes the directory irrelevant in that case.
|
||||||
|
|
||||||
|
### Authentication errors
|
||||||
|
|
||||||
|
`session/new` before authentication returns JSON-RPC `-32000` `"Authentication
|
||||||
|
required"`, with a message enumerating the settings key and the environment
|
||||||
|
variables each method needs. The server also writes a Python traceback to stderr
|
||||||
|
for that handled error, so stderr traceback text is not evidence of a failed
|
||||||
|
launch when a well-formed JSON-RPC error arrived on stdout.
|
||||||
|
|
||||||
|
`authenticate` with `gemini-api-key` and no `GEMINI_API_KEY` in the launch
|
||||||
|
environment returns `-32602` and does not authenticate.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
`<home>` is `$GEMINI_HOME` when set and non-empty, otherwise `~/.gemini`.
|
||||||
|
Startup logged `Gemini home resolved to /Users/henry/.gemini (default;
|
||||||
|
$GEMINI_HOME is unset)`; setting `GEMINI_HOME=/tmp/gemhome` moved both the
|
||||||
|
resolved home and the settings path. Nothing is written to the config root
|
||||||
|
before authentication succeeds.
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `<home>/antigravity-acp/settings.json` | `auth.type`, `gcp.project`, `gcp.location` |
|
||||||
|
| `<home>/antigravity-acp/acp_token.json` | `oauth-personal` token |
|
||||||
|
| `<home>/antigravity-acp/acp_business_token.json` | `oauth-business` token |
|
||||||
|
| `<home>/antigravity-acp/trusted_workspaces.json` | trusted workspace roots |
|
||||||
|
| `<home>/antigravity-acp/brain/` | harness per-conversation artifacts |
|
||||||
|
| `<home>/antigravity-acp/conversations/` | session trajectories |
|
||||||
|
|
||||||
|
Session lookup is scoped to the resolved home: an unknown identifier reports
|
||||||
|
`Session not found in the current GEMINI_HOME`. Resume therefore depends on the
|
||||||
|
config root being identical across relaunches, which is what makes per-task
|
||||||
|
`GEMINI_HOME` isolation incompatible with resume.
|
||||||
|
|
||||||
|
**Declared template versus resolved root.** Kandev declares one static
|
||||||
|
`SessionDirTemplate`, `{home}/.gemini/antigravity-acp`. That string is not
|
||||||
|
expanded against a home directory at launch. Its single production consumer is
|
||||||
|
the container bind-mount source, where `SessionDirHostPath` strips the `{home}/`
|
||||||
|
prefix and rebases the remainder onto `<kandev-home>/agent-sessions/<instance>/`
|
||||||
|
— a kandev-managed directory deliberately outside every home, so host state DBs
|
||||||
|
and session caches stay out of the container. That mount is created only when the
|
||||||
|
agent also declares a `SessionDirTarget`, and per
|
||||||
|
AC-AGENTS-ANTIGRAVITY-ACP-004.10 this agent declares none. For this agent the
|
||||||
|
template is therefore a *statement about where the server keeps its config root*
|
||||||
|
with no runtime effect today, not a path Kandev resolves.
|
||||||
|
|
||||||
|
As a statement it is accurate exactly when `GEMINI_HOME` is unset or empty; it
|
||||||
|
diverges when the environment exports a non-empty one, because the runtime
|
||||||
|
declaration inherits the environment unmodified and so the server sees it. That
|
||||||
|
divergence is not repairable here: the field is a static string on every agent,
|
||||||
|
consumed by container and remote paths that resolve it against a target the
|
||||||
|
local environment does not describe, so deriving it from a local variable would
|
||||||
|
be wrong for exactly the executors that consume it. Kandev declares the default
|
||||||
|
root, never reads `GEMINI_HOME`, and the requirements name any non-default
|
||||||
|
`GEMINI_HOME` as an explicit exclusion rather than pretending the template still
|
||||||
|
describes it. Remote credential upload has the same shape: it resolves sources
|
||||||
|
against `os.UserHomeDir()`, so it too addresses the default root only.
|
||||||
|
|
||||||
|
Skill resolution appends `<cwd>/.agents/skills/` and `<cwd>/.gemini/skills` for
|
||||||
|
the project, and `<home>/config/skills` plus `<home>/antigravity-cli/skills`
|
||||||
|
globally. Kandev declares the first of each pair — `.agents/skills` and
|
||||||
|
`.gemini/config/skills` — matching the single project and user skill directory
|
||||||
|
its injection contract supports. The server searches both entries of each pair,
|
||||||
|
so taking the first is a determinism choice rather than a capability one; it
|
||||||
|
also keeps Kandev out of `antigravity-cli/`, the directory the `agy` CLI owns
|
||||||
|
and the requirements isolate. Because that global pair resolves against `<home>`,
|
||||||
|
the declared user skill directory carries the same `GEMINI_HOME` divergence as
|
||||||
|
the session template: under a non-default `GEMINI_HOME` Kandev would inject user
|
||||||
|
skills where the server does not look, which is why the requirements name it in
|
||||||
|
that exclusion alongside session-dir seeding and the remote-auth copy.
|
||||||
|
|
||||||
|
Note that `<home>/antigravity-acp` nests inside `~/.gemini`, which is already
|
||||||
|
the Gemini agent's session directory template. The two remain distinct values;
|
||||||
|
neither is aliased to the other.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Credentials are written and read by the ACP server alone. Kandev copies token
|
||||||
|
files for remote executors and sets environment variables, and otherwise does
|
||||||
|
not create, modify, or delete anything under the config root.
|
||||||
|
|
||||||
|
API keys cannot be supplied over the ACP wire: `gemini-api-key` reads
|
||||||
|
`GEMINI_API_KEY` and `agent-platform` reads `GOOGLE_API_KEY` or
|
||||||
|
`GOOGLE_CLOUD_PROJECT`/`GOOGLE_CLOUD_LOCATION` with Application Default
|
||||||
|
Credentials, all from the launch environment. Remote auth therefore needs both a
|
||||||
|
file-copy method and environment-variable methods.
|
||||||
|
|
||||||
|
Which token file exists depends on the method the user chose, so a file-copy
|
||||||
|
method must tolerate absent entries rather than requiring all of them.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
The server logs to stderr in absl format at INFO by default. The two lines worth
|
||||||
|
recognising are `Gemini home resolved to <path>` at startup, which reports the
|
||||||
|
effective config root, and `Localharness not found.`, which is the only signal
|
||||||
|
of the partial-install state described above.
|
||||||
|
|
||||||
|
`--debug` raises verbosity. Kandev does not pass it; a user debugging an install
|
||||||
|
runs the binary directly.
|
||||||
|
|
||||||
|
## Deferred work
|
||||||
|
|
||||||
|
The requirements' `## Out of scope` names these exclusions; this is what a
|
||||||
|
follow-up would have to decide.
|
||||||
|
|
||||||
|
**Provisioning the artifact.** A five-platform URL matrix, where the cache lives,
|
||||||
|
how integrity is verified given Google publishes no checksum beside the archive,
|
||||||
|
how a rotating build-stamped URL is refreshed, and whether a ~894 MB extraction
|
||||||
|
is per-host or shared. Until that exists no image ships the binary, so the
|
||||||
|
containerized, Kubernetes, SSH and Sprites executors report it not found.
|
||||||
|
|
||||||
|
**A variable config root.** Supporting a non-default `GEMINI_HOME` means making
|
||||||
|
the declared template environment-derived, which the static `SessionDirTemplate`
|
||||||
|
contract shared by every agent does not allow today. Setting it per task or
|
||||||
|
session would additionally isolate credentials, forcing a fresh login per task,
|
||||||
|
and would break the resume property REQ-AGENTS-ANTIGRAVITY-ACP-004 depends on.
|
||||||
|
|
||||||
|
**Multi-variable remote auth.** `agent-platform` also resolves via
|
||||||
|
`GOOGLE_CLOUD_PROJECT` plus `GOOGLE_CLOUD_LOCATION` with Application Default
|
||||||
|
Credentials. A remote-auth method carries a single `EnvVar`, so that combination
|
||||||
|
cannot be expressed and an ADC-only remote host has no auth path until the method
|
||||||
|
shape grows.
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
|
||||||
|
None. This design records an external vendor contract and introduces no new
|
||||||
|
Kandev architecture boundary.
|
||||||
Reference in New Issue
Block a user