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
56 changes: 56 additions & 0 deletions core/plugins/ssh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ssh

SSH server inside the agent container for remote development access (IDE, debugging, file transfer).

## How It Works

Installs OpenSSH server at build time. On container startup, sshd launches in the background before the agent process starts. Only public key authentication is allowed — no passwords.

A new host key is generated at build time. If you need a persistent host key (to avoid fingerprint warnings across rebuilds), mount one via `runtime.volumes`.

## Usage

```yaml
# agent.yaml
installations:
- plugin: ssh
options:
port: 2222
authorized_keys: "./ssh_key.pub"
```

Then connect:

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

## Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `port` | integer | no | `2222` | SSH port to expose on the host |
| `authorized_keys` | string | yes | — | Path to public key file (relative to project root) |

## What It Contributes

- **Runtime (build):** Installs openssh-server, configures sshd (key-only auth, custom port), copies authorized_keys
- **Runtime (pre_entrypoint):** Starts sshd daemon before agent CMD
- **Ports:** Exposes the SSH port on the host

## Persistent Host Key (optional)

To avoid SSH fingerprint warnings after rebuilds:

```bash
ssh-keygen -t ed25519 -f .ssh_host_key -N '' -C ''
```

```yaml
# agent.yaml
runtime:
volumes:
- "./.ssh_host_key:/etc/ssh/ssh_host_ed25519_key:ro"
```

Add `.ssh_host_key` to `.gitignore`.
26 changes: 26 additions & 0 deletions core/plugins/ssh/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: ssh
options:
port:
type: integer
required: false
default: 2222
description: "SSH port to expose"
authorized_keys:
type: string
required: true
description: "Path to public key file (relative to project root)"

contributes:
runtime:
extra_builds:
- "RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/*"
- "RUN mkdir -p /run/sshd /home/agent/.ssh"
- "RUN ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N '' -q"
- "RUN sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config"
- "RUN sed -i 's/^#*Port.*/Port {{ .options.port }}/' /etc/ssh/sshd_config"
- "COPY {{ .options.authorized_keys }} /home/agent/.ssh/authorized_keys"
- "RUN chmod 700 /home/agent/.ssh && chmod 600 /home/agent/.ssh/authorized_keys"
pre_entrypoint:
- "/usr/sbin/sshd -p {{ .options.port }}"
ports:
- "{{ .options.port }}:{{ .options.port }}"
4 changes: 4 additions & 0 deletions internal/generate/v1/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ func BuildCompose(cfg *config.V1Config, contribs *plugin.Contributions, projectD
"networks": []string{"sandbox"},
"volumes": agentVolumes,
}
// Expose ports from plugin contributions (e.g. SSH)
if contribs != nil && len(contribs.Runtime.Ports) > 0 {
agentSvc["ports"] = contribs.Runtime.Ports
}
compose.Services[agentName] = agentSvc

// Gateway service
Expand Down
21 changes: 21 additions & 0 deletions internal/generate/v1/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,24 @@ func TestBuildCompose_NoSidecars(t *testing.T) {
assert.Contains(t, output, "simple-agent-gateway:")
assert.NotContains(t, output, "telegram:")
}

