Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
11e2d02
feat: add podman support with runtime auto-detection and capability h…
wongtsejian Jun 3, 2026
be1d5c5
feat(config): add container_runtime field, remove CONTAINER_RUNTIME e…
wongtsejian Jun 3, 2026
f0e6eb1
feat(plugins): add SSH plugin and HTTP reverse proxy for gateway
wongtsejian Jun 3, 2026
2211a91
fix: align HTTP proxy with main's HTTPServices model
wongtsejian Jun 3, 2026
784b73d
security: remove hardcoded SSH host key, generate at runtime
wongtsejian Jun 3, 2026
31bbcb6
refactor(examples): split SSH config into dedicated local-coding-ssh …
wongtsejian Jun 3, 2026
4730056
feat(external-services): support http:// scheme for local services
wongtsejian Jun 3, 2026
ac833a2
security(ssh): mount keys as volumes instead of embedding in scripts
wongtsejian Jun 3, 2026
fa1f582
chore: add missing files to local-coding-ssh example
wongtsejian Jun 3, 2026
fbb30e1
fix(examples): add Zed codex-acp registration to local-coding-ssh
wongtsejian Jun 3, 2026
0c57722
feat(compose): add env_file to gateway for loading .env secrets
wongtsejian Jun 3, 2026
314980d
fix(examples): add auth header to external-services for credential in…
wongtsejian Jun 3, 2026
528da1c
docs(examples): add SSH config section to local-coding-ssh README
wongtsejian Jun 3, 2026
dd33c49
fixup! feat(config): add container_runtime field, remove CONTAINER_RU…
wongtsejian Jun 3, 2026
62f7c3c
fix(examples): use correct Zed agent_servers config format
wongtsejian Jun 3, 2026
876bea4
Clean up Zed config and update gateway URLs
wongtsejian Jun 3, 2026
faeb84e
Support port-aware domain matching in gateway rewriters
wongtsejian Jun 3, 2026
992827b
chore: ignore bin/agent-sandbox
wongtsejian Jun 3, 2026
64d56f6
feat(config): add container_runtime field to AgentConfig and SharedBlock
wongtsejian Jun 3, 2026
f486fae
Merge pull request #2 from wongtsejian/feat/ssh-http-proxy
wongtsejian Jun 3, 2026
0eca7e1
Merge branch 'main' into feat/podman-support
wongtsejian Jun 3, 2026
506fa95
fix(test): update unsupported scheme test for http:// support
wongtsejian Jun 3, 2026
e554c6e
fix: address review findings for SSH+HTTP proxy
wongtsejian Jun 3, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ Thumbs.db
channel-manager/node_modules/
node_modules/
.worktrees/
.ssh/
ssh*key
bin/agent-sandbox
95 changes: 78 additions & 17 deletions cmd/agent-sandbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/donbader/agent-sandbox/internal/generate"
_ "github.com/donbader/agent-sandbox/internal/plugins" // register core feature plugins
"github.com/donbader/agent-sandbox/internal/resolve"
crt "github.com/donbader/agent-sandbox/internal/runtime"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -190,10 +191,11 @@ func generateAgent(dir, outDir string, cfg *config.AgentConfig, _ *config.Shared
ChannelManager: hasChannelManager,
SkipEnvExample: skipEnvExample,
GatewaySpec: generate.GatewaySpec{
BuildImage: "golang:1.26.4-alpine",
BinaryPath: "/gateway",
ListenPort: 8443,
DNSPort: 53,
BuildImage: "golang:1.26.4-alpine",
BinaryPath: "/gateway",
ListenPort: 8443,
HTTPListenPort: 8080,
DNSPort: 53,
},
ChannelManagerSpec: generate.ChannelManagerSpec{
BuildImage: "node:22-slim",
Expand All @@ -216,7 +218,7 @@ func writeFleetCompose(outDir string, agents []string) error {

b.WriteString("include:\n")
for _, name := range agents {
_, _ = fmt.Fprintf(&b, " - path: %s/docker-compose.yml\n", name)
_, _ = fmt.Fprintf(&b, " - %s/docker-compose.yml\n", name)
}

composePath := filepath.Join(outDir, "docker-compose.yml")
Expand Down Expand Up @@ -269,22 +271,46 @@ func ensureSchemaComment(yamlPath string, schemaRelPath string) error {
func composeCmd(dir *string) *cobra.Command {
cmd := &cobra.Command{
Use: "compose",
Short: "Docker compose passthrough (auto-injects -f .build/docker-compose.yml)",
Short: "Container compose passthrough (auto-injects -f .build/docker-compose.yml)",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
composePath := filepath.Join(*dir, ".build", "docker-compose.yml")
buildDir := filepath.Join(*dir, ".build")
composePath := filepath.Join(buildDir, "docker-compose.yml")
if _, err := os.Stat(composePath); os.IsNotExist(err) {
return fmt.Errorf("%s not found — run 'agent-sandbox generate' first", composePath)
}

composeArgs := []string{"-f", composePath, "--project-name", "agent-sandbox"}
// Load config to get container_runtime override; ignore errors
// (fleet mode or missing config still auto-detects from PATH).
// Priority: agent.yaml > fleet.yaml shared > auto-detect.
var containerRuntime string
if fleet, err := config.LoadFleet(*dir); err == nil {
containerRuntime = fleet.Shared.ContainerRuntime
}
if cfg, err := config.Load(*dir); err == nil && cfg.ContainerRuntime != "" {
containerRuntime = cfg.ContainerRuntime
}
rt, err := crt.DetectWithOverride(containerRuntime)
if err != nil {
return err
}

// Fleet mode: expand sub-compose files as multiple -f flags
// instead of relying on the `include` directive (not supported by podman-compose)
composeFiles := expandFleetComposeFiles(buildDir, composePath)

var composeArgs []string
for _, f := range composeFiles {
composeArgs = append(composeArgs, "-f", f)
}
composeArgs = append(composeArgs, "--project-name", "agent-sandbox")
// Auto-inject --env-file if .env exists in project dir
envPath := filepath.Join(*dir, ".env")
if _, err := os.Stat(envPath); err == nil {
composeArgs = append(composeArgs, "--env-file", envPath)
}
composeArgs = append(composeArgs, args...)
c := exec.Command("docker", append([]string{"compose"}, composeArgs...)...)
c := exec.Command(rt.ComposeCmd[0], append(rt.ComposeCmd[1:], composeArgs...)...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
Expand All @@ -296,6 +322,40 @@ func composeCmd(dir *string) *cobra.Command {
return cmd
}

// expandFleetComposeFiles checks if the compose file is a fleet umbrella
// (contains only include directives). If so, returns the individual sub-compose
// file paths. Otherwise returns the single compose file path.
func expandFleetComposeFiles(buildDir, composePath string) []string {
data, err := os.ReadFile(composePath)
if err != nil {
return []string{composePath}
}

content := string(data)
if !strings.Contains(content, "include:") {
return []string{composePath}
}

// Parse include entries (format: " - path/to/docker-compose.yml")
var files []string
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if after, ok := strings.CutPrefix(line, "- "); ok {
rel := after
rel = strings.TrimSpace(rel)
abs := filepath.Join(buildDir, rel)
if _, err := os.Stat(abs); err == nil {
files = append(files, abs)
}
}
}

if len(files) == 0 {
return []string{composePath}
}
return files
}

func validateCmd(dir *string) *cobra.Command {
return &cobra.Command{
Use: "validate",
Expand Down Expand Up @@ -365,6 +425,7 @@ func describePlugin(name string, plugin resolve.FeaturePlugin) string {
"claude-code": "Anthropic Claude Code runtime configuration",
"pi": "Pi coding agent runtime configuration",
"mcp-oauth": "OAuth token injection for remote MCP servers",
"ssh": "SSH server for remote development access",
}
if desc, ok := descriptions[name]; ok {
return desc
Expand Down Expand Up @@ -414,7 +475,7 @@ func initCmd() *cobra.Command {

var features []string
var envVars []string
for _, ch := range strings.Split(featureChoice, ",") {
for ch := range strings.SplitSeq(featureChoice, ",") {
switch strings.TrimSpace(ch) {
case "1":
features = append(features, "github-pat")
Expand Down Expand Up @@ -445,13 +506,13 @@ func initCmd() *cobra.Command {
if username == "" {
username = "@your_username"
}
b.WriteString(" - plugin: telegram\n")
b.WriteString(" access_control:\n")
_, _ = fmt.Fprintf(&b, " allowed_users: [\"%s\"]\n", username)
case "custom-runtime":
b.WriteString(" - plugin: custom-runtime\n")
b.WriteString(" commands:\n")
b.WriteString(" - \"apt-get update && apt-get install -y --no-install-recommends ripgrep && rm -rf /var/lib/apt/lists/*\"\n")
b.WriteString(" - plugin: telegram\n")
b.WriteString(" access_control:\n")
_, _ = fmt.Fprintf(&b, " allowed_users: [\"%s\"]\n", username)
case "custom-runtime":
b.WriteString(" - plugin: custom-runtime\n")
b.WriteString(" commands:\n")
b.WriteString(" - \"apt-get update && apt-get install -y --no-install-recommends ripgrep && rm -rf /var/lib/apt/lists/*\"\n")
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions examples/local-coding-ssh/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Environment variables for agent-sandbox
# Copy to .env and fill in values

STX_LLM_GATEWAY_API_KEY=
2 changes: 2 additions & 0 deletions examples/local-coding-ssh/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ssh_key*
*ssh_host_key*
63 changes: 63 additions & 0 deletions examples/local-coding-ssh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Local Coding + SSH Example

Extends the base `local-coding` example with SSH access into the agent container on port 2222.

## Prerequisites

Generate an SSH key pair for agent access:

```bash
ssh-keygen -t ed25519 -f ssh_key -N ""
```

This creates `ssh_key` (private) and `ssh_key.pub` (public). The private key stays on your machine; the public key is mounted into the container as an authorized key.

Both files are gitignored — do not commit real keys.

## Setup

```bash
cd examples/local-coding-ssh

# Generate the SSH key pair (if not already done)
ssh-keygen -t ed25519 -f ssh_key -N ""

# Generate build artifacts
agent-sandbox generate

# Create .env from the example
cp .env.example .env
# Edit .env and fill in:
# STX_LLM_GATEWAY_API_KEY=your-api-key

# Build and run
agent-sandbox compose up --build
```

## Connecting via SSH

```bash
ssh -i ssh_key -p 2222 agent@localhost
```

### SSH Config (for Zed and other tools)

Add to `~/.ssh/config`:

```
Host agent-sandbox
HostName localhost
Port 2222
User agent
IdentityFile /path/to/examples/local-coding-ssh/ssh_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
```

Then connect with `ssh agent-sandbox` or use the host name in Zed's SSH remote connections.

## What's Included

- **external-services** — gateway intercepts HTTP requests to `host.containers.internal:8000` and injects your real API key from `.env`.
- **ssh** — starts an OpenSSH server on port 2222 inside the container, using your generated public key for authentication.
- **custom-runtime** — overlays codex configuration (model catalog, provider settings) into the agent's home directory.
19 changes: 19 additions & 0 deletions examples/local-coding-ssh/agent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# yaml-language-server: $schema=.build/schema.json
name: coder
runtime: codex
log_level: debug
features:
- plugin: external-services
services:
- url: http://host.containers.internal:8000/v1
headers:
Authorization: Bearer ${STX_LLM_GATEWAY_API_KEY}

- plugin: ssh
port: 2222
authorized_keys: "./ssh_key.pub"

- plugin: custom-runtime
home_override: "./home"
runtime_volumes:
- "agent-home:/home/agent"
18 changes: 18 additions & 0 deletions examples/local-coding-ssh/home/.codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# --- codex-switch:begin ---
model = "claude-opus-4.6"
model_provider = "agent_gateway_codex"
# --- codex-switch:end ---

model_catalog_json = "/home/agent/.codex/models.json"

[model_providers.agent_gateway_kiro]
name = "Agent Gateway (Kiro)"
base_url = "http://host.containers.internal:8000/v1"
http_headers = { Authorization = "Bearer dummy" }
wire_api = "responses"

[model_providers.agent_gateway_codex]
name = "Agent Gateway (Codex)"
base_url = "http://host.containers.internal:8000/v1"
http_headers = { Authorization = "Bearer dummy" }
wire_api = "responses"
Loading
Loading