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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cmd/onecli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ func (c *RunCmd) Run(out *output.Writer) error {
maybeInstallGatewayPlugin(out, a.agentName, a.baseDir)
}

// Agents that refuse to start without a provider key (e.g. OpenClaw)
// get a placeholder — the gateway swaps in the real key per request.
// A key already in the user's shell wins (it passes through
// buildChildEnv today for every agent, and the gateway replaces it on
// the wire either way).
if a.needsAnthropicKey {
env = ensureEnv(env, "ANTHROPIC_API_KEY", anthropicKeyPlaceholder)
}

// Electron-based agents (e.g. Cursor) ignore embedded user:pass in
// HTTPS_PROXY and show a native auth dialog. Inject proxy credentials
// into the app's VS Code-style settings.json instead.
Expand Down Expand Up @@ -451,6 +460,7 @@ type agentSpec struct {
skipHook bool // true for agents that don't support Claude Code-style UserPromptSubmit hooks.
pluginGateway bool // true for agents that load the transform_tool_result recovery plugin (e.g. Hermes).
dockerSandbox bool // true for agents that run tools in a Docker sandbox needing TERMINAL_DOCKER_* injection.
needsAnthropicKey bool // true for agents that refuse to start without a provider key in the env (e.g. OpenClaw); a placeholder is ensured, the gateway swaps in the real key per request.
nativeProxyConfig string // home-relative dir with a TOML config needing proxy_url injection (e.g. ".codex").
hooksFile string // home-relative hook registration file; empty means Claude Code-style <baseDir>/settings.json.
}
Expand All @@ -465,6 +475,32 @@ var supportedAgents = []struct {
{[]string{"codex"}, agentSpec{agentName: "Codex", baseDir: ".agents", nativeProxyConfig: ".codex", hooksFile: ".codex/hooks.json"}},
{[]string{"hermes"}, agentSpec{agentName: "Hermes", baseDir: ".hermes", skipHook: true, pluginGateway: true, dockerSandbox: true}},
{[]string{"opencode"}, agentSpec{agentName: "OpenCode", baseDir: ".opencode"}},
// OpenClaw loads skills from ~/.openclaw/skills; its hook system is its
// own (not Claude-style settings.json), so the hook install is skipped.
// Its long-lived process is `openclaw gateway run`, and it honors the
// injected proxy env via undici's EnvHttpProxyAgent.
{[]string{"openclaw"}, agentSpec{agentName: "OpenClaw", baseDir: ".openclaw", skipHook: true, needsAnthropicKey: true}},
}

// anthropicKeyPlaceholder satisfies agents that refuse to start without a
// provider key in their environment (needsAnthropicKey). It is never a real
// credential: the gateway replaces x-api-key on every injected request, so
// this value exists only to pass the agent's local boot check — and is
// self-describing if it ever surfaces in an upstream 401.
const anthropicKeyPlaceholder = "sk-ant-onecli-gateway-placeholder"

// ensureEnv appends key=value when key is absent. An existing entry wins —
// POSIX getenv returns the first match, and a user's own shell value (which
// buildChildEnv deliberately passes through) must keep doing what it does
// today for every agent.
func ensureEnv(env []string, key, value string) []string {
prefix := key + "="
for _, kv := range env {
if strings.HasPrefix(kv, prefix) {
return env
}
}
return append(env, prefix+value)
}

// agentSkillDir returns the integration spec for a known agent command, or
Expand Down
25 changes: 25 additions & 0 deletions cmd/onecli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func TestAgentSkillDir(t *testing.T) {
{"codex", agentSpec{agentName: "Codex", baseDir: ".agents", nativeProxyConfig: ".codex", hooksFile: ".codex/hooks.json"}, true},
{"hermes", agentSpec{agentName: "Hermes", baseDir: ".hermes", skipHook: true, pluginGateway: true, dockerSandbox: true}, true},
{"opencode", agentSpec{agentName: "OpenCode", baseDir: ".opencode"}, true},
{"openclaw", agentSpec{agentName: "OpenClaw", baseDir: ".openclaw", skipHook: true, needsAnthropicKey: true}, true},
{"/usr/local/bin/cursor", agentSpec{agentName: "Cursor", baseDir: ".cursor", configDir: "Cursor"}, true},
{"unknown", agentSpec{}, false},
}
Expand All @@ -167,6 +168,30 @@ func TestAgentSkillDir(t *testing.T) {
}
}

func TestEnsureEnv(t *testing.T) {
t.Run("appends when absent", func(t *testing.T) {
env := ensureEnv([]string{"HOME=/h"}, "ANTHROPIC_API_KEY", anthropicKeyPlaceholder)
if v, ok := envValue(env, "ANTHROPIC_API_KEY"); !ok || v != anthropicKeyPlaceholder {
t.Errorf("ANTHROPIC_API_KEY = %q, want placeholder", v)
}
})
t.Run("an existing value wins (the user's shell key)", func(t *testing.T) {
env := ensureEnv([]string{"ANTHROPIC_API_KEY=sk-ant-real"}, "ANTHROPIC_API_KEY", anthropicKeyPlaceholder)
if len(env) != 1 {
t.Fatalf("env grew to %d entries; a duplicate would be ambiguous under POSIX first-match", len(env))
}
if v, _ := envValue(env, "ANTHROPIC_API_KEY"); v != "sk-ant-real" {
t.Errorf("ANTHROPIC_API_KEY = %q, want the existing value", v)
}
})
t.Run("does not match on key prefix", func(t *testing.T) {
env := ensureEnv([]string{"ANTHROPIC_API_KEY_BACKUP=x"}, "ANTHROPIC_API_KEY", anthropicKeyPlaceholder)
if v, ok := envValue(env, "ANTHROPIC_API_KEY"); !ok || v != anthropicKeyPlaceholder {
t.Errorf("ANTHROPIC_API_KEY = %q, want placeholder despite the prefixed sibling", v)
}
})
}

func TestProxyURLWithHost(t *testing.T) {
tests := []struct{ name, raw, host, want string }{
{"rewrites host keeping port+creds", "http://aoc_tok:x@127.0.0.1:10255", "host.docker.internal", "http://aoc_tok:x@host.docker.internal:10255"},
Expand Down
Loading