diff --git a/README.md b/README.md index 91d137d..ea95e9a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,19 @@ onecli auth status Check current auth state Authentication is only required when the server enforces it. In local mode, commands work without logging in. +### Run + +``` +onecli run -- [args...] Run a command with gateway access +onecli run --agent my-agent -- claude One-off run as a specific agent +``` + +`onecli run` launches any command behind the OneCLI gateway — proxy and CA +trust are configured automatically, and credentials are injected at the +gateway. The agent identity resolves as `--agent` flag > `ONECLI_AGENT` env > +`onecli config set agent `; when none is set, the project's +default agent is used. + ### Config ``` @@ -151,12 +164,22 @@ onecli config get Read config value onecli config set Write config value ``` +Keys: + +| Key | Description | +|-----|-------------| +| `api-host` | API base URL (default: `https://api.onecli.sh`) | +| `project` | Project slug used when no `--project` flag is given | +| `agent` | Agent identifier `onecli run` uses when no `--agent` flag is given (pins this machine to an agent) | + ## Environment Variables | Variable | Description | |----------|-------------| | `ONECLI_API_KEY` | API key (overrides stored key) | | `ONECLI_API_HOST` | API base URL (default: `https://api.onecli.sh`) | +| `ONECLI_PROJECT` | Project slug (overrides the `project` config key) | +| `ONECLI_AGENT` | Agent identifier for `onecli run` (overrides the `agent` config key) | | `ONECLI_ENV` | `dev` or `production` | ## Output diff --git a/cmd/onecli/config.go b/cmd/onecli/config.go index 34031cb..a69f598 100644 --- a/cmd/onecli/config.go +++ b/cmd/onecli/config.go @@ -13,7 +13,7 @@ type ConfigCmd struct { // ConfigGetCmd is `onecli config get `. type ConfigGetCmd struct { - Key string `arg:"" required:"" help:"Config key to read. Keys: api-host."` + Key string `arg:"" required:"" help:"Config key to read. Keys: api-host, project, agent."` } // ConfigGetResponse is the JSON output of config get. @@ -32,7 +32,7 @@ func (c *ConfigGetCmd) Run(out *output.Writer) error { // ConfigSetCmd is `onecli config set `. type ConfigSetCmd struct { - Key string `arg:"" required:"" help:"Config key to set. Keys: api-host."` + Key string `arg:"" required:"" help:"Config key to set. Keys: api-host, project, agent."` Value string `arg:"" required:"" help:"Value to set."` } diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index b1561ae..61d9c5a 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -42,9 +42,10 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "run", Description: "Run a command with OneCLI gateway access.", Args: []ArgInfo{ {Name: "", Required: true, Description: "Command to execute (e.g. claude, cursor, codex)."}, {Name: "--project, -p", Description: "Project slug."}, - {Name: "--agent", Description: "OneCLI agent identifier (uses default if omitted)."}, + {Name: "--agent", Description: "OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."}, {Name: "--gateway", Description: "Gateway host:port override (default: derived from API host)."}, {Name: "--no-ca", Description: "Skip CA cert write and CA trust env injection."}, + {Name: "--enforce", Description: "OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."}, {Name: "--dry-run", Description: "Print resolved env and command without executing."}, }}, {Name: "agents list", Description: "List all agents.", Args: []ArgInfo{ @@ -449,8 +450,8 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { }}, {Name: "auth api-key", Description: "Show your current API key."}, {Name: "auth regenerate-api-key", Description: "Regenerate your API key."}, - {Name: "config get ", Description: "Get a config value."}, - {Name: "config set ", Description: "Set a config value."}, + {Name: "config get ", Description: "Get a config value. Keys: api-host, project, agent."}, + {Name: "config set ", Description: "Set a config value. Keys: api-host, project, agent."}, {Name: "migrate", Description: "Migrate data to OneCLI Cloud.", Args: []ArgInfo{ {Name: "--cloud-key", Required: true, Description: "OneCLI Cloud API key."}, }}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 39276f0..6c08b55 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -179,6 +179,24 @@ func resolveProject(flag string) (string, error) { return v, nil } +// resolveAgent returns the agent identifier from the flag value, falling back +// to config (ONECLI_AGENT env var > config file). Empty means the project's +// server-side default agent. Returns an error if the resolved value fails +// input validation. +func resolveAgent(flag string) (string, error) { + v := flag + if v == "" { + v = config.Agent() + } + if v == "" { + return "", nil + } + if err := validate.ResourceID(v); err != nil { + return "", fmt.Errorf("invalid agent identifier: %w", err) + } + return v, nil +} + // hintForCommand returns a contextual hint message based on the active command group. func hintForCommand(cmd, host string) string { group := strings.SplitN(cmd, " ", 2)[0] diff --git a/cmd/onecli/main_test.go b/cmd/onecli/main_test.go index d6337a7..c4b1c31 100644 --- a/cmd/onecli/main_test.go +++ b/cmd/onecli/main_test.go @@ -2,6 +2,8 @@ package main import ( "testing" + + "github.com/onecli/onecli-cli/internal/config" ) func TestResolveProjectFlagTakesPrecedence(t *testing.T) { @@ -68,3 +70,86 @@ func TestResolveProjectRejectsInvalidEnvValue(t *testing.T) { t.Error("resolveProject should reject invalid env value") } } + +func TestResolveAgentFlagTakesPrecedence(t *testing.T) { + t.Setenv("ONECLI_AGENT", "env-agent") + got, err := resolveAgent("flag-agent") + if err != nil { + t.Fatal(err) + } + if got != "flag-agent" { + t.Errorf("resolveAgent(flag-agent) = %q, want flag-agent", got) + } +} + +func TestResolveAgentFallsBackToEnv(t *testing.T) { + t.Setenv("ONECLI_AGENT", "env-agent") + got, err := resolveAgent("") + if err != nil { + t.Fatal(err) + } + if got != "env-agent" { + t.Errorf("resolveAgent(\"\") = %q, want env-agent", got) + } +} + +func TestResolveAgentFallsBackToConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_ENV", "") + t.Setenv("ONECLI_AGENT", "") + + if err := config.SetConfigValue("agent", "pinned-agent"); err != nil { + t.Fatal(err) + } + got, err := resolveAgent("") + if err != nil { + t.Fatal(err) + } + if got != "pinned-agent" { + t.Errorf("resolveAgent(\"\") = %q, want pinned-agent", got) + } +} + +func TestResolveAgentEmptyWhenUnset(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_AGENT", "") + t.Setenv("ONECLI_ENV", "") + + got, err := resolveAgent("") + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("resolveAgent(\"\") = %q, want empty (server default)", got) + } +} + +func TestResolveAgentRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + flag string + }{ + {"path traversal", "../etc/passwd"}, + {"query injection", "agent?foo=bar"}, + {"percent encoding", "agent%2e"}, + {"control chars", "agent\x00"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveAgent(tt.flag) + if err == nil { + t.Errorf("resolveAgent(%q) should return error", tt.flag) + } + }) + } +} + +func TestResolveAgentRejectsInvalidEnvValue(t *testing.T) { + t.Setenv("ONECLI_AGENT", "bad agent") + _, err := resolveAgent("") + if err == nil { + t.Error("resolveAgent should reject invalid env value") + } +} diff --git a/cmd/onecli/run.go b/cmd/onecli/run.go index 0d0f8f2..7daf915 100644 --- a/cmd/onecli/run.go +++ b/cmd/onecli/run.go @@ -20,7 +20,6 @@ import ( "github.com/onecli/onecli-cli/internal/api" "github.com/onecli/onecli-cli/internal/config" "github.com/onecli/onecli-cli/pkg/output" - "github.com/onecli/onecli-cli/pkg/validate" "gopkg.in/yaml.v3" ) @@ -43,7 +42,7 @@ var caShimSource string // RunCmd is `onecli run -- [args...]`. type RunCmd struct { Project string `optional:"" short:"p" help:"Project slug."` - Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (uses default agent if omitted)."` + Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."` Gateway string `optional:"" name:"gateway" help:"Gateway host:port override (default: derived from API host)."` NoCA bool `optional:"" name:"no-ca" help:"Skip writing the CA cert and CA trust env injection."` Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."` @@ -56,11 +55,12 @@ func (c *RunCmd) Run(out *output.Writer) error { return fmt.Errorf("no command specified: use 'onecli run -- [args...]'") } - // Validate agent identifier if provided. - if c.Agent != "" { - if err := validate.ResourceID(c.Agent); err != nil { - return fmt.Errorf("invalid agent identifier: %w", err) - } + // Resolve the agent identity: --agent flag > ONECLI_AGENT env > + // `onecli config set agent ...` (the machine-local pin); empty means the + // project's server-side default agent. Validated at the boundary. + agent, err := resolveAgent(c.Agent) + if err != nil { + return err } // Resolve the binary path early — fail fast before the API round-trip. @@ -74,7 +74,7 @@ func (c *RunCmd) Run(out *output.Writer) error { if err != nil { return err } - cfg, err := client.GetContainerConfig(newContext(), c.Agent) + cfg, err := client.GetContainerConfig(newContext(), agent) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 48d10e3..4ae9731 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/onecli/onecli-cli/pkg/validate" ) const ( @@ -13,6 +15,7 @@ const ( apiKeyVar = "ONECLI_API_KEY" apiHostVar = "ONECLI_API_HOST" projectVar = "ONECLI_PROJECT" + agentVar = "ONECLI_AGENT" envProduction = "production" envDev = "dev" @@ -78,6 +81,22 @@ func Project() string { return cfg["project"] } +// Agent returns the configured agent identifier, or empty string if not set. +// This is the machine-local agent pin ("this terminal runs as writer-bot"), +// used by `onecli run` when no --agent flag is given; empty means the +// project's server-side default agent. +// Precedence: ONECLI_AGENT env var > config file > empty. +func Agent() string { + if v := os.Getenv(agentVar); v != "" { + return v + } + cfg, err := readConfig() + if err != nil { + return "" + } + return cfg["agent"] +} + // KeychainService returns the keychain service name for API key storage. func KeychainService() string { if IsDev() { @@ -115,12 +134,14 @@ var ErrInvalidConfigValue = errors.New("invalid config value") var validKeys = map[string]func(string) error{ "api-host": validateURL, "project": nil, + "agent": validate.ResourceID, } // configDefaults maps each config key to its default value. var configDefaults = map[string]string{ "api-host": defaultAPIHost, "project": "", + "agent": "", } func validateURL(u string) error { @@ -200,6 +221,8 @@ func GetConfigValue(key string) (string, error) { return APIHost(), nil case "project": return Project(), nil + case "agent": + return Agent(), nil } cfg, err := readConfig() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b1aa52b..6697197 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -204,6 +204,81 @@ func TestGetConfigValueProjectRespectsEnv(t *testing.T) { } } +func TestAgentDefault(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_AGENT", "") + + if got := Agent(); got != "" { + t.Errorf("Agent() = %q, want empty", got) + } +} + +func TestAgentEnvOverride(t *testing.T) { + t.Setenv("ONECLI_AGENT", "env-agent") + if got := Agent(); got != "env-agent" { + t.Errorf("Agent() = %q, want env-agent", got) + } +} + +func TestAgentFromConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_ENV", "") + t.Setenv("ONECLI_AGENT", "") + + if err := SetConfigValue("agent", "file-agent"); err != nil { + t.Fatal(err) + } + if got := Agent(); got != "file-agent" { + t.Errorf("Agent() = %q, want file-agent", got) + } +} + +func TestAgentEnvTakesPrecedenceOverFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_ENV", "") + + _ = SetConfigValue("agent", "file-agent") + t.Setenv("ONECLI_AGENT", "env-agent") + + if got := Agent(); got != "env-agent" { + t.Errorf("Agent() = %q, env var should take precedence", got) + } +} + +func TestGetConfigValueAgentRespectsEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_ENV", "") + + _ = SetConfigValue("agent", "file-agent") + t.Setenv("ONECLI_AGENT", "env-agent") + + val, err := GetConfigValue("agent") + if err != nil { + t.Fatal(err) + } + if val != "env-agent" { + t.Errorf("GetConfigValue(agent) = %q, env var should take precedence", val) + } +} + +func TestSetConfigValueAgentRejectsInvalidIdentifier(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ONECLI_ENV", "") + + // The agent value reaches URLs and shell contexts downstream — reject + // hardened-input violations at the boundary, like the --agent flag does. + for _, bad := range []string{"", "two words", "a?b", "a%20b", "../etc"} { + if err := SetConfigValue("agent", bad); !errors.Is(err, ErrInvalidConfigValue) { + t.Errorf("SetConfigValue(agent, %q) = %v, want ErrInvalidConfigValue", bad, err) + } + } +} + func TestGetConfigValueDefaultWhenNoFile(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir)