From 91ba5eef50dc8e576f999f5050de2e60c4f860e7 Mon Sep 17 00:00:00 2001 From: "dorey-agent[bot]" <3504508+dorey-agent[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:27:17 +0000 Subject: [PATCH 1/2] feat: add SSH plugin with pre_entrypoint and ports support Extend the plugin contribution model with two new RuntimeContrib fields: - pre_entrypoint: commands injected into entrypoint.sh before exec - ports: port mappings exposed on the agent container Add core/plugins/ssh which uses these to install and start sshd inside the agent container for remote development access (IDE, debugging). --- core/plugins/ssh/README.md | 56 +++++++++++++++++++++++++ core/plugins/ssh/plugin.yaml | 26 ++++++++++++ internal/generate/v1/compose.go | 4 ++ internal/generate/v1/compose_test.go | 21 ++++++++++ internal/generate/v1/dockerfile.go | 14 ++++++- internal/generate/v1/dockerfile_test.go | 28 +++++++++++++ internal/generate/v1/generator.go | 2 +- internal/plugin/merge.go | 2 + internal/plugin/merge_test.go | 20 +++++++++ internal/plugin/render_test.go | 54 ++++++++++++++++++++++++ internal/plugin/types.go | 4 +- 11 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 core/plugins/ssh/README.md create mode 100644 core/plugins/ssh/plugin.yaml diff --git a/core/plugins/ssh/README.md b/core/plugins/ssh/README.md new file mode 100644 index 0000000..ecdc996 --- /dev/null +++ b/core/plugins/ssh/README.md @@ -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`. diff --git a/core/plugins/ssh/plugin.yaml b/core/plugins/ssh/plugin.yaml new file mode 100644 index 0000000..7dd3104 --- /dev/null +++ b/core/plugins/ssh/plugin.yaml @@ -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 }}" diff --git a/internal/generate/v1/compose.go b/internal/generate/v1/compose.go index 8c9b8e2..ab7e415 100644 --- a/internal/generate/v1/compose.go +++ b/internal/generate/v1/compose.go @@ -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 diff --git a/internal/generate/v1/compose_test.go b/internal/generate/v1/compose_test.go index 669670c..d72b268 100644 --- a/internal/generate/v1/compose_test.go +++ b/internal/generate/v1/compose_test.go @@ -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") +} diff --git a/internal/generate/v1/dockerfile.go b/internal/generate/v1/dockerfile.go index 0aa7cca..770655c 100644 --- a/internal/generate/v1/dockerfile.go +++ b/internal/generate/v1/dockerfile.go @@ -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. diff --git a/internal/generate/v1/dockerfile_test.go b/internal/generate/v1/dockerfile_test.go index a7a434f..4e26888 100644 --- a/internal/generate/v1/dockerfile_test.go +++ b/internal/generate/v1/dockerfile_test.go @@ -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 +} diff --git a/internal/generate/v1/generator.go b/internal/generate/v1/generator.go index 5cae77e..1ed128d 100644 --- a/internal/generate/v1/generator.go +++ b/internal/generate/v1/generator.go @@ -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) } diff --git a/internal/plugin/merge.go b/internal/plugin/merge.go index 8e97826..0a2e1b4 100644 --- a/internal/plugin/merge.go +++ b/internal/plugin/merge.go @@ -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 { diff --git a/internal/plugin/merge_test.go b/internal/plugin/merge_test.go index 04a1693..2215ced 100644 --- a/internal/plugin/merge_test.go +++ b/internal/plugin/merge_test.go @@ -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) +} diff --git a/internal/plugin/render_test.go b/internal/plugin/render_test.go index 5cb16ff..6b846ad 100644 --- a/internal/plugin/render_test.go +++ b/internal/plugin/render_test.go @@ -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]) +} diff --git a/internal/plugin/types.go b/internal/plugin/types.go index b64ed30..35c9470 100644 --- a/internal/plugin/types.go +++ b/internal/plugin/types.go @@ -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 { From 3d311224a278175325459f9c565098c739a15954 Mon Sep 17 00:00:00 2001 From: "dorey-agent[bot]" <3504508+dorey-agent[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:32:30 +0000 Subject: [PATCH 2/2] ci: retrigger (httpbin.org 503 flake)