diff --git a/cmd/onecli/plugin_gateway_hermes.py b/cmd/onecli/plugin_gateway_hermes.py new file mode 100644 index 0000000..818e5a8 --- /dev/null +++ b/cmd/onecli/plugin_gateway_hermes.py @@ -0,0 +1,128 @@ +"""OneCLI Gateway plugin for Hermes. + +Intercepts tool results containing auth errors (NOT_AUTHENTICATED, missing +token, etc.) and appends gateway recovery instructions so the agent creates +credential stubs instead of following manual OAuth/API-key setup flows. +""" + +import json +import logging +import os +from typing import Any, Optional + +log = logging.getLogger("onecli-gateway") + +# Specific, low-false-positive markers — safe to match even on a successful +# result, since they almost never appear in benign tool output. +_AUTH_PATTERNS_STRICT = [ + "not_authenticated", + "no token at", + "oauth_client_disabled", + "token_revoked", + "token_invalid", + "token_corrupt", + "refresh_failed", +] + +# Looser natural-language phrases — only trusted when the tool actually errored, +# so a successful result that merely mentions them (an email subject, a web +# page) isn't rewritten with the recovery hint. +_AUTH_PATTERNS_LOOSE = [ + "not authenticated", + "authentication required", + "credentials not found", + "missing token", +] + +_RECOVERY_HINT = """ + +=== OneCLI Gateway: Auth Error Recovery === + +This auth error is handled by the OneCLI gateway proxy. +Do NOT follow OAuth, API-key, or App-Password setup instructions. + +STEP 1 — Create a stub credential file at the EXACT path named in the error +above (e.g. the path after "No token at ..."). Use "onecli-managed" for every +secret value and match the format the tool expects. Examples (substitute the +real path from the error for ): + + Google OAuth token file: + echo '{"type":"authorized_user","access_token":"onecli-managed","refresh_token":"onecli-managed","client_id":"onecli-managed","client_secret":"onecli-managed","token_uri":"https://oauth2.googleapis.com/token","expiry":"2099-01-01T00:00:00+00:00"}' > "" && chmod 600 "" + + Generic JSON credential / API-key file: + echo '{"api_key":"onecli-managed"}' > "" && chmod 600 "" + +STEP 2 — Retry the failed operation. The gateway replaces "onecli-managed" +placeholders with real credentials at the network level. + +STEP 3 — If the retry returns app_not_connected, show the user the +connect_url from the error response so they can connect the service. + +=== End OneCLI Gateway ===""" + + +def _is_gateway_active(): + proxy = os.environ.get("HTTPS_PROXY", "") + os.environ.get("https_proxy", "") + active = "aoc_" in proxy or os.environ.get("ONECLI_GATEWAY") == "true" + return active + + +def _result_to_str(result): + """Convert result to a searchable string regardless of type.""" + if isinstance(result, str): + return result + if isinstance(result, dict): + return json.dumps(result, default=str) + return str(result) if result is not None else "" + + +def _looks_like_error(status, error_type, error_message): + """Best-effort: did this tool call actually fail? Hermes passes these + fields to transform_tool_result; older versions may not, in which case we + fall back to strict-pattern matching only.""" + if error_type or error_message: + return True + if isinstance(status, str) and status.lower() not in ( + "", + "ok", + "success", + "succeeded", + "completed", + ): + return True + return False + + +def _has_auth_error(text, is_error): + lower = text.lower() + if any(p in lower for p in _AUTH_PATTERNS_STRICT): + return True + if is_error and any(p in lower for p in _AUTH_PATTERNS_LOOSE): + return True + return False + + +def _on_transform_tool_result( + tool_name: str = "", + args: Any = None, + result: Any = None, + status: Any = None, + error_type: Any = None, + error_message: Any = None, + **_: Any, +) -> Optional[str]: + if not _is_gateway_active(): + return None + text = _result_to_str(result) + is_error = _looks_like_error(status, error_type, error_message) + if not _has_auth_error(text, is_error): + return None + log.warning("OneCLI gateway intercepted auth error in %s, injecting recovery hint", tool_name) + if isinstance(result, str): + return result + _RECOVERY_HINT + return text + _RECOVERY_HINT + + +def register(ctx) -> None: + log.info("OneCLI gateway plugin registered (transform_tool_result)") + ctx.register_hook("transform_tool_result", _on_transform_tool_result) diff --git a/cmd/onecli/plugin_gateway_hermes.yaml b/cmd/onecli/plugin_gateway_hermes.yaml new file mode 100644 index 0000000..d076c93 --- /dev/null +++ b/cmd/onecli/plugin_gateway_hermes.yaml @@ -0,0 +1,6 @@ +name: onecli-gateway +version: "0.8.0" +description: "Intercepts auth errors and injects OneCLI gateway recovery instructions" +author: "OneCLI" +provides_hooks: + - transform_tool_result diff --git a/cmd/onecli/run.go b/cmd/onecli/run.go index 46b1d5d..e6eac45 100644 --- a/cmd/onecli/run.go +++ b/cmd/onecli/run.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "syscall" "time" @@ -19,6 +20,8 @@ import ( "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" ) //go:embed skill_gateway_fallback.md @@ -27,6 +30,15 @@ var gatewaySkillFallback string //go:embed hook_gateway_detect.sh var gatewayDetectHook string +//go:embed plugin_gateway_hermes.yaml +var hermesPluginManifest string + +//go:embed plugin_gateway_hermes.py +var hermesPluginHandler string + +//go:embed sitecustomize_onecli_ca.py +var caShimSource string + // RunCmd is `onecli run -- [args...]`. type RunCmd struct { Project string `optional:"" short:"p" help:"Project slug."` @@ -72,6 +84,13 @@ func (c *RunCmd) Run(out *output.Writer) error { if gatewayHost == "" { gatewayHost = resolveLocalGatewayHost() } + + // Derive the proxy URL Hermes' Docker sandbox should use, captured before + // rewriteProxyEnvHosts mutates cfg.Env. The sandbox reaches the gateway at + // the same host this process resolves it to — except a loopback host, which + // a container can't reach and must hit via host.docker.internal. + containerProxyURL := containerProxyURLFor(firstProxyURL(cfg.Env), gatewayHost) + rewriteProxyEnvHosts(cfg.Env, gatewayHost) // The gateway proxy injects the API key at the HTTP level (x-api-key header). @@ -123,36 +142,48 @@ func (c *RunCmd) Run(out *output.Writer) error { // For known agents, fetch the agent-specific skill variant and install // to the agent's skill directory. Also optionally register a hook. agentFramework := strings.ToLower(filepath.Base(c.Args[0])) - if name, dir, cfgDir, noHook, _, nativeProxy, ok := agentSkillDir(c.Args[0]); ok { + if a, ok := agentSkillDir(c.Args[0]); ok { skillContent := gatewaySkillFallback - if fetched, err := client.GetGatewaySkill(newContext()); err == nil && fetched != "" { + if fetched, err := client.GetGatewaySkill(newContext(), agentFramework); err == nil && fetched != "" { skillContent = fetched } - maybeInstallGatewaySkill(out, name, dir, skillContent) - if !noHook { - maybeInstallGatewayHook(out, name, dir) + maybeInstallGatewaySkill(out, a.agentName, a.baseDir, skillContent) + if !a.skipHook { + maybeInstallGatewayHook(out, a.agentName, a.baseDir) + } + if a.pluginGateway { + maybeInstallGatewayPlugin(out, a.agentName, a.baseDir) } // 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. - if cfgDir != "" { - env = injectElectronProxySettings(out, env, cfgDir, caPath) + if a.configDir != "" { + env = injectElectronProxySettings(out, env, a.configDir, caPath) } // Agents with a native proxy config (e.g. Codex) need proxy_url // written to their TOML config and CODEX_CA_CERTIFICATE set. - if nativeProxy != "" { - maybeInjectNativeProxyConfig(out, name, nativeProxy, env, caPath) + if a.nativeProxyConfig != "" { + maybeInjectNativeProxyConfig(out, a.agentName, a.nativeProxyConfig, env, caPath) } if agentFramework == "codex" { maybeCreateCodexAuthStub(out, client) } + + // Agents that run tools in a Docker sandbox (e.g. Hermes) don't inherit + // this process's proxy/CA env. Configure the sandbox via Hermes' + // TERMINAL_DOCKER_* env overrides, make the gateway CA trusted by + // certifi-pinned Python clients (httplib2) via a sitecustomize shim, + // and route — and thereby govern — the agent's own inference traffic. + if a.dockerSandbox { + env = applyHermesGateway(out, env, a.baseDir, caPath, containerProxyURL) + } } else { // Unknown agent — install the skill to ~/.onecli/skills/ so the // framework can discover it via ONECLI_GATEWAY_SKILL_PATH. skillContent := gatewaySkillFallback - if fetched, err := client.GetGatewaySkill(newContext()); err == nil && fetched != "" { + if fetched, err := client.GetGatewaySkill(newContext(), agentFramework); err == nil && fetched != "" { skillContent = fetched } if p := installUniversalGatewaySkill(out, skillContent); p != "" { @@ -298,6 +329,9 @@ func buildChildEnv(current []string, serverEnv map[string]string, caPath string) return out } +// proxyEnvKeys are the proxy URL env vars (both casings) the gateway sets. +var proxyEnvKeys = []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} + // dockerInternalHosts is the set of hostnames used inside Docker containers to // reach the host machine. These don't resolve from a local process. var dockerInternalHosts = map[string]bool{ @@ -347,60 +381,76 @@ func rewriteContainerHomeEnv(env map[string]string, home string) { // with the given local host, keeping the port and credentials intact. // Only rewrites values that look like proxy URLs (contain "://"). func rewriteProxyEnvHosts(env map[string]string, localHost string) { - proxyKeys := map[string]bool{ - "HTTPS_PROXY": true, "HTTP_PROXY": true, - "https_proxy": true, "http_proxy": true, - } for k, v := range env { - if !proxyKeys[k] { + if !slices.Contains(proxyEnvKeys, k) { continue } u, err := url.Parse(v) - if err != nil { - continue - } - if !dockerInternalHosts[u.Hostname()] { + if err != nil || !dockerInternalHosts[u.Hostname()] { continue } - port := u.Port() - if port != "" { - u.Host = localHost + ":" + port - } else { - u.Host = localHost - } - env[k] = u.String() + env[k] = proxyURLWithHost(v, localHost) } } -// supportedAgents maps CLI binary base-names to (agentName, skillsBaseDir) pairs. -var supportedAgents = []struct { - bases []string +// isLoopbackHost reports whether h is a loopback host a Docker container cannot +// reach directly (so it must go through host.docker.internal instead). +func isLoopbackHost(h string) bool { + switch strings.ToLower(h) { + case "localhost", "127.0.0.1", "::1", "[::1]": + return true + } + return false +} + +// containerProxyURLFor returns the proxy URL Hermes' Docker sandbox should use +// to reach the gateway. The gateway lives at gatewayHost (where this process +// reaches it): a container reaches a routable host directly, but a loopback +// host must be reached via host.docker.internal (paired with --add-host on +// Linux). serverProxy supplies the scheme, credentials, and port. +func containerProxyURLFor(serverProxy, gatewayHost string) string { + host := gatewayHost + if isLoopbackHost(host) { + host = "host.docker.internal" + } + return proxyURLWithHost(serverProxy, host) +} + +// agentSpec describes how `onecli run` integrates a known coding agent with the +// gateway: where its skill/hook/plugin files live and which injection +// strategies it needs. +type agentSpec struct { agentName string - baseDir string - configDir string // VS Code-style config dir name; non-empty enables proxy settings injection. - skipHook bool // true for agents that don't support Claude Code-style hooks. - hasPlugin bool // true for agents that support a transform_tool_result plugin. - nativeProxyConfig string // home-relative dir containing a TOML config that needs proxy_url injection (e.g. ".codex"). + baseDir string // home-relative config dir (skills/hooks/plugins live here) + configDir string // VS Code-style app dir name; non-empty enables Electron proxy-settings injection. + 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. + nativeProxyConfig string // home-relative dir with a TOML config needing proxy_url injection (e.g. ".codex"). +} + +// supportedAgents maps CLI binary base-names to their gateway integration spec. +var supportedAgents = []struct { + bases []string + spec agentSpec }{ - {[]string{"claude"}, "Claude Code", ".claude", "", false, false, ""}, - {[]string{"cursor", "agent"}, "Cursor", ".cursor", "Cursor", false, false, ""}, - {[]string{"codex"}, "Codex", ".agents", "", false, false, ".codex"}, - {[]string{"hermes"}, "Hermes", ".hermes", "", true, true, ""}, - {[]string{"opencode"}, "OpenCode", ".opencode", "", false, false, ""}, + {[]string{"claude"}, agentSpec{agentName: "Claude Code", baseDir: ".claude"}}, + {[]string{"cursor", "agent"}, agentSpec{agentName: "Cursor", baseDir: ".cursor", configDir: "Cursor"}}, + {[]string{"codex"}, agentSpec{agentName: "Codex", baseDir: ".agents", nativeProxyConfig: ".codex"}}, + {[]string{"hermes"}, agentSpec{agentName: "Hermes", baseDir: ".hermes", skipHook: true, pluginGateway: true, dockerSandbox: true}}, + {[]string{"opencode"}, agentSpec{agentName: "OpenCode", baseDir: ".opencode"}}, } -// agentSkillDir returns the display name, skills base directory, and config -// options for a known agent command, or ok=false if the command is not recognized. -func agentSkillDir(cmd string) (agentName, baseDir, configDir string, skipHook bool, hasPlugin bool, nativeProxyConfig string, ok bool) { +// agentSkillDir returns the integration spec for a known agent command, or +// ok=false if the command is not recognized. +func agentSkillDir(cmd string) (agentSpec, bool) { base := filepath.Base(cmd) for _, a := range supportedAgents { - for _, b := range a.bases { - if base == b { - return a.agentName, a.baseDir, a.configDir, a.skipHook, a.hasPlugin, a.nativeProxyConfig, true - } + if slices.Contains(a.bases, base) { + return a.spec, true } } - return "", "", "", false, false, "", false + return agentSpec{}, false } // maybeInstallGatewaySkill installs the OneCLI gateway skill file if it is @@ -541,6 +591,370 @@ func maybeInjectNativeProxyConfig(out *output.Writer, agentName, configRelDir st } } +// maybeInstallGatewayPlugin installs the Hermes transform_tool_result recovery +// plugin and enables it in ~/.hermes/config.yaml. The plugin runs in the agent +// process and appends gateway recovery guidance to any tool result that looks +// like an auth error, so the agent creates a credential stub instead of +// following a manual OAuth/API-key setup flow. +func maybeInstallGatewayPlugin(out *output.Writer, agentName, baseDir string) { + home, err := os.UserHomeDir() + if err != nil { + return + } + pluginDir := filepath.Join(home, baseDir, "plugins", "onecli-gateway") + + wroteManifest := writeIfChanged(out, filepath.Join(pluginDir, "plugin.yaml"), hermesPluginManifest) + wroteHandler := writeIfChanged(out, filepath.Join(pluginDir, "__init__.py"), hermesPluginHandler) + if wroteManifest || wroteHandler { + out.Stderr(fmt.Sprintf("onecli: installed gateway plugin for %s.", agentName)) + } + + // Plugins are opt-in: a plugin only loads if listed under plugins.enabled + // in config.yaml. Edit the file via a YAML round-trip so other settings and + // comments are preserved (no fragile string surgery). + configPath := filepath.Join(home, baseDir, "config.yaml") + if changed, err := enableHermesPlugin(configPath, "onecli-gateway"); err != nil { + out.Stderr(fmt.Sprintf("onecli: warning: could not enable gateway plugin: %v", err)) + } else if changed { + out.Stderr(fmt.Sprintf("onecli: enabled gateway plugin in %s config.", agentName)) + } +} + +// applyHermesGateway makes the gateway reach where Hermes actually sends +// traffic. Hermes runs its own LLM/inference on this host (httpx, which honors +// HTTPS_PROXY + SSL_CERT_FILE — already set by buildChildEnv), but runs *tools* +// in a separate Docker sandbox that inherits none of this process's env. It +// returns env extended with: (1) a CA-trust shim for certifi-pinned Python +// clients (httplib2 → Google Workspace), and (2) Hermes' TERMINAL_DOCKER_* +// overrides that push the proxy + CA + shim into the sandbox container (no +// config-file mutation; inert when terminal.backend != docker). +func applyHermesGateway(out *output.Writer, env []string, baseDir, caPath, containerProxyURL string) []string { + home, err := os.UserHomeDir() + if err != nil { + return env + } + cfg := readHermesConfig(filepath.Join(home, baseDir, "config.yaml")) + + // Host side: Hermes' inference (httpx) already trusts the gateway CA via + // SSL_CERT_FILE. Add HERMES_CA_BUNDLE (Hermes' native CA knob) and a + // sitecustomize shim so certifi-pinned clients (httplib2) trust it too — + // this also covers Google Workspace when terminal.backend is "local". + shimDir := "" + if caPath != "" { + env = append(env, "HERMES_CA_BUNDLE="+caPath, "ONECLI_CA_BUNDLE="+caPath) + if shimDir = installCAShim(out); shimDir != "" { + env = prependPythonPath(env, shimDir) + } + } + + // Inference governance: Hermes' model calls flow through the gateway, so + // OneCLI sees and can police them. Make that visible. + out.Stderr("onecli: Hermes inference is routed through the OneCLI gateway; " + + "under a deny-by-default policy, allow your model-provider host in OneCLI rules.") + + // Sandbox side: route Hermes' Docker tool-sandbox through the gateway via + // env-var overrides (merged with the user's config in hermesSandboxEnv). + return append(env, hermesSandboxEnv(cfg, caPath, shimDir, containerProxyURL)...) +} + +// hermesSandboxEnv returns the TERMINAL_DOCKER_* env overrides that route +// Hermes' Docker tool-sandbox through the gateway, merged with any docker_env / +// docker_volumes / docker_extra_args already in the user's config. It performs +// no I/O so it can be unit-tested. Disabling cross-process container reuse +// forces a fresh container that picks up the proxy + CA (Hermes reuses by label +// and ignores env/mount changes; on-disk filesystem persistence is unaffected). +func hermesSandboxEnv(cfg hermesConfig, caPath, shimDir, containerProxyURL string) []string { + const containerCA = "/etc/ssl/onecli-ca.pem" + const containerShim = "/opt/onecli-pyca" + + dockerEnv := map[string]string{"ONECLI_GATEWAY": "true"} + for k, v := range cfg.Terminal.DockerEnv { + dockerEnv[k] = fmt.Sprint(v) + } + if containerProxyURL != "" { + for _, k := range proxyEnvKeys { + dockerEnv[k] = containerProxyURL + } + } + if caPath != "" { + for _, k := range []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "GIT_SSL_CAINFO", "ONECLI_CA_BUNDLE"} { + dockerEnv[k] = containerCA + } + } + // Prepend the CA shim to PYTHONPATH (container path separator is always ":"), + // preserving any PYTHONPATH the user set in docker_env. Only when the shim is + // actually mounted (shimDir != "") — otherwise the path wouldn't exist. + if shimDir != "" { + if existing := dockerEnv["PYTHONPATH"]; existing != "" { + dockerEnv["PYTHONPATH"] = containerShim + ":" + existing + } else { + dockerEnv["PYTHONPATH"] = containerShim + } + } + + volumes := append([]string{}, cfg.Terminal.DockerVolumes...) + if caPath != "" { + if caVol := caPath + ":" + containerCA + ":ro"; !slices.Contains(volumes, caVol) { + volumes = append(volumes, caVol) + } + if shimDir != "" { + if shimVol := shimDir + ":" + containerShim + ":ro"; !slices.Contains(volumes, shimVol) { + volumes = append(volumes, shimVol) + } + } + } + + // --add-host is only needed when the sandbox reaches the gateway via + // host.docker.internal (Linux doesn't resolve that name automatically). + // For a routable gateway host the container connects directly, so skip it. + extraArgs := append([]string{}, cfg.Terminal.DockerExtraArgs...) + if runtime.GOOS == "linux" && proxyURLHostname(containerProxyURL) == "host.docker.internal" && + !slices.Contains(extraArgs, "host.docker.internal:host-gateway") { + extraArgs = append(extraArgs, "--add-host", "host.docker.internal:host-gateway") + } + + var out []string + if b, err := json.Marshal(dockerEnv); err == nil { + out = append(out, "TERMINAL_DOCKER_ENV="+string(b)) + } + if b, err := json.Marshal(volumes); err == nil { + out = append(out, "TERMINAL_DOCKER_VOLUMES="+string(b)) + } + if b, err := json.Marshal(extraArgs); err == nil { + out = append(out, "TERMINAL_DOCKER_EXTRA_ARGS="+string(b)) + } + return append(out, "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES=false") +} + +// hermesConfig is the subset of ~/.hermes/config.yaml we read (best-effort) to +// merge sandbox settings without clobbering the user's. +type hermesConfig struct { + Terminal struct { + DockerEnv map[string]any `yaml:"docker_env"` + DockerVolumes []string `yaml:"docker_volumes"` + DockerExtraArgs []string `yaml:"docker_extra_args"` + } `yaml:"terminal"` +} + +func readHermesConfig(configPath string) hermesConfig { + var cfg hermesConfig + if data, err := os.ReadFile(configPath); err == nil { + _ = yaml.Unmarshal(data, &cfg) // best-effort; absent keys stay zero + } + return cfg +} + +// enableHermesPlugin adds name to plugins.enabled in a Hermes config.yaml, +// preserving the rest of the document (keys, order, comments) via a yaml.Node +// round-trip. Returns whether the file was changed. +func enableHermesPlugin(configPath, name string) (bool, error) { + data, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return false, err + } + if os.IsNotExist(err) || len(bytes.TrimSpace(data)) == 0 { + // Hermes deep-merges defaults at load, so a minimal file is sufficient. + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return false, err + } + content := "plugins:\n enabled:\n - " + name + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + return false, err + } + return true, nil + } + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return false, fmt.Errorf("parsing config.yaml: %w", err) + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return false, fmt.Errorf("unexpected config.yaml structure") + } + root := doc.Content[0] + + // Duplicate top-level keys are ambiguous: yaml.v3 keeps both, but Hermes' + // loader is last-key-wins — editing the first block would silently fail to + // enable the plugin. Refuse rather than report a false success. + if yamlMapCount(root, "plugins") > 1 { + return false, fmt.Errorf("config.yaml has duplicate top-level 'plugins' keys; enable onecli-gateway manually") + } + + plugins := yamlMapGet(root, "plugins") + if plugins == nil || plugins.Kind != yaml.MappingNode { + plugins = &yaml.Node{Kind: yaml.MappingNode} + yamlMapSet(root, "plugins", plugins) + } else if yamlMapCount(plugins, "enabled") > 1 { + return false, fmt.Errorf("config.yaml has duplicate 'plugins.enabled' keys; enable onecli-gateway manually") + } + + enabled := yamlMapGet(plugins, "enabled") + switch { + case enabled == nil: + enabled = &yaml.Node{Kind: yaml.SequenceNode} + yamlMapSet(plugins, "enabled", enabled) + case enabled.Kind == yaml.ScalarNode && enabled.Value != "" && enabled.Tag != "!!null": + // Single-scalar form (`enabled: foo`): promote to a sequence, keeping + // the user's existing value instead of dropping it. Explicit nulls + // (`enabled: null` / `~`) are tagged !!null with a non-empty Value, so + // they're excluded here and fall through to the fresh-sequence case. + kept := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: enabled.Value} + enabled = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{kept}} + yamlMapSet(plugins, "enabled", enabled) + case enabled.Kind != yaml.SequenceNode: + // null / mapping / other — replace with a fresh sequence. + enabled = &yaml.Node{Kind: yaml.SequenceNode} + yamlMapSet(plugins, "enabled", enabled) + } + for _, item := range enabled.Content { + if item.Value == name { + return false, nil + } + } + enabled.Content = append(enabled.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}) + + encoded, err := yaml.Marshal(&doc) + if err != nil { + return false, err + } + if err := os.WriteFile(configPath, encoded, 0o600); err != nil { + return false, err + } + return true, nil +} + +// yamlMapGet returns the value node for key in a YAML mapping node, or nil. +func yamlMapGet(m *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// yamlMapCount returns how many times key appears in a YAML mapping node. +func yamlMapCount(m *yaml.Node, key string) int { + n := 0 + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + n++ + } + } + return n +} + +// yamlMapSet sets key=val in a YAML mapping node, appending if absent. +func yamlMapSet(m *yaml.Node, key string, val *yaml.Node) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content[i+1] = val + return + } + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, val) +} + +// installCAShim writes the embedded sitecustomize CA shim to ~/.onecli/pyca/ +// and returns that directory (mountable into the sandbox), or "" on failure. +func installCAShim(out *output.Writer) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir := filepath.Join(home, ".onecli", "pyca") + path := filepath.Join(dir, "sitecustomize.py") + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, []byte(caShimSource)) { + return dir + } + if err := os.MkdirAll(dir, 0o755); err != nil { + out.Stderr(fmt.Sprintf("onecli: warning: could not create CA shim dir: %v", err)) + return "" + } + if err := os.WriteFile(path, []byte(caShimSource), 0o644); err != nil { + out.Stderr(fmt.Sprintf("onecli: warning: could not write CA shim: %v", err)) + return "" + } + return dir +} + +// writeIfChanged writes content to path (creating parent dirs) unless the file +// already holds exactly content. Returns whether it wrote. +func writeIfChanged(out *output.Writer, path, content string) bool { + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, []byte(content)) { + return false + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + out.Stderr(fmt.Sprintf("onecli: warning: could not create %s: %v", filepath.Dir(path), err)) + return false + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + out.Stderr(fmt.Sprintf("onecli: warning: could not write %s: %v", path, err)) + return false + } + return true +} + +// firstProxyURL returns the first proxy URL set in env (any casing), or "". +func firstProxyURL(env map[string]string) string { + for _, k := range proxyEnvKeys { + if v := env[k]; v != "" { + return v + } + } + return "" +} + +// proxyURLWithHost rewrites the host of a proxy URL, preserving scheme, +// credentials, and port. Returns "" for empty input. +func proxyURLWithHost(raw, host string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return raw + } + if p := u.Port(); p != "" { + u.Host = host + ":" + p + } else { + u.Host = host + } + return u.String() +} + +// proxyURLHostname returns the hostname of a proxy URL, or "". +func proxyURLHostname(raw string) string { + if raw == "" { + return "" + } + if u, err := url.Parse(raw); err == nil { + return u.Hostname() + } + return "" +} + +// prependPythonPath ensures dir is the first entry on PYTHONPATH in env, +// comparing whole path elements (not substrings) so a prefix collision doesn't +// wrongly suppress it. +func prependPythonPath(env []string, dir string) []string { + const key = "PYTHONPATH=" + sep := string(os.PathListSeparator) + for i, kv := range env { + if strings.HasPrefix(kv, key) { + switch existing := kv[len(key):]; { + case existing == "": + env[i] = key + dir + case !slices.Contains(strings.Split(existing, sep), dir): + env[i] = key + dir + sep + existing + } + return env + } + } + return append(env, key+dir) +} + // maybeInstallGatewayHook installs the gateway detection hook script and // registers it in the agent's settings.json so the agent knows the gateway // is active without needing to run any visible checks. @@ -667,7 +1081,7 @@ func injectElectronProxySettings(out *output.Writer, env []string, configDir str } func findProxyURL(env []string) string { - for _, key := range []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} { + for _, key := range proxyEnvKeys { prefix := key + "=" for _, kv := range env { if strings.HasPrefix(kv, prefix) { @@ -736,14 +1150,10 @@ func mergeVSCodeProxySettings(path, proxyURL, authHeader string, terminalEnv map } func stripProxyCredentials(env []string) []string { - proxyKeys := map[string]bool{ - "HTTPS_PROXY": true, "HTTP_PROXY": true, - "https_proxy": true, "http_proxy": true, - } result := make([]string, 0, len(env)) for _, kv := range env { i := strings.IndexByte(kv, '=') - if i < 0 || !proxyKeys[kv[:i]] { + if i < 0 || !slices.Contains(proxyEnvKeys, kv[:i]) { result = append(result, kv) continue } diff --git a/cmd/onecli/run_test.go b/cmd/onecli/run_test.go index 80f2fe6..c5d278c 100644 --- a/cmd/onecli/run_test.go +++ b/cmd/onecli/run_test.go @@ -5,8 +5,11 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestFindProxyURL(t *testing.T) { @@ -133,50 +136,318 @@ func TestRewriteContainerHomeEnv(t *testing.T) { func TestAgentSkillDir(t *testing.T) { tests := []struct { - cmd string - wantName string - wantDir string - wantCfg string - wantSkipHook bool - wantPlugin bool - wantNativeProxy string - wantOK bool + cmd string + want agentSpec + ok bool }{ - {"claude", "Claude Code", ".claude", "", false, false, "", true}, - {"cursor", "Cursor", ".cursor", "Cursor", false, false, "", true}, - {"agent", "Cursor", ".cursor", "Cursor", false, false, "", true}, - {"codex", "Codex", ".agents", "", false, false, ".codex", true}, - {"hermes", "Hermes", ".hermes", "", true, true, "", true}, - {"opencode", "OpenCode", ".opencode", "", false, false, "", true}, - {"/usr/local/bin/cursor", "Cursor", ".cursor", "Cursor", false, false, "", true}, - {"unknown", "", "", "", false, false, "", false}, + {"claude", agentSpec{agentName: "Claude Code", baseDir: ".claude"}, true}, + {"cursor", agentSpec{agentName: "Cursor", baseDir: ".cursor", configDir: "Cursor"}, true}, + {"agent", agentSpec{agentName: "Cursor", baseDir: ".cursor", configDir: "Cursor"}, true}, + {"codex", agentSpec{agentName: "Codex", baseDir: ".agents", nativeProxyConfig: ".codex"}, true}, + {"hermes", agentSpec{agentName: "Hermes", baseDir: ".hermes", skipHook: true, pluginGateway: true, dockerSandbox: true}, true}, + {"opencode", agentSpec{agentName: "OpenCode", baseDir: ".opencode"}, true}, + {"/usr/local/bin/cursor", agentSpec{agentName: "Cursor", baseDir: ".cursor", configDir: "Cursor"}, true}, + {"unknown", agentSpec{}, false}, } for _, tt := range tests { t.Run(tt.cmd, func(t *testing.T) { - name, dir, cfg, skipHook, plugin, nativeProxy, ok := agentSkillDir(tt.cmd) - if ok != tt.wantOK { - t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + got, ok := agentSkillDir(tt.cmd) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) } - if name != tt.wantName { - t.Errorf("name = %q, want %q", name, tt.wantName) + if got != tt.want { + t.Errorf("spec = %+v, want %+v", got, tt.want) } - if dir != tt.wantDir { - t.Errorf("dir = %q, want %q", dir, tt.wantDir) + }) + } +} + +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"}, + {"no port", "http://aoc_tok@localhost", "host.docker.internal", "http://aoc_tok@host.docker.internal"}, + {"empty input", "", "host.docker.internal", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := proxyURLWithHost(tt.raw, tt.host); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) } - if cfg != tt.wantCfg { - t.Errorf("configDir = %q, want %q", cfg, tt.wantCfg) + }) + } +} + +func TestPrependPythonPath(t *testing.T) { + sep := string(os.PathListSeparator) + t.Run("absent appends", func(t *testing.T) { + got := prependPythonPath([]string{"HOME=/h"}, "/shim") + if v, _ := envValue(got, "PYTHONPATH"); v != "/shim" { + t.Errorf("PYTHONPATH = %q, want /shim", v) + } + }) + t.Run("existing prepends", func(t *testing.T) { + got := prependPythonPath([]string{"PYTHONPATH=/a" + sep + "/b"}, "/shim") + want := "/shim" + sep + "/a" + sep + "/b" + if v, _ := envValue(got, "PYTHONPATH"); v != want { + t.Errorf("PYTHONPATH = %q, want %q", v, want) + } + }) + t.Run("idempotent when already present", func(t *testing.T) { + got := prependPythonPath([]string{"PYTHONPATH=/shim" + sep + "/a"}, "/shim") + if v, _ := envValue(got, "PYTHONPATH"); v != "/shim"+sep+"/a" { + t.Errorf("PYTHONPATH = %q", v) + } + }) +} + +func TestHermesSandboxEnv(t *testing.T) { + var cfg hermesConfig + cfg.Terminal.DockerEnv = map[string]any{"FOO": "bar"} + cfg.Terminal.DockerVolumes = []string{"/data:/data"} + + env := hermesSandboxEnv(cfg, "/home/u/.onecli/ca-bundle.pem", "/home/u/.onecli/pyca", + "http://aoc_t:x@host.docker.internal:10255") + + if v, _ := envValue(env, "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES"); v != "false" { + t.Errorf("persist = %q, want false", v) + } + + rawEnv, ok := envValue(env, "TERMINAL_DOCKER_ENV") + if !ok { + t.Fatal("TERMINAL_DOCKER_ENV missing") + } + var de map[string]string + if err := json.Unmarshal([]byte(rawEnv), &de); err != nil { + t.Fatalf("docker_env not valid JSON: %v", err) + } + if de["FOO"] != "bar" { + t.Errorf("user docker_env clobbered: FOO=%q", de["FOO"]) + } + if de["HTTPS_PROXY"] != "http://aoc_t:x@host.docker.internal:10255" { + t.Errorf("HTTPS_PROXY = %q", de["HTTPS_PROXY"]) + } + if de["SSL_CERT_FILE"] != "/etc/ssl/onecli-ca.pem" { + t.Errorf("SSL_CERT_FILE = %q", de["SSL_CERT_FILE"]) + } + if de["PYTHONPATH"] != "/opt/onecli-pyca" { + t.Errorf("PYTHONPATH = %q", de["PYTHONPATH"]) + } + if de["ONECLI_GATEWAY"] != "true" { + t.Errorf("ONECLI_GATEWAY = %q", de["ONECLI_GATEWAY"]) + } + + rawVol, _ := envValue(env, "TERMINAL_DOCKER_VOLUMES") + var vols []string + if err := json.Unmarshal([]byte(rawVol), &vols); err != nil { + t.Fatalf("volumes not valid JSON: %v", err) + } + if !slices.Contains(vols, "/data:/data") { + t.Errorf("user volume dropped: %v", vols) + } + if !slices.Contains(vols, "/home/u/.onecli/ca-bundle.pem:/etc/ssl/onecli-ca.pem:ro") { + t.Errorf("CA mount missing: %v", vols) + } +} + +func TestHermesSandboxEnv_NoCA(t *testing.T) { + env := hermesSandboxEnv(hermesConfig{}, "", "", "") + rawEnv, _ := envValue(env, "TERMINAL_DOCKER_ENV") + var de map[string]string + _ = json.Unmarshal([]byte(rawEnv), &de) + if _, ok := de["SSL_CERT_FILE"]; ok { + t.Errorf("should not set CA env when caPath empty: %v", de) + } + if de["ONECLI_GATEWAY"] != "true" { + t.Error("ONECLI_GATEWAY should still be set") + } +} + +func TestContainerProxyURLFor(t *testing.T) { + const server = "http://aoc_tok:x@host.docker.internal:10255" + tests := []struct{ name, gatewayHost, want string }{ + {"loopback 127.0.0.1 → host.docker.internal", "127.0.0.1", "http://aoc_tok:x@host.docker.internal:10255"}, + {"loopback localhost → host.docker.internal", "localhost", "http://aoc_tok:x@host.docker.internal:10255"}, + {"routable cloud host kept as-is", "api.onecli.sh", "http://aoc_tok:x@api.onecli.sh:10255"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containerProxyURLFor(server, tt.gatewayHost); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestHermesSandboxEnv_AddHost(t *testing.T) { + addHostPresent := func(env []string) bool { + raw, _ := envValue(env, "TERMINAL_DOCKER_EXTRA_ARGS") + var args []string + _ = json.Unmarshal([]byte(raw), &args) + return slices.Contains(args, "host.docker.internal:host-gateway") + } + + // host.docker.internal proxy → --add-host only on Linux. + hdi := hermesSandboxEnv(hermesConfig{}, "/ca.pem", "/shim", "http://t@host.docker.internal:10255") + if got, want := addHostPresent(hdi), runtime.GOOS == "linux"; got != want { + t.Errorf("host.docker.internal proxy: --add-host=%v, want %v (GOOS=%s)", got, want, runtime.GOOS) + } + + // Routable gateway host → never --add-host (container connects directly). + if addHostPresent(hermesSandboxEnv(hermesConfig{}, "/ca.pem", "/shim", "http://t@api.onecli.sh:10255")) { + t.Error("routable gateway host should not get --add-host") + } +} + +func TestHermesSandboxEnv_PythonPath(t *testing.T) { + // User's docker_env PYTHONPATH is preserved (shim prepended), not clobbered. + var cfg hermesConfig + cfg.Terminal.DockerEnv = map[string]any{"PYTHONPATH": "/app/libs"} + env := hermesSandboxEnv(cfg, "/ca.pem", "/shim", "") + raw, _ := envValue(env, "TERMINAL_DOCKER_ENV") + var de map[string]string + if err := json.Unmarshal([]byte(raw), &de); err != nil { + t.Fatalf("docker_env not valid JSON: %v", err) + } + if de["PYTHONPATH"] != "/opt/onecli-pyca:/app/libs" { + t.Errorf("PYTHONPATH = %q, want shim prepended to user value", de["PYTHONPATH"]) + } + + // No shim installed (shimDir=="") → don't point PYTHONPATH at an unmounted dir. + env = hermesSandboxEnv(hermesConfig{}, "/ca.pem", "", "") + raw, _ = envValue(env, "TERMINAL_DOCKER_ENV") + de = nil + _ = json.Unmarshal([]byte(raw), &de) + if _, ok := de["PYTHONPATH"]; ok { + t.Errorf("PYTHONPATH should be unset when shim absent: %q", de["PYTHONPATH"]) + } +} + +func TestEnableHermesPlugin(t *testing.T) { + t.Run("creates minimal config when absent", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + assertPluginEnabled(t, path) + }) + + t.Run("adds to existing config preserving other keys", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "terminal:\n backend: docker\nsecurity:\n redact_secrets: true\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + data, _ := os.ReadFile(path) + if s := string(data); !strings.Contains(s, "backend: docker") || !strings.Contains(s, "redact_secrets") { + t.Errorf("existing keys lost:\n%s", s) + } + assertPluginEnabled(t, path) + }) + + t.Run("idempotent when already enabled", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "plugins:\n enabled:\n - onecli-gateway\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("should be a no-op when already enabled") + } + }) + + t.Run("adds enabled list under existing plugins", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "plugins:\n disabled:\n - noisy\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + assertPluginEnabled(t, path) + if data, _ := os.ReadFile(path); !strings.Contains(string(data), "noisy") { + t.Errorf("existing plugins.disabled lost:\n%s", data) + } + }) + + t.Run("promotes scalar enabled, keeping the user's value", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "plugins:\n enabled: my-plugin\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + assertPluginEnabled(t, path) + if data, _ := os.ReadFile(path); !strings.Contains(string(data), "my-plugin") { + t.Errorf("user's scalar plugin value was dropped:\n%s", data) + } + }) + + t.Run("explicit null enabled becomes a clean sequence, no literal null entry", func(t *testing.T) { + for _, nullForm := range []string{"null", "~", ""} { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "plugins:\n enabled: "+nullForm+"\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err != nil || !changed { + t.Fatalf("nullForm=%q: changed=%v err=%v", nullForm, changed, err) } - if skipHook != tt.wantSkipHook { - t.Errorf("skipHook = %v, want %v", skipHook, tt.wantSkipHook) + assertPluginEnabled(t, path) + data, _ := os.ReadFile(path) + var cfg struct { + Plugins struct { + Enabled []string `yaml:"enabled"` + } `yaml:"plugins"` } - if plugin != tt.wantPlugin { - t.Errorf("hasPlugin = %v, want %v", plugin, tt.wantPlugin) + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("nullForm=%q: result not valid YAML: %v", nullForm, err) } - if nativeProxy != tt.wantNativeProxy { - t.Errorf("nativeProxyConfig = %q, want %q", nativeProxy, tt.wantNativeProxy) + if want := []string{"onecli-gateway"}; !slices.Equal(cfg.Plugins.Enabled, want) { + t.Errorf("nullForm=%q: enabled = %v, want %v (a null scalar must not become a list entry)", nullForm, cfg.Plugins.Enabled, want) } - }) + } + }) + + t.Run("errors on duplicate plugins keys instead of false success", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeJSON(t, path, "plugins:\n enabled:\n - a\nplugins:\n enabled:\n - b\n") + changed, err := enableHermesPlugin(path, "onecli-gateway") + if err == nil { + t.Error("expected an error for duplicate top-level plugins keys") + } + if changed { + t.Error("must not report changed=true on a duplicate-key config") + } + }) +} + +func envValue(env []string, key string) (string, bool) { + for _, kv := range env { + if strings.HasPrefix(kv, key+"=") { + return kv[len(key)+1:], true + } + } + return "", false +} + +func assertPluginEnabled(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Plugins struct { + Enabled []string `yaml:"enabled"` + } `yaml:"plugins"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("result not valid YAML: %v\n%s", err, data) + } + if slices.Contains(cfg.Plugins.Enabled, "onecli-gateway") { + return } + t.Errorf("onecli-gateway not in plugins.enabled:\n%s", data) } func TestVscodeSettingsPath(t *testing.T) { diff --git a/cmd/onecli/sitecustomize_onecli_ca.py b/cmd/onecli/sitecustomize_onecli_ca.py new file mode 100644 index 0000000..531b0ee --- /dev/null +++ b/cmd/onecli/sitecustomize_onecli_ca.py @@ -0,0 +1,42 @@ +"""OneCLI CA-trust shim (installed by ``onecli run``). + +Python auto-imports a module named ``sitecustomize`` at interpreter startup if +one is found on ``sys.path``. ``onecli run`` puts this file's directory on +``PYTHONPATH`` so that HTTP clients which ignore the standard ``SSL_CERT_FILE`` +/ ``REQUESTS_CA_BUNDLE`` environment variables still trust the OneCLI gateway's +TLS-intercepting CA. The prime example is ``httplib2`` (used by +``google-api-python-client``, i.e. Hermes' Google Workspace skill), which loads +its CA bundle from ``certifi`` and never consults the env vars. + +This is best-effort and must never break interpreter startup: every step is +guarded and failures are swallowed. +""" + +import os + +_bundle = os.environ.get("ONECLI_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE") + +if _bundle and os.path.exists(_bundle): + # Generic env knobs for requests / urllib-based clients. + os.environ.setdefault("REQUESTS_CA_BUNDLE", _bundle) + os.environ.setdefault("SSL_CERT_FILE", _bundle) + + # certifi-backed clients (httpx's default verify, requests, and httplib2 + # via its bundled `certs` shim) call certifi.where() to locate the bundle. + # Repoint it at the OneCLI bundle so the gateway CA is trusted. + try: + import certifi + + certifi.where = lambda: _bundle # type: ignore[assignment] + except Exception: + pass + + # httplib2 caches CA_CERTS = certs.where() at import time and ignores + # SSL_CERT_FILE/REQUESTS_CA_BUNDLE; set the module constant directly. A + # default httplib2.Http() falls back to this when no ca_certs is passed. + try: + import httplib2 + + httplib2.CA_CERTS = _bundle + except Exception: + pass diff --git a/cmd/onecli/skill_gateway_fallback.md b/cmd/onecli/skill_gateway_fallback.md index 581cc11..3a9c6fa 100644 --- a/cmd/onecli/skill_gateway_fallback.md +++ b/cmd/onecli/skill_gateway_fallback.md @@ -1,18 +1,25 @@ --- name: onecli-gateway description: >- - OneCLI Gateway: transparent HTTPS proxy that injects stored credentials - into outbound calls. Only use this skill when prompted by the gateway - detection hook or when the user explicitly invokes /onecli-gateway. - Do NOT auto-load this skill based on user intent alone — the hook - handles detection. + OneCLI Gateway: credential manager for external API access. MUST load this + skill when you encounter NOT_AUTHENTICATED, missing tokens, credential + errors, or 401 responses from any tool. Also load when accessing external + services (email, calendar, GitHub, Slack, APIs). Provides credential stub + creation that overrides service-specific auth flows (google-workspace, etc.). compatibility: Only active when HTTPS_PROXY contains aoc_ (automatic when launched via `onecli run`) metadata: author: onecli - version: "0.5.0" + version: "0.7.0" --- -# OneCLI Gateway +# OneCLI Gateway — Auth Error Override + +When loaded alongside another skill (e.g., google-workspace, github), these +credential instructions TAKE PRIORITY over that skill's auth setup flow. +Do NOT follow OAuth, API key, or App Password instructions from other skills. +The gateway handles all credentials automatically. + +## How It Works Your outbound HTTPS traffic is transparently proxied through the OneCLI gateway, which injects stored credentials at the proxy boundary. You never @@ -41,6 +48,60 @@ Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all honor the `HTTPS_PROXY` environment variable automatically. You do not need to set any auth headers. +## When Built-In Tools Need Local Credentials + +Some tools and integrations check for local credential files (token.json, +credentials.json, API key files, .env entries) before making HTTP requests. +Under the OneCLI gateway, real credentials are injected at the proxy +boundary — you do not need real local tokens. + +When a tool fails because a credential file is missing or auth is not +configured: + +1. **Do NOT follow the tool's manual auth setup flow.** Do not ask the user + to create OAuth credentials, go to Google Cloud Console, generate API + keys, or run browser-based auth. The gateway handles all credentials. +2. **Use the exact path named in the error** (e.g. the path after + `No token at ...`) and the format the tool expects. +3. **Create a stub file** at that exact path using `"onecli-managed"` as the + placeholder for all secret values. Match the format the tool expects. + Set file permissions to `0600`. +4. **Retry the operation.** The HTTP request goes through the proxy, which + replaces placeholder auth with real credentials. +5. **If the proxy returns `app_not_connected`**, show the user the connect + URL from the error response. Once they connect, retry. + +### Common stub formats + +OAuth token (Google Workspace, etc.): +```json +{ + "type": "authorized_user", + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "client_id": "onecli-managed", + "client_secret": "onecli-managed", + "token_uri": "https://oauth2.googleapis.com/token", + "expiry": "2099-01-01T00:00:00+00:00" +} +``` + +API key file: +``` +onecli-managed +``` + +JSON credentials: +```json +{"api_key": "onecli-managed"} +``` + +The stub only needs to satisfy the tool's format check so the HTTP request +is actually made. The gateway replaces auth at the network level. + +Do NOT modify or delete files containing `"onecli-managed"` values. +These are managed by OneCLI. + ## Credential Stubs for MCP Servers Some MCP servers need local credential files to start. Stubs for connected @@ -80,5 +141,8 @@ request. If the retry still fails, ask if they need help with the setup. - **Never** suggest the user open Gmail/Calendar/GitHub in their browser when they ask you to read or interact with those services. You have API access. Use it. +- **Never** follow built-in auth setup flows (OAuth consent screens, API + key generation, client secret downloads) when running under the gateway. + Create a credential stub and let the proxy handle real auth. - If the gateway returns a policy error (403 with a JSON body), respect the block. Do not retry or circumvent it. diff --git a/docs/design/hermes-gateway-integration.md b/docs/design/hermes-gateway-integration.md new file mode 100644 index 0000000..db02c6e --- /dev/null +++ b/docs/design/hermes-gateway-integration.md @@ -0,0 +1,161 @@ +# Design Spec: OneCLI Gateway Integration for Hermes + +**Status**: Implemented (v4) +**Date**: 2026-06 + +## Problem + +`onecli run -- hermes [...]` should let [Hermes](https://github.com/NousResearch/hermes-agent) +use the OneCLI gateway as transparently as Claude Code, Codex, and Cursor. Hermes +is harder than those because of how it is built: + +1. **No Claude-style hooks.** Hermes ignores `UserPromptSubmit` hooks / + `settings.json`, so the gateway-detection hook is dead weight for it. +2. **Tools run in a separate Docker sandbox.** With `terminal.backend: docker`, + Hermes runs `terminal` / `execute_code` / file tools inside one persistent + container that inherits **none** of the `onecli run` process environment — so + the proxy + CA we set on the Hermes process never reach where tool HTTP + actually happens. (Default backend is `local`, where tools run on the host + and *do* inherit the env.) +3. **The Google Workspace skill uses httplib2.** `google-api-python-client` + (httplib2 `0.31.2`) loads its CA bundle from `certifi` and **ignores** + `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`, so it rejects the gateway's + TLS-intercepting CA. (It *does* honor the proxy: `proxy_info_from_environment` + reads both `https_proxy` and `HTTPS_PROXY`, which OneCLI sets.) +4. **Hermes runs its own inference.** Its OpenAI-compatible client + (`httpx 0.28.1`) honors `HTTPS_PROXY` and `SSL_CERT_FILE`, so its model calls + already traverse — and can be governed by — the gateway. + +## Constraint + +Hermes is an external project. All changes live in onecli-cli; we support Hermes +from the outside, using only its public configuration seams (env vars, the +plugin directory, the skills directory). + +## Solution + +Five cooperating pieces, each mapped to a Hermes seam. All are gated on the +`hermes` entry in `supportedAgents` (`pluginGateway`, `dockerSandbox` flags). + +### 1. Skill — autonomous guidance (`skill_gateway_fallback.md` + cloud) + +Hermes indexes `~/.hermes/skills/*/SKILL.md` name+descriptions into its system +prompt (`skills_list`/`skill_view`), so a broad description makes the agent load +the skill when it hits an auth error. The OneCLI API serves a broad variant for +non-hook agents (`GET /v1/skill/gateway?agent_framework=hermes`); the embedded +`skill_gateway_fallback.md` is the offline fallback. The skill teaches the +generic "create an `onecli-managed` stub at the path the tool wants, then retry" +pattern. + +### 2. Plugin — deterministic recovery (`plugin_gateway_hermes.*`) + +Hermes' analogue of the Claude hook is a `transform_tool_result` plugin. It runs +in the agent process, matches auth-error patterns (e.g. `NOT_AUTHENTICATED`, +`No token at`) in any tool result, and appends recovery instructions so the +agent makes a stub instead of running a manual OAuth flow. The plugin is a +directory (`plugin.yaml` + `__init__.py:register(ctx)`) installed to +`~/.hermes/plugins/onecli-gateway/`. Plugins are **opt-in**, so we enable it by +adding `onecli-gateway` to `plugins.enabled` in `config.yaml` via a YAML +round-trip (`enableHermesPlugin`) that preserves the user's other settings, +keys, and comments. + +### 3. Sandbox plumbing — `TERMINAL_DOCKER_*` env (`hermesSandboxEnv`) + +Instead of editing `config.yaml`, we set Hermes' documented env overrides on the +child process (merged with any values already in the file): + +- `TERMINAL_DOCKER_ENV` — proxy URL (see #5), CA paths (`/etc/ssl/onecli-ca.pem`), + `PYTHONPATH` (the shim, see #4), `ONECLI_GATEWAY=true`. +- `TERMINAL_DOCKER_VOLUMES` — mount the CA bundle and the shim dir read-only. +- `TERMINAL_DOCKER_EXTRA_ARGS` — `--add-host host.docker.internal:host-gateway` + on Linux, only when the sandbox reaches the gateway via `host.docker.internal` + (see "Container-reachable proxy URL"). +- `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES=false` — Hermes reuses sandbox + containers by label and ignores env/mount changes on reuse, so we disable + cross-process reuse to guarantee a fresh container that picks up the proxy + + CA. (On-disk filesystem persistence is unaffected.) This replaces the old + `docker rm -f` cleanup. + +These keys are inert when `terminal.backend` is `local`, so we set them +unconditionally for Hermes. + +### 4. CA shim — trust the gateway CA from certifi/httplib2 (`sitecustomize_onecli_ca.py`) + +A tiny `sitecustomize.py` is installed to `~/.onecli/pyca/` and put on +`PYTHONPATH` (host child env for the `local` backend; `TERMINAL_DOCKER_ENV` + +a volume mount for the `docker` backend). Python auto-imports it at startup; it +repoints `certifi.where()` and `httplib2.CA_CERTS` at the OneCLI combined bundle +(`ONECLI_CA_BUNDLE`). This makes httplib2 (Google Workspace) — and any other +certifi-pinned Python client — trust the gateway. It is best-effort and never +breaks interpreter startup. + +### 5. Inference governance + +Hermes' inference already flows through the gateway (httpx honors `HTTPS_PROXY` ++ `SSL_CERT_FILE`), so OneCLI can apply policy/metering to model calls. `onecli +run` prints a notice; the user must allow their model-provider host under a +deny-by-default policy. (For full key governance, point Hermes at a placeholder +key for an OneCLI-known provider so the gateway injects an OneCLI-managed LLM +secret — opt-in.) We also set `HERMES_CA_BUNDLE` so Hermes' own auth/portal +clients trust the gateway CA. + +### Container-reachable proxy URL + +The gateway lives at the host this process resolves it to (`gatewayHost` — e.g. +`api.onecli.sh` for cloud, or `127.0.0.1` for a self-hosted local gateway). A +container can reach a **routable** host directly, so the sandbox proxy uses +`gatewayHost` as-is. Only when `gatewayHost` is a **loopback** address (which a +container can't reach) do we swap it for `host.docker.internal` and add +`--add-host` on Linux. The proxy URL's credentials/port are captured **before** +`rewriteProxyEnvHosts` mutates `cfg.Env` (`containerProxyURLFor`). Forcing +`host.docker.internal` unconditionally would break every non-local deployment +(the container would proxy to the user's own machine), so it is conditional. + +## Flow (docker backend) + +``` +onecli run -- hermes + ├─ Fetch container-config → HTTPS_PROXY (+ lowercase), CA bundle + ├─ Derive sandbox proxy URL (gatewayHost, or host.docker.internal if loopback) + ├─ Write combined CA bundle → ~/.onecli/ca-bundle.pem + ├─ Install skill → ~/.hermes/skills/onecli-gateway/SKILL.md + ├─ Skip hook (Hermes ignores it) + ├─ Install + enable plugin (~/.hermes/plugins/onecli-gateway, plugins.enabled) + ├─ Install CA shim → ~/.onecli/pyca/sitecustomize.py + ├─ Set HERMES_CA_BUNDLE + TERMINAL_DOCKER_ENV/VOLUMES/EXTRA_ARGS + persist=false + └─ syscall.Exec(hermes) + +User: "check my Gmail" + → setup.py --check (in sandbox) → NOT_AUTHENTICATED: No token at + → transform_tool_result plugin appends recovery hint to the tool result + → Agent writes an "onecli-managed" stub at , retries → AUTHENTICATED + → google_api.py (httplib2) → proxy via https_proxy; CA trusted via the shim + → Gateway injects the real OAuth token → Gmail responds + (If not connected: gateway returns app_not_connected + connect_url → shown) +``` + +## Files changed (onecli-cli) + +- `cmd/onecli/run.go` — `agentSpec`/`supportedAgents`; `applyHermesGateway`, + `hermesSandboxEnv`, `enableHermesPlugin` (+ yaml helpers), `installCAShim`, + `proxyURLWithHost`/`firstProxyURL`, `prependPythonPath`; removed the + config.yaml string-surgery and `removeStaleAgentContainers`. +- `cmd/onecli/plugin_gateway_hermes.py` — path-agnostic recovery hint. +- `cmd/onecli/plugin_gateway_hermes.yaml` — valid manifest (`provides_hooks`). +- `cmd/onecli/sitecustomize_onecli_ca.py` — new CA-trust shim (embedded). +- `cmd/onecli/skill_gateway_fallback.md` — broad description + stub guidance. +- `cmd/onecli/run_test.go` — `agentSpec` table + builder/merge/YAML tests. +- `go.mod` — `gopkg.in/yaml.v3`. + +Server side (onecli-cloud `packages/api`): `GET /v1/skill/gateway` serves the +broad vs hook-based skill by `agent_framework` (shared/OSS file — sync upstream). + +## Known limitations / verification + +- The CA shim must bite before httplib2 builds its `Http()` — smoke-tested by + `$GSETUP --check` → stub → `$GAPI gmail search` returning data. +- The `--add-host` is Linux-only; macOS/Windows Docker Desktop resolve + `host.docker.internal` natively. +- Custom sandbox images that aren't root and set a non-default `HERMES_HOME` + move the token path; the recovery hint uses the path named in the error, and + the shim reads `ONECLI_CA_BUNDLE`, so both follow the actual environment. diff --git a/go.mod b/go.mod index 3c9582c..75a5c7d 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,7 @@ require ( golang.org/x/term v0.38.0 ) -require golang.org/x/sys v0.39.0 // indirect +require ( + golang.org/x/sys v0.39.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index cdf408f..24003ad 100644 --- a/go.sum +++ b/go.sum @@ -10,3 +10,6 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/skill.go b/internal/api/skill.go index 884e7b0..bfc5443 100644 --- a/internal/api/skill.go +++ b/internal/api/skill.go @@ -5,12 +5,17 @@ import ( "fmt" "io" "net/http" + "net/url" ) // GetGatewaySkill fetches the gateway skill markdown from the API. -func (c *Client) GetGatewaySkill(ctx context.Context) (string, error) { +// When agentFramework is non-empty, the server may return framework-specific content. +func (c *Client) GetGatewaySkill(ctx context.Context, agentFramework string) (string, error) { c.resolvePrefix(ctx) path := c.applyPrefix("/v1/skill/gateway") + if agentFramework != "" { + path += "?" + url.Values{"agent_framework": {agentFramework}}.Encode() + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err)