func TestBuildCompose_PluginPorts(t *testing.T) {
cfg := &config.V1Config{
Name: "ssh-agent",
Runtime: config.RuntimeConfig{
Image: "@builtin/codex",
},
}

contribs := &plugin.Contributions{
Runtime: plugin.RuntimeContrib{
Ports: []string{"2222:2222"},
},
Sidecar: plugin.SidecarContrib{Services: map[string]plugin.ComposeService{}},
}

output, err := BuildCompose(cfg, contribs, "/project")
require.NoError(t, err)

assert.Contains(t, output, "2222:2222")
}
14 changes: 12 additions & 2 deletions internal/generate/v1/dockerfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,18 @@ exec "$@"
`

// EntrypointScript returns the transparent proxy bootstrap script content.
func EntrypointScript() string {
return entrypointScript
// preEntrypoint commands are injected before exec "$@".
func EntrypointScript(preEntrypoint []string) string {
if len(preEntrypoint) == 0 {
return entrypointScript
}
// Insert pre_entrypoint commands before the final exec "$@"
var extra string
extra += "\n# Plugin pre-entrypoint commands\n"
for _, cmd := range preEntrypoint {
extra += cmd + "\n"
}
return strings.Replace(entrypointScript, `exec "$@"`, extra+`exec "$@"`, 1)
}

// BuildDockerfile generates a Dockerfile string from config and plugin contributions.
Expand Down
28 changes: 28 additions & 0 deletions internal/generate/v1/dockerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,31 @@ func TestBuildDockerfile_CustomImage(t *testing.T) {
assert.Contains(t, output, `CMD ["python","main.py"]`)
assert.NotContains(t, output, "npm install")
}

func TestEntrypointScript_NoPreEntrypoint(t *testing.T) {
script := EntrypointScript(nil)
assert.Contains(t, script, `exec "$@"`)
assert.NotContains(t, script, "pre-entrypoint")
}

func TestEntrypointScript_WithPreEntrypoint(t *testing.T) {
cmds := []string{"/usr/sbin/sshd -p 2222", "/usr/bin/other-daemon"}
script := EntrypointScript(cmds)

assert.Contains(t, script, "/usr/sbin/sshd -p 2222")
assert.Contains(t, script, "/usr/bin/other-daemon")
assert.Contains(t, script, "# Plugin pre-entrypoint commands")
// pre_entrypoint must come before exec
sshdIdx := indexOf(script, "/usr/sbin/sshd -p 2222")
execIdx := indexOf(script, `exec "$@"`)
assert.Greater(t, execIdx, sshdIdx, "pre_entrypoint commands must come before exec")
}

func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
2 changes: 1 addition & 1 deletion internal/generate/v1/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func (g *Generator) Run() error {
if err := os.WriteFile(filepath.Join(buildDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
}
if err := os.WriteFile(filepath.Join(buildDir, "entrypoint.sh"), []byte(EntrypointScript()), 0755); err != nil {
if err := os.WriteFile(filepath.Join(buildDir, "entrypoint.sh"), []byte(EntrypointScript(merged.Runtime.PreEntrypoint)), 0755); err != nil {
return fmt.Errorf("write entrypoint.sh: %w", err)
}

Expand Down
2 changes: 2 additions & 0 deletions internal/plugin/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ func MergeContributions(contribs ...*Contributions) *Contributions {
continue
}
merged.Runtime.ExtraBuilds = append(merged.Runtime.ExtraBuilds, c.Runtime.ExtraBuilds...)
merged.Runtime.PreEntrypoint = append(merged.Runtime.PreEntrypoint, c.Runtime.PreEntrypoint...)
merged.Runtime.Ports = append(merged.Runtime.Ports, c.Runtime.Ports...)
merged.Gateway.Services = append(merged.Gateway.Services, c.Gateway.Services...)
merged.Gateway.Volumes = append(merged.Gateway.Volumes, c.Gateway.Volumes...)
for name, svc := range c.Sidecar.Services {
Expand Down
20 changes: 20 additions & 0 deletions internal/plugin/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,23 @@ func TestMergeContributions_Empty(t *testing.T) {
assert.NotNil(t, merged.Sidecar.Services)
assert.Empty(t, merged.Runtime.ExtraBuilds)
}

func TestMergeContributions_PreEntrypointAndPorts(t *testing.T) {
a := &Contributions{
Runtime: RuntimeContrib{
PreEntrypoint: []string{"/usr/sbin/sshd -p 2222"},
Ports: []string{"2222:2222"},
},
}
b := &Contributions{
Runtime: RuntimeContrib{
PreEntrypoint: []string{"/usr/bin/some-daemon"},
Ports: []string{"8080:8080"},
},
}

merged := MergeContributions(a, b)

assert.Equal(t, []string{"/usr/sbin/sshd -p 2222", "/usr/bin/some-daemon"}, merged.Runtime.PreEntrypoint)
assert.Equal(t, []string{"2222:2222", "8080:8080"}, merged.Runtime.Ports)
}
54 changes: 54 additions & 0 deletions internal/plugin/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,57 @@ contributes:

assert.Equal(t, "RUN install v1.0.0", rendered.Runtime.ExtraBuilds[0])
}

func TestRenderContributions_PreEntrypointAndPorts(t *testing.T) {
raw := `
name: ssh
options:
port:
type: integer
default: 2222
contributes:
runtime:
extra_builds:
- "RUN apt-get install -y openssh-server"
pre_entrypoint:
- "/usr/sbin/sshd -p {{ .options.port }}"
ports:
- "{{ .options.port }}:{{ .options.port }}"
`
p, err := ParsePluginYAML([]byte(raw))
require.NoError(t, err)

opts := map[string]any{}
rendered, err := RenderContributions(p, opts)
require.NoError(t, err)

require.Len(t, rendered.Runtime.PreEntrypoint, 1)
assert.Equal(t, "/usr/sbin/sshd -p 2222", rendered.Runtime.PreEntrypoint[0])
require.Len(t, rendered.Runtime.Ports, 1)
assert.Equal(t, "2222:2222", rendered.Runtime.Ports[0])
}

func TestRenderContributions_PreEntrypointCustomPort(t *testing.T) {
raw := `
name: ssh
options:
port:
type: integer
default: 2222
contributes:
runtime:
pre_entrypoint:
- "/usr/sbin/sshd -p {{ .options.port }}"
ports:
- "{{ .options.port }}:{{ .options.port }}"
`
p, err := ParsePluginYAML([]byte(raw))
require.NoError(t, err)

opts := map[string]any{"port": 8022}
rendered, err := RenderContributions(p, opts)
require.NoError(t, err)

assert.Equal(t, "/usr/sbin/sshd -p 8022", rendered.Runtime.PreEntrypoint[0])
assert.Equal(t, "8022:8022", rendered.Runtime.Ports[0])
}
4 changes: 3 additions & 1 deletion internal/plugin/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ type Contributions struct {
}

type RuntimeContrib struct {
ExtraBuilds []string `yaml:"extra_builds"`
ExtraBuilds []string `yaml:"extra_builds"`
PreEntrypoint []string `yaml:"pre_entrypoint"`
Ports []string `yaml:"ports"`
}

type GatewayContrib struct {
Expand Down
Loading