Skip to content

Commit 87172a1

Browse files
authored
feat: add "onecli run" command to wrap agent processes with gateway access (#23)
* feat: add "onecli run" command to wrap agent processes with gateway access * fix: sanitize query params, align gofmt, and clean up run command internals
1 parent a9b4dff commit 87172a1

5 files changed

Lines changed: 489 additions & 0 deletions

File tree

cmd/onecli/help.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
3939
Version: version,
4040
Description: "CLI for managing OneCLI agents, secrets, rules, and configuration.",
4141
Commands: []CommandInfo{
42+
{Name: "run", Description: "Run a command with OneCLI gateway access.", Args: []ArgInfo{
43+
{Name: "<command>", Required: true, Description: "Command to execute (e.g. claude, cursor, codex)."},
44+
{Name: "--agent", Description: "OneCLI agent identifier (uses default if omitted)."},
45+
{Name: "--gateway", Description: "Gateway host:port override (default: derived from API host)."},
46+
{Name: "--no-ca", Description: "Skip CA cert write and CA trust env injection."},
47+
{Name: "--dry-run", Description: "Print resolved env and command without executing."},
48+
}},
4249
{Name: "agents list", Description: "List all agents."},
4350
{Name: "agents get-default", Description: "Get the default agent."},
4451
{Name: "agents create", Description: "Create a new agent.", Args: []ArgInfo{

cmd/onecli/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var version = "dev"
1919

2020
// CLI is the root command. Subcommands are added as fields.
2121
type CLI struct {
22+
Run RunCmd `cmd:"" help:"Run a command with OneCLI gateway access."`
2223
Version VersionCmd `cmd:"" help:"Print version information."`
2324
Help HelpCmd `cmd:"" help:"Show available commands."`
2425
Agents AgentsCmd `cmd:"" help:"Manage agents."`
@@ -127,6 +128,8 @@ func hintForCommand(cmd, host string) string {
127128
return "Manage authentication \u2192 " + host
128129
case "config":
129130
return "Manage configuration \u2192 " + host
131+
case "run":
132+
return "OneCLI gateway docs \u2192 " + host
130133
case "migrate":
131134
return "Migrate data to OneCLI Cloud"
132135
default:

cmd/onecli/run.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
_ "embed"
6+
"fmt"
7+
"net/url"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strings"
12+
"syscall"
13+
14+
"github.com/onecli/onecli-cli/internal/config"
15+
"github.com/onecli/onecli-cli/pkg/output"
16+
"github.com/onecli/onecli-cli/pkg/validate"
17+
)
18+
19+
//go:embed skill_gateway.md
20+
var gatewaySkill string
21+
22+
// RunCmd is `onecli run -- <command> [args...]`.
23+
type RunCmd struct {
24+
Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (uses default agent if omitted)."`
25+
Gateway string `optional:"" name:"gateway" help:"Gateway host:port override (default: derived from API host)."`
26+
NoCA bool `optional:"" name:"no-ca" help:"Skip writing the CA cert and CA trust env injection."`
27+
DryRun bool `optional:"" name:"dry-run" help:"Print resolved env and command without executing."`
28+
Args []string `arg:"" optional:"" name:"command" help:"Command and arguments to execute (after --)."`
29+
}
30+
31+
func (c *RunCmd) Run(out *output.Writer) error {
32+
if len(c.Args) == 0 {
33+
return fmt.Errorf("no command specified: use 'onecli run -- <command> [args...]'")
34+
}
35+
36+
// Validate agent identifier if provided.
37+
if c.Agent != "" {
38+
if err := validate.ResourceID(c.Agent); err != nil {
39+
return fmt.Errorf("invalid agent identifier: %w", err)
40+
}
41+
}
42+
43+
// Resolve the binary path early — fail fast before the API round-trip.
44+
binary, err := exec.LookPath(c.Args[0])
45+
if err != nil {
46+
return fmt.Errorf("command not found %s: %w", c.Args[0], err)
47+
}
48+
49+
// Fetch gateway configuration from the API.
50+
client, err := newClient()
51+
if err != nil {
52+
return err
53+
}
54+
cfg, err := client.GetContainerConfig(newContext(), c.Agent)
55+
if err != nil {
56+
return err
57+
}
58+
59+
// Rewrite proxy URLs for local use. The server returns Docker-internal
60+
// hostnames (e.g. host.docker.internal) that don't resolve on the host
61+
// machine. Replace with the gateway host reachable from this machine.
62+
gatewayHost := c.Gateway
63+
if gatewayHost == "" {
64+
gatewayHost = resolveLocalGatewayHost()
65+
}
66+
rewriteProxyEnvHosts(cfg.Env, gatewayHost)
67+
68+
// Dry-run: print resolved config without side effects (no CA write,
69+
// no skill install, no exec).
70+
if c.DryRun {
71+
injected := make([]string, 0, len(cfg.Env)+len(caTrustKeys))
72+
for k := range cfg.Env {
73+
injected = append(injected, k)
74+
}
75+
if !c.NoCA && cfg.CACertificate != "" {
76+
injected = append(injected, caTrustKeys...)
77+
}
78+
return out.WriteDryRun("Would exec command with OneCLI gateway", map[string]any{
79+
"binary": binary,
80+
"args": c.Args,
81+
"env_injected": injected,
82+
})
83+
}
84+
85+
// Write CA cert to disk (unless --no-ca).
86+
caPath := ""
87+
if !c.NoCA && cfg.CACertificate != "" {
88+
caPath, err = writeGatewayCACert(cfg.CACertificate)
89+
if err != nil {
90+
// Non-fatal: warn and skip CA injection rather than aborting.
91+
out.Stderr(fmt.Sprintf("onecli: warning: could not write CA cert (%v); continuing without CA trust injection", err))
92+
caPath = ""
93+
}
94+
}
95+
96+
// Build child environment.
97+
env := buildChildEnv(os.Environ(), cfg.Env, caPath)
98+
99+
// Install skill for known agents (silently updates stale files).
100+
if name, dir, ok := agentSkillDir(c.Args[0]); ok {
101+
maybeInstallGatewaySkill(out, name, dir)
102+
}
103+
104+
// Exec — replaces this process so the agent gets direct terminal control.
105+
out.Stderr(fmt.Sprintf("onecli: gateway connected. Starting %s...", c.Args[0]))
106+
if err := syscall.Exec(binary, c.Args, env); err != nil {
107+
return fmt.Errorf("exec %s: %w", binary, err)
108+
}
109+
return nil
110+
}
111+
112+
// writeGatewayCACert writes the gateway CA PEM to ~/.onecli/gateway-ca.pem.
113+
// Returns the path on success. Skips the write if on-disk content already matches.
114+
func writeGatewayCACert(pem string) (string, error) {
115+
home, err := os.UserHomeDir()
116+
if err != nil {
117+
return "", fmt.Errorf("resolving home dir: %w", err)
118+
}
119+
caPath := filepath.Join(home, ".onecli", "gateway-ca.pem")
120+
if err := os.MkdirAll(filepath.Dir(caPath), 0o700); err != nil {
121+
return "", fmt.Errorf("creating CA dir: %w", err)
122+
}
123+
existing, err := os.ReadFile(caPath)
124+
if err == nil && bytes.Equal(existing, []byte(pem)) {
125+
return caPath, nil
126+
}
127+
if err := os.WriteFile(caPath, []byte(pem), 0o600); err != nil {
128+
return "", fmt.Errorf("writing CA cert: %w", err)
129+
}
130+
return caPath, nil
131+
}
132+
133+
// caTrustKeys are env vars we inject locally for CA trust. These aren't in
134+
// the server response but may exist in the parent env and need stripping.
135+
var caTrustKeys = []string{
136+
"NODE_EXTRA_CA_CERTS",
137+
"SSL_CERT_FILE",
138+
"REQUESTS_CA_BUNDLE",
139+
"CURL_CA_BUNDLE",
140+
"GIT_SSL_CAINFO",
141+
"DENO_CERT",
142+
}
143+
144+
// buildChildEnv builds the environment for the child process by stripping
145+
// conflicting keys from the current env, appending the server-provided env,
146+
// and overriding CA cert paths to use the local file.
147+
func buildChildEnv(current []string, serverEnv map[string]string, caPath string) []string {
148+
// Strip keys the server provides + CA trust keys we inject locally.
149+
// This prevents stale inherited values (e.g. a corporate HTTPS_PROXY)
150+
// from shadowing the gateway values — POSIX getenv returns the first match.
151+
stripKeys := make(map[string]struct{}, len(serverEnv)+len(caTrustKeys))
152+
for k := range serverEnv {
153+
stripKeys[k] = struct{}{}
154+
}
155+
for _, k := range caTrustKeys {
156+
stripKeys[k] = struct{}{}
157+
}
158+
159+
out := make([]string, 0, len(current)+len(serverEnv)+6)
160+
for _, kv := range current {
161+
i := strings.IndexByte(kv, '=')
162+
if i < 0 {
163+
out = append(out, kv)
164+
continue
165+
}
166+
if _, drop := stripKeys[kv[:i]]; drop {
167+
continue
168+
}
169+
out = append(out, kv)
170+
}
171+
172+
// Build set of CA trust keys we'll override locally — skip these from
173+
// serverEnv so the local paths (appended below) aren't shadowed.
174+
// POSIX getenv returns the first match, so order matters.
175+
localCAKeys := make(map[string]struct{}, len(caTrustKeys))
176+
if caPath != "" {
177+
for _, k := range caTrustKeys {
178+
localCAKeys[k] = struct{}{}
179+
}
180+
}
181+
182+
// Append server-provided env (HTTPS_PROXY, credentials, etc.),
183+
// excluding any CA trust keys we'll override with local paths.
184+
for k, v := range serverEnv {
185+
if _, skip := localCAKeys[k]; skip {
186+
continue
187+
}
188+
out = append(out, k+"="+v)
189+
}
190+
191+
// Append CA trust vars pointing to the local cert file, replacing the
192+
// Docker container path that the server returns in NODE_EXTRA_CA_CERTS.
193+
if caPath != "" {
194+
out = append(out,
195+
"NODE_EXTRA_CA_CERTS="+caPath,
196+
"SSL_CERT_FILE="+caPath,
197+
"REQUESTS_CA_BUNDLE="+caPath,
198+
"CURL_CA_BUNDLE="+caPath,
199+
"GIT_SSL_CAINFO="+caPath,
200+
"DENO_CERT="+caPath,
201+
)
202+
}
203+
204+
return out
205+
}
206+
207+
// dockerInternalHosts is the set of hostnames used inside Docker containers to
208+
// reach the host machine. These don't resolve from a local process.
209+
var dockerInternalHosts = map[string]bool{
210+
"host.docker.internal": true,
211+
"gateway.docker.internal": true,
212+
}
213+
214+
// resolveLocalGatewayHost derives the gateway hostname from the API host the
215+
// CLI is configured to talk to. If the API host is localhost/127.0.0.1, the
216+
// gateway is on the same machine. For remote hosts, use the same hostname
217+
// (the gateway is typically co-located with the web app).
218+
func resolveLocalGatewayHost() string {
219+
apiHost := config.APIHost()
220+
u, err := url.Parse(apiHost)
221+
if err != nil || u.Hostname() == "" {
222+
return "127.0.0.1"
223+
}
224+
return u.Hostname()
225+
}
226+
227+
// rewriteProxyEnvHosts replaces Docker-internal hostnames in proxy URL values
228+
// with the given local host, keeping the port and credentials intact.
229+
// Only rewrites values that look like proxy URLs (contain "://").
230+
func rewriteProxyEnvHosts(env map[string]string, localHost string) {
231+
proxyKeys := map[string]bool{
232+
"HTTPS_PROXY": true, "HTTP_PROXY": true,
233+
"https_proxy": true, "http_proxy": true,
234+
}
235+
for k, v := range env {
236+
if !proxyKeys[k] {
237+
continue
238+
}
239+
u, err := url.Parse(v)
240+
if err != nil {
241+
continue
242+
}
243+
if !dockerInternalHosts[u.Hostname()] {
244+
continue
245+
}
246+
port := u.Port()
247+
if port != "" {
248+
u.Host = localHost + ":" + port
249+
} else {
250+
u.Host = localHost
251+
}
252+
env[k] = u.String()
253+
}
254+
}
255+
256+
// supportedAgents maps CLI binary base-names to (agentName, skillsBaseDir) pairs.
257+
var supportedAgents = []struct {
258+
bases []string
259+
agentName string
260+
baseDir string
261+
}{
262+
{[]string{"claude"}, "Claude Code", ".claude"},
263+
{[]string{"cursor", "agent"}, "Cursor", ".cursor"},
264+
{[]string{"codex"}, "Codex", ".agents"},
265+
{[]string{"hermes"}, "Hermes", ".hermes"},
266+
{[]string{"opencode"}, "OpenCode", ".opencode"},
267+
}
268+
269+
// agentSkillDir returns the display name and skills base directory for a known
270+
// agent command, or ok=false if the command is not recognized.
271+
func agentSkillDir(cmd string) (agentName, baseDir string, ok bool) {
272+
base := filepath.Base(cmd)
273+
for _, a := range supportedAgents {
274+
for _, b := range a.bases {
275+
if base == b {
276+
return a.agentName, a.baseDir, true
277+
}
278+
}
279+
}
280+
return "", "", false
281+
}
282+
283+
// maybeInstallGatewaySkill installs the OneCLI gateway skill file if it is
284+
// missing or stale. agentName is used in user-facing messages.
285+
func maybeInstallGatewaySkill(out *output.Writer, agentName, baseDir string) {
286+
home, err := os.UserHomeDir()
287+
if err != nil {
288+
out.Stderr(fmt.Sprintf("onecli: warning: could not resolve home directory: %v", err))
289+
return
290+
}
291+
fullPath := filepath.Join(home, baseDir, "skills", "onecli-gateway", "SKILL.md")
292+
293+
existing, err := os.ReadFile(fullPath)
294+
if err == nil && bytes.Equal(existing, []byte(gatewaySkill)) {
295+
return
296+
}
297+
298+
if err := os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil {
299+
out.Stderr(fmt.Sprintf("onecli: warning: could not create skill directory: %v", err))
300+
return
301+
}
302+
if err := os.WriteFile(fullPath, []byte(gatewaySkill), 0o600); err != nil {
303+
out.Stderr(fmt.Sprintf("onecli: warning: could not write skill file: %v", err))
304+
return
305+
}
306+
out.Stderr(fmt.Sprintf("onecli: installed gateway skill for %s.", agentName))
307+
}

0 commit comments

Comments
 (0)