From 28c6c6a5ffcb2875a6346b5956e6732b436c481a Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Sun, 10 May 2026 20:07:47 +0200 Subject: [PATCH 01/13] Network Mode host init --- .../design-build-resources.md | 2 +- .../bootstrap-ai-coding/design-components.md | 31 +- .../bootstrap-ai-coding/design-data-models.md | 20 +- .../bootstrap-ai-coding/design-docker.md | 13 +- .../bootstrap-ai-coding/design-properties.md | 14 +- .../requirements-cli-combinations.md | 70 ++-- .../bootstrap-ai-coding/requirements-core.md | 31 +- .../requirements-two-layer-image.md | 4 +- .kiro/specs/bootstrap-ai-coding/tasks.md | 193 ++++++--- internal/agents/augment/integration_test.go | 1 + .../agents/buildresources/integration_test.go | 1 + internal/agents/claude/integration_test.go | 1 + internal/cmd/root.go | 39 +- internal/cmd/root_test.go | 31 ++ internal/datadir/datadir.go | 27 ++ internal/datadir/datadir_test.go | 60 +++ internal/docker/builder.go | 8 +- internal/docker/builder_test.go | 48 ++- internal/docker/export_test.go | 10 + internal/docker/integration_test.go | 365 +++++++++++++++++- internal/docker/runner.go | 69 ++-- internal/docker/runner_network_test.go | 140 +++++++ 22 files changed, 1011 insertions(+), 167 deletions(-) create mode 100644 internal/docker/export_test.go create mode 100644 internal/docker/runner_network_test.go diff --git a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md index 151bf54..49f04d5 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md +++ b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md @@ -196,7 +196,7 @@ RUN echo manifest > /bac-manifest.json ← manifest FROM bac-base:latest RUN SSH host key injection ← per-project (core Req 13) RUN SSH authorized_keys ← per-user key (core Req 4) -RUN sshd_config hardening ← stable +RUN sshd_config hardening + Port/ListenAddress ← per-project (Req 26.2) RUN mkdir /run/sshd ← stable CMD ["/usr/sbin/sshd", "-D"] ← always last (Req 21.2) ``` diff --git a/.kiro/specs/bootstrap-ai-coding/design-components.md b/.kiro/specs/bootstrap-ai-coding/design-components.md index de4ef8b..6556009 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-components.md +++ b/.kiro/specs/bootstrap-ai-coding/design-components.md @@ -322,13 +322,25 @@ func validateRestartPolicy(policy string) error { **Application in `docker/runner.go`** (`CreateContainer`): -The `ContainerSpec.RestartPolicy` field is mapped to the Docker SDK's `container.RestartPolicy` struct in `HostConfig`: +The `ContainerSpec.RestartPolicy` field is mapped to the Docker SDK's `container.RestartPolicy` struct in `HostConfig`. The network mode depends on `ContainerSpec.HostNetworkOff` (Req 26): ```go import "github.com/docker/docker/api/types/container" +// Host network mode (default: HostNetworkOff == false) hostConfig := &container.HostConfig{ - // ... existing port bindings, mounts, etc. + NetworkMode: "host", // Req 26: share host network namespace + Mounts: mounts, + RestartPolicy: container.RestartPolicy{ + Name: container.RestartPolicyMode(spec.RestartPolicy), + }, + // No PortBindings — host network mode makes them unnecessary (Req 26.4) +} + +// Bridge mode (HostNetworkOff == true, i.e. --host-network-off IS set) +hostConfig := &container.HostConfig{ + PortBindings: portBindings, // maps container:22 → host:SSH_Port + Mounts: mounts, RestartPolicy: container.RestartPolicy{ Name: container.RestartPolicyMode(spec.RestartPolicy), }, @@ -343,6 +355,21 @@ hostConfig := &container.HostConfig{ 4. Passes it to `ContainerSpec.RestartPolicy` when constructing the spec 5. `docker/runner.go` applies it in `CreateContainer` +**`--host-network-off` flag (Req 26):** + +```go +rootCmd.Flags().Bool("host-network-off", false, + "Disable host network mode; use bridge networking with port mapping instead") +``` + +**Threading from CLI to runner:** + +1. `cmd/root.go` reads `--host-network-off` flag value (default: `false`) +2. Stores it in `Config.HostNetworkOff` +3. Passes it to `ContainerSpec.HostNetworkOff` when constructing the spec +4. `docker/runner.go` selects `NetworkMode: "host"` or bridge + port bindings in `CreateContainer` +5. Passes it to `NewInstanceImageBuilder` to control whether sshd_config includes `Port`/`ListenAddress` directives + **Behaviour with `--stop-and-remove`:** When `--stop-and-remove` is used, the container is stopped via `docker stop` (which sends SIGTERM) and then removed. A container with `unless-stopped` policy that was explicitly stopped will NOT restart on reboot — Docker tracks the "stopped by user" state. Removal deletes the container entirely, so there is nothing to restart. diff --git a/.kiro/specs/bootstrap-ai-coding/design-data-models.md b/.kiro/specs/bootstrap-ai-coding/design-data-models.md index 460519f..a7effaa 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-data-models.md +++ b/.kiro/specs/bootstrap-ai-coding/design-data-models.md @@ -43,6 +43,7 @@ type Config struct { NoUpdateKnownHosts bool NoUpdateSSHConfig bool RestartPolicy string // Docker restart policy (default: "unless-stopped") + HostNetworkOff bool // Req 26: when true, use bridge mode instead of host network CredStoreOverrides map[string]string HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity } @@ -52,14 +53,15 @@ type Config struct { ```go type ContainerSpec struct { - Name string - ImageTag string - Dockerfile string - Mounts []Mount - SSHPort int - Labels map[string]string - RestartPolicy string // Req 25: Docker restart policy name - HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) + Name string + ImageTag string + Dockerfile string + Mounts []Mount + SSHPort int + Labels map[string]string + RestartPolicy string // Req 25: Docker restart policy name + HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network + HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) } type Mount struct { @@ -93,7 +95,7 @@ type SessionSummary struct { | `--stop-and-remove` and `--purge` both set | CLI-1 | Descriptive error → stderr, exit 1 | | START or STOP mode and `` absent | CLI-2 | Usage message → stderr, exit 1 | | PURGE mode and `` provided | CLI-2 | Descriptive error → stderr, exit 1 | -| STOP or PURGE mode and any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy` set | CLI-3 | Descriptive error naming the incompatible flag(s) → stderr, exit 1 | +| STOP or PURGE mode and any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy`, `--host-network-off` set | CLI-3 | Descriptive error naming the incompatible flag(s) → stderr, exit 1 | | `--port` value outside 1024–65535 | CLI-5 | Descriptive error → stderr, exit 1 | | `--agents` parses to empty list | CLI-6 | Descriptive error → stderr, exit 1 | | `--agents` contains unknown agent ID | CLI-6 | Unknown ID + available IDs → stderr, exit 1 | diff --git a/.kiro/specs/bootstrap-ai-coding/design-docker.md b/.kiro/specs/bootstrap-ai-coding/design-docker.md index 2934c89..74fdb27 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-docker.md +++ b/.kiro/specs/bootstrap-ai-coding/design-docker.md @@ -42,8 +42,10 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, // NewInstanceImageBuilder produces the Dockerfile for bac-:latest. // Starts with FROM bac-base:latest, adds only per-project SSH config + CMD. +// When hostNetworkOff is false (default), sshd_config includes Port and ListenAddress +// directives for host network mode. When true, sshd uses default port 22 (bridge mode). func NewInstanceImageBuilder(info *hostinfo.Info, - publicKey, hostKeyPriv, hostKeyPub string) *DockerfileBuilder + publicKey, hostKeyPriv, hostKeyPub string, sshPort int, hostNetworkOff bool) *DockerfileBuilder ``` The existing `NewDockerfileBuilder` is replaced by these two functions. Agent `Install()` methods are called on the base builder only. The instance builder has no agent steps — it's just SSH key injection + CMD. @@ -116,6 +118,15 @@ When `--rebuild` is set: 3. Build Instance_Image (inherits fresh base) 4. Create and start new container +### `--host-network-off` and Instance_Image + +The `--host-network-off` flag (Req 26) affects the Instance_Image content: + +- **Default (host network mode):** `NewInstanceImageBuilder` appends `Port ` and `ListenAddress 127.0.0.1` to sshd_config. The container is created with `NetworkMode: "host"` and no port bindings. +- **`--host-network-off` set (bridge mode):** `NewInstanceImageBuilder` omits the `Port` and `ListenAddress` directives — sshd uses its default port 22. The container is created with bridge networking and Docker port bindings (`127.0.0.1:` → container port 22). + +Changing `--host-network-off` between invocations produces a different Instance_Image (different sshd_config). The CLI detects this mismatch and requires `--rebuild` to regenerate the Instance_Image. + ### `--stop-and-remove` Behavior No change to image handling. Only the container is stopped/removed. Both Base_Image and Instance_Image remain cached for fast restart. diff --git a/.kiro/specs/bootstrap-ai-coding/design-properties.md b/.kiro/specs/bootstrap-ai-coding/design-properties.md index c30845f..0d9a00e 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-properties.md +++ b/.kiro/specs/bootstrap-ai-coding/design-properties.md @@ -215,11 +215,11 @@ --- -#### Property 22b: SSH port is always bound to the selected host port +#### Property 22b: SSH port configuration matches the selected network mode -*For any* valid port number, the container spec SHALL contain a port binding mapping container port `constants.ContainerSSHPort/tcp` to that host port. +*For any* valid port number and network mode setting: WHEN host network mode is active, the Instance_Image Dockerfile SHALL contain sshd_config directives setting `Port ` and `ListenAddress 127.0.0.1`, and the container spec SHALL use `NetworkMode: "host"` with no `PortBindings` or `ExposedPorts`. WHEN bridge mode is active, the Instance_Image Dockerfile SHALL NOT contain `Port` or `ListenAddress` directives, and the container spec SHALL contain a port binding mapping container port `constants.ContainerSSHPort/tcp` to `constants.HostBindIP:`. -**Validates: Req 12.4** +**Validates: Req 12.4, Req 26.2, Req 26.4, Req 26.6** --- @@ -329,6 +329,14 @@ --- +#### Property 57: --host-network-off controls network mode and sshd_config + +*For any* container creation: WHEN `HostNetworkOff` is `false` (default), the `ContainerSpec` SHALL produce a container with `NetworkMode: "host"` and no port bindings, and the Instance_Image Dockerfile SHALL contain `Port ` and `ListenAddress 127.0.0.1` in sshd_config. WHEN `HostNetworkOff` is `true`, the `ContainerSpec` SHALL produce a container with default bridge networking and port bindings mapping `constants.ContainerSSHPort/tcp` to `constants.HostBindIP:`, and the Instance_Image Dockerfile SHALL NOT contain `Port` or `ListenAddress` directives. + +**Validates: Req 26.1, 26.2, 26.4, 26.6, 26.7, 26.13** + +--- + ### Agent Module Properties #### Property 27: All registered agents satisfy the Agent interface diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md b/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md index e89e431..6d3872d 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-cli-combinations.md @@ -17,6 +17,7 @@ This document defines which flag combinations are valid, invalid, or redundant. | `C` | `--no-update-ssh-config` | | `V` | `--verbose` | | `D` | `--docker-restart-policy` | +| `H` | `--host-network-off` | | `S` | `--stop-and-remove` | | `U` | `--purge` | @@ -71,11 +72,11 @@ IF START or STOP mode AND `P` is absent THEN THE CLI SHALL print a usage message ### Requirement CLI-3: START-only flags are invalid in STOP and PURGE modes -`A`, `T`, `K`, `R`, `N`, `C`, `V`, and `D` are only meaningful in START mode. They have no effect on STOP or PURGE operations and must not be silently ignored. +`A`, `T`, `K`, `R`, `N`, `C`, `V`, `D`, and `H` are only meaningful in START mode. They have no effect on STOP or PURGE operations and must not be silently ignored. -**Formal:** `(S ∨ U) → ¬(A ∨ T ∨ K ∨ R ∨ N ∨ C ∨ V ∨ D)` +**Formal:** `(S ∨ U) → ¬(A ∨ T ∨ K ∨ R ∨ N ∨ C ∨ V ∨ D ∨ H)` -IF STOP or PURGE mode AND any of `A`, `T`, `K`, `R`, `N`, `C`, `V`, `D` is provided THEN THE CLI SHALL print a descriptive error to stderr identifying the incompatible flag(s) and exit with a non-zero exit code. +IF STOP or PURGE mode AND any of `A`, `T`, `K`, `R`, `N`, `C`, `V`, `D`, `H` is provided THEN THE CLI SHALL print a descriptive error to stderr identifying the incompatible flag(s) and exit with a non-zero exit code. --- @@ -125,33 +126,36 @@ IF `D` is provided AND its value is not one of `no`, `always`, `unless-stopped`, The table below lists all meaningful flag combinations. `✓` = present, `∅` = absent/default, `✗` = forbidden. -| Mode | P | A | T | K | R | N | C | V | D | S | U | Valid? | -|---|---|---|---|---|---|---|---|---|---|---|---|---| -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ minimal start | -| START | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom agents | -| START | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom port | -| START | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom SSH key | -| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip known_hosts update | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ skip SSH config update | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ verbose build output | -| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ custom restart policy | -| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ force rebuild + verbose | -| START | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ∅ | ∅ | ✓ all start flags | -| STOP | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | -| PURGE | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✗ no mode, no path | -| — | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: A with S | -| — | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: T with S | -| — | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: R with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: N with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: C with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ∅ | ✗ CLI-3: V with S | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ∅ | ✗ CLI-3: D with S | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: N with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: C with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ✗ CLI-3: V with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ✗ CLI-3: D with U | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ✗ CLI-1: S ∧ U | -| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-2: P with U | -| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-2: S without P | +| Mode | P | A | T | K | R | N | C | V | D | H | S | U | Valid? | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ minimal start | +| START | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom agents | +| START | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom port | +| START | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ custom SSH key | +| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip known_hosts update | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ skip SSH config update | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ verbose build output | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ custom restart policy | +| START | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ disable host network | +| START | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ force rebuild + verbose | +| START | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ∅ | ∅ | ✓ all start flags | +| STOP | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | +| PURGE | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✗ no mode, no path | +| — | ✓ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: A with S | +| — | ✓ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: T with S | +| — | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: R with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: N with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: C with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-3: V with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ∅ | ✗ CLI-3: D with S | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ∅ | ✗ CLI-3: H with S | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: N with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: C with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-3: V with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ∅ | ✓ | ✗ CLI-3: D with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✓ | ✗ CLI-3: H with U | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✓ | ✗ CLI-1: S ∧ U | +| — | ✓ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ✗ CLI-2: P with U | +| — | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | ✓ | ∅ | ✗ CLI-2: S without P | diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 0cc0303..ed0522f 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -31,7 +31,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana - **Enabled_Agents**: The set of Agent modules selected by the user for a given Container, specified via CLI flag or tool configuration. - **Credential_Store**: The directory on the Host where an Agent's authentication tokens are persisted between Sessions. Each Agent module declares its own default Credential_Store path via the Agent_Interface. - **Credential_Volume**: The Docker bind-mount that makes an Agent's Credential_Store accessible inside the Container at the path the Agent expects. -- **SSH_Port**: The host-side TCP port mapped to port 22 inside the Container. The CLI selects the SSH_Port by starting at `2222` and incrementing by 1 until a free port is found on the Host. The selected port is persisted in the Tool_Data_Dir for the project so the same port is reused on subsequent runs. Can be overridden per invocation via `--port`. +- **SSH_Port**: The TCP port on which the SSH_Server is reachable from the Host at `127.0.0.1`. In host network mode (Req 26, default), sshd listens directly on this port inside the Container. In bridge mode (`--host-network-off`), Docker maps the Container's port 22 to this port on the Host. The CLI selects the SSH_Port by starting at `2222` and incrementing by 1 until a free port is found on the Host. The selected port is persisted in the Tool_Data_Dir for the project so the same port is reused on subsequent runs. Can be overridden per invocation via `--port`. - **Container_User_Home**: The home directory of the Container_User inside the Container. Defined as the Host_User's home directory path (e.g. `/home/alice`). This ensures absolute paths stored by tools resolve identically inside the Container and on the Host. - **Tool_Data_Dir**: The directory on the Host where the CLI stores all persistent data for a given project (SSH host keys, SSH port assignment, agent manifests). Located at `~/.config/bootstrap-ai-coding//`. - **Known_Hosts_File**: The SSH client's `~/.ssh/known_hosts` file on the Host. @@ -79,8 +79,8 @@ The core application is responsible for all orchestration: Docker lifecycle mana #### Acceptance Criteria -1. WHEN a Container is started, THE SSH_Server SHALL be running and listening on port 22 inside the Container. -2. WHILE the Container is running, THE SSH_Server SHALL accept incoming connections on the SSH_Port. +1. WHEN a Container is started, THE SSH_Server SHALL be running and listening on the SSH_Port. In host network mode (Req 26, default), sshd listens on `127.0.0.1:` directly. In bridge mode (`--host-network-off`), sshd listens on port 22 and Docker maps it to the SSH_Port on the Host. +2. WHILE the Container is running, THE SSH_Server SHALL accept incoming connections on the SSH_Port from localhost. 3. IF the SSH_Server fails to start inside the Container, THEN THE CLI SHALL stop the Container, print a descriptive error message to stderr, and exit with a non-zero exit code. --- @@ -227,7 +227,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana 1. WHEN the CLI starts a Container for the first time, THE CLI SHALL select the SSH_Port by checking port `2222` on the Host; if it is in use, THE CLI SHALL increment by 1 and repeat until a free port is found. 2. THE selected SSH_Port SHALL be persisted in the Tool_Data_Dir for the project so that subsequent invocations reuse the same port. 3. THE CLI SHALL accept a `--port` flag that overrides the automatic SSH_Port selection for the Container; the overridden value SHALL also be persisted in the Tool_Data_Dir. -4. WHEN a Container is started, THE CLI SHALL bind the Container's internal SSH port 22 to the SSH_Port on the Host. +4. WHEN a Container is started, THE SSH_Server inside the Container SHALL be reachable on `127.0.0.1:` from the Host. In host network mode (Req 26), sshd listens directly on that address via sshd_config. In bridge mode, Docker port mapping binds the Container's port 22 to `127.0.0.1:` on the Host. 5. IF the persisted SSH_Port is in use on the Host by a process other than the Container for this project when the Container is started, THE CLI SHALL print a descriptive error message to stderr identifying the port conflict and exit with a non-zero exit code. 6. THE CLI SHALL print the SSH_Port and the full SSH connection command as part of the session summary defined in Requirement 17. @@ -438,3 +438,26 @@ The core application is responsible for all orchestration: Docker lifecycle mana 8. IF the `--docker-restart-policy` flag is provided with an invalid value (not one of `no`, `always`, `unless-stopped`, `on-failure`), THEN THE CLI SHALL print a descriptive error message to stderr listing the valid values and exit with a non-zero exit code. 9. THE `--docker-restart-policy` flag SHALL only be valid in START mode; it is a START-only flag subject to the CLI-3 constraint. 10. WHEN a Container already exists and the CLI reconnects to it, THE CLI SHALL NOT modify the existing Container's restart policy — the policy is set only at Container creation time. + +--- + +### Requirement 26: Host Network Mode + +**User Story:** As a developer, I want the container to share the host's network namespace by default, so that services running on the host (e.g. Vibe Kanban on localhost:3000) are directly accessible from inside the container, and services started inside the container are directly accessible from the host browser — without any additional port forwarding configuration. I also want the option to disable host networking and fall back to bridge mode with port mapping if needed. + +#### Acceptance Criteria + +1. THE CLI SHALL accept a `--host-network-off` flag (boolean, default: absent/false) that disables Docker host network mode. When `--host-network-off` is NOT set (the default), host network mode is active. When `--host-network-off` IS set, the Container uses bridge networking with port mapping. +2. WHEN `--host-network-off` is NOT set (the default), THE CLI SHALL configure the Container with Docker host network mode (`NetworkMode: "host"`), so the Container shares the Host's network namespace. +3. WHEN `--host-network-off` is NOT set, THE SSH_Server inside the Container SHALL be configured to listen on `127.0.0.1:` via sshd_config directives (`Port ` and `ListenAddress 127.0.0.1`) instead of the default port 22. +4. WHEN `--host-network-off` is NOT set, Docker port binding configuration (`PortBindings`, `ExposedPorts`) SHALL NOT be set on the Container — they are ignored by Docker in host network mode and would produce a warning. +5. WHEN `--host-network-off` is NOT set, THE Container SHALL be able to reach any TCP/UDP service listening on the Host's loopback or external interfaces without additional configuration. +6. WHEN `--host-network-off` IS set, THE CLI SHALL configure the Container with the default Docker bridge network and SHALL map the Container's internal SSH port (`constants.ContainerSSHPort`, port 22) to the SSH_Port on the Host via Docker port bindings (`HostIP: constants.HostBindIP`, `HostPort: SSH_Port`). +7. WHEN `--host-network-off` IS set, THE SSH_Server inside the Container SHALL listen on port 22 (the default sshd port). The sshd_config SHALL NOT include `Port` or `ListenAddress` directives — sshd uses its defaults. +8. THE existing SSH_Port selection logic (Requirement 12) SHALL remain unchanged regardless of `--host-network-off` — the port is auto-selected starting at `constants.SSHPortStart` (2222), incrementing until free, and persisted in the Tool_Data_Dir. +9. THE `--port` flag SHALL continue to override the SSH_Port regardless of `--host-network-off`. +10. THE `WaitForSSH` function SHALL connect to `127.0.0.1:` to verify SSH readiness in both modes (in host mode sshd listens directly on that port; in bridge mode Docker maps it). +11. THE `HostBindIP` constant (`127.0.0.1`) SHALL be used as the sshd bind address in host mode and as the Docker port binding IP in bridge mode, ensuring SSH is only accessible from localhost in both cases. +12. THE `--host-network-off` flag SHALL only be valid in START mode; it is a START-only flag subject to the CLI-3 constraint. +13. THE `--host-network-off` value SHALL influence the Instance_Image build: when absent (host mode), sshd_config includes `Port ` and `ListenAddress 127.0.0.1`; when set (bridge mode), these directives are omitted. +14. WHEN `--host-network-off` is changed between invocations for the same project (e.g. added or removed), THE CLI SHALL require `--rebuild` to regenerate the Instance_Image with the correct sshd_config. IF the network mode has changed and `--rebuild` is not set, THE CLI SHALL print a message instructing the user to run with `--rebuild` and exit with a zero exit code. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md index ac31f23..826dce3 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md @@ -48,9 +48,9 @@ This feature splits the current monolithic Container_Image build (core Req 14) i 1. WHEN an Instance_Image build is needed (no existing image, base was rebuilt, or `--rebuild`), THE Builder SHALL produce a Dockerfile starting with `FROM bac-base:latest`. 2. THE Instance_Image SHALL inject the project's persisted SSH host key pair (core Req 13) into `/etc/ssh/ssh_host_ed25519_key` (`0600`) and `.pub` (`0644`). 3. THE Instance_Image SHALL inject Public_Key (core Req 4) into `/.ssh/authorized_keys` (`0600`), directory at `0700`, owned by Container_User. -4. THE Instance_Image SHALL append sshd_config: `PasswordAuthentication no`, `PermitRootLogin no`, `PubkeyAuthentication yes`. +4. THE Instance_Image SHALL append sshd_config directives: `PasswordAuthentication no`, `PermitRootLogin no`, `PubkeyAuthentication yes`. WHEN host network mode is active (default, `--host-network-off` NOT set), THE Instance_Image SHALL additionally append `Port ` and `ListenAddress 127.0.0.1` to configure sshd to listen on the project's SSH_Port bound to localhost only. WHEN bridge mode is active (`--host-network-off` IS set), these port/address directives SHALL be omitted (sshd uses its default port 22). 5. THE Instance_Image SHALL create `/run/sshd`. -6. THE Instance_Image SHALL set `CMD ["/usr/sbin/sshd", "-D"]` as the final instruction. +6. THE Instance_Image SHALL set `CMD ["/usr/sbin/sshd", "-D"]` as the final instruction. sshd reads the port and bind address from sshd_config; no `-p` flag is needed in CMD. 7. THE Instance_Image SHALL be tagged `:latest`. 8. THE Instance_Image SHALL carry labels `bac.managed` = `"true"` and `bac.container` = Container_Name. diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md index e539ae4..529b02c 100644 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -1,88 +1,173 @@ -# Tasks: Container Restart Policy (Req 25, CLI-7) +# Tasks: Host Network Mode (Req 26) ## Task Dependency Graph +```mermaid +flowchart TD + T1[Task 1: Add --host-network-off flag to CLI] + T2[Task 2: Add HostNetworkOff to ContainerSpec] + T3[Task 3: Update CreateContainer for host network mode] + T4[Task 4: Update NewInstanceImageBuilder for sshd_config] + T5[Task 5: Update runStart orchestration] + T6[Task 6: Network mode change detection] + T7[Task 7: Unit tests for CLI flag] + T8[Task 8: Property test for network mode + sshd_config] + T9[Task 9: Integration test for host network mode] + + T1 --> T5 + T2 --> T3 + T2 --> T4 + T3 --> T5 + T4 --> T5 + T5 --> T6 + T1 --> T7 + T3 --> T8 + T4 --> T8 + T6 --> T9 ``` -Task 1 (constants) → Task 2 (ContainerSpec) → Task 3 (runner) → Task 4 (cmd flags) → Task 5 (tests) -``` --- -## Task 1: Add `DefaultRestartPolicy` constant +## Task 1: Add `--host-network-off` flag to CLI + +Add the `--host-network-off` boolean flag to `cmd/root.go`. Default is `false` (host network mode is ON). Register it as a START-only flag in `ValidateStartOnlyFlags`. + +### Sub-tasks + +- [x] 1.1. Add `flagHostNetworkOff bool` variable and register the flag in `init()`: + ```go + rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, "Disable host network mode; use bridge networking with port mapping") + ``` +- [x] 1.2. Add `"host-network-off": true` to the `startOnly` map in `ValidateStartOnlyFlags`. +- [x] 1.3. Thread `flagHostNetworkOff` into the `runStart` call (pass it as a parameter or add to a config struct). + +--- + +## Task 2: Add `HostNetworkOff` field to `ContainerSpec` + +Add the `HostNetworkOff bool` field to the `ContainerSpec` struct in `docker/runner.go`. + +### Sub-tasks + +- [x] 2.1. Add `HostNetworkOff bool` field to `ContainerSpec` struct with comment: `// Req 26: when true, use bridge mode; when false (default), use host network`. +- [x] 2.2. Update the `SSHPort` field comment from "Host-side TCP port mapped to container port 22" to "SSH port (used in sshd_config for host mode, or Docker port mapping for bridge mode)". + +--- + +## Task 3: Update `CreateContainer` for host network mode -- [x] Add `DefaultRestartPolicy = "unless-stopped"` to `internal/constants/constants.go` +Modify `CreateContainer` in `docker/runner.go` to conditionally use `NetworkMode: "host"` (no port bindings) or bridge mode (with port bindings) based on `spec.HostNetworkOff`. -### Files to modify -- `internal/constants/constants.go` +### Sub-tasks -### Acceptance criteria -- `constants.DefaultRestartPolicy` exists and equals `"unless-stopped"` -- No other package hardcodes the default restart policy string +- [x] 3.1. When `spec.HostNetworkOff == false` (default, host network mode): + - Set `HostConfig.NetworkMode = "host"` + - Do NOT set `PortBindings` or `ExposedPorts` + - Do NOT set `ExposedPorts` in `container.Config` +- [x] 3.2. When `spec.HostNetworkOff == true` (bridge mode): + - Keep existing port binding logic: map `constants.ContainerSSHPort/tcp` → `constants.HostBindIP:spec.SSHPort` + - Set `ExposedPorts` in `container.Config` + - Do NOT set `NetworkMode` (use Docker default bridge) +- [x] 3.3. Remove the `nat` import if it becomes conditionally unused (it won't — bridge mode still uses it). --- -## Task 2: Add `RestartPolicy` field to `ContainerSpec` +## Task 4: Update `NewInstanceImageBuilder` for sshd_config -- [x] Add `RestartPolicy string` field to the `ContainerSpec` struct in `internal/docker/runner.go` +Modify `NewInstanceImageBuilder` in `docker/builder.go` to accept a `hostNetworkOff bool` parameter. When `false` (host mode), append `Port ` and `ListenAddress 127.0.0.1` to sshd_config. When `true` (bridge mode), omit these directives. -### Files to modify -- `internal/docker/runner.go` +### Sub-tasks -### Acceptance criteria -- `ContainerSpec` has a `RestartPolicy string` field -- Existing code that constructs `ContainerSpec` still compiles (field is zero-value safe) +- [x] 4.1. Add `sshPort int` and `hostNetworkOff bool` parameters to `NewInstanceImageBuilder` signature. +- [x] 4.2. In step 4 (sshd_config hardening), conditionally append `Port` and `ListenAddress`: + ```go + sshdConfig := "echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config" + if !hostNetworkOff { + sshdConfig += fmt.Sprintf(" && echo 'Port %d' >> /etc/ssh/sshd_config && echo 'ListenAddress %s' >> /etc/ssh/sshd_config", sshPort, constants.HostBindIP) + } + b.Run(sshdConfig) + ``` +- [x] 4.3. Update all callers of `NewInstanceImageBuilder` to pass the new parameters (currently only `runStart` in `cmd/root.go`). --- -## Task 3: Apply restart policy in `CreateContainer` +## Task 5: Update `runStart` orchestration -- [x] In `CreateContainer` (`internal/docker/runner.go`), set `HostConfig.RestartPolicy` from `spec.RestartPolicy` -- [x] If `spec.RestartPolicy` is empty, default to `constants.DefaultRestartPolicy` +Wire the `--host-network-off` flag through the `runStart` function: pass it to `NewInstanceImageBuilder` and set it on the `ContainerSpec`. -### Files to modify -- `internal/docker/runner.go` +### Sub-tasks -### Acceptance criteria -- Containers created via `CreateContainer` have the Docker restart policy set -- When `RestartPolicy` is empty string, `unless-stopped` is used as fallback -- When `RestartPolicy` is explicitly set, that value is used +- [x] 5.1. Pass `flagHostNetworkOff` to `NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, sshPort, flagHostNetworkOff)`. +- [x] 5.2. Set `HostNetworkOff: flagHostNetworkOff` on the `ContainerSpec` passed to `CreateContainer`. +- [x] 5.3. Persist the `hostNetworkOff` value in the Tool_Data_Dir (for change detection in Task 6). Add `WriteHostNetworkOff` / `ReadHostNetworkOff` methods to `datadir.DataDir`. --- -## Task 4: Add `--docker-restart-policy` flag and validation in CLI +## Task 6: Network mode change detection -- [x] Add `flagDockerRestartPolicy string` variable -- [x] Register `--docker-restart-policy` flag in `init()` with default `constants.DefaultRestartPolicy` -- [x] Add `"docker-restart-policy"` to the `startOnly` map in `ValidateStartOnlyFlags` -- [x] Add validation: reject values not in `{no, always, unless-stopped, on-failure}` -- [x] Pass the flag value to `ContainerSpec.RestartPolicy` when creating the container in `runStart` +Detect when `--host-network-off` changes between invocations and require `--rebuild`. This prevents running a container whose sshd_config doesn't match the network mode. -### Files to modify -- `internal/cmd/root.go` +### Sub-tasks -### Acceptance criteria -- `--docker-restart-policy` flag is registered with default `"unless-stopped"` -- Invalid values produce a descriptive error listing valid options (exit 1) -- Flag is rejected in STOP and PURGE modes (CLI-3) -- The value is threaded through to `ContainerSpec.RestartPolicy` in `runStart` +- [x] 6.1. Add `datadir.WriteHostNetworkOff(off bool)` and `datadir.ReadHostNetworkOff() (bool, error)` — store as `"true"` or `"false"` in a file `host_network_off` in the Tool_Data_Dir. +- [x] 6.2. In `runStart`, after loading the persisted value, compare with the current `flagHostNetworkOff`. If they differ and `--rebuild` is not set, print "Network mode changed — run with --rebuild to update the image." and return nil (exit 0). +- [x] 6.3. When building the instance image (needInstance == true), persist the current `flagHostNetworkOff` value via `dd.WriteHostNetworkOff(flagHostNetworkOff)`. --- -## Task 5: Add unit and property-based tests +## Task 7: Unit tests for CLI flag + +Add unit tests for the `--host-network-off` flag validation and CLI-3 constraint. -- [x] Add `TestRestartPolicyDefaultIsUnlessStopped` — verify flag default -- [x] Add `TestRestartPolicyInvalidValueRejected` — verify invalid values produce errors -- [x] Add `TestRestartPolicyFlagWithStopRejected` — verify CLI-3 for STOP mode -- [x] Add `TestRestartPolicyFlagWithPurgeRejected` — verify CLI-3 for PURGE mode -- [x] Add `TestRestartPolicyAppliedToContainerSpec` — verify the value reaches `HostConfig` -- [x] Add property test (Property 55): for any string, validation accepts iff it's in the valid set -- [x] Add property test (Property 56): for any valid policy, ContainerSpec.RestartPolicy matches +### Sub-tasks + +- [x] 7.1. In `cmd/root_test.go`, add test: `--host-network-off` with `--stop-and-remove` → error (CLI-3). +- [x] 7.2. In `cmd/root_test.go`, add test: `--host-network-off` with `--purge` → error (CLI-3). +- [x] 7.3. In `cmd/root_test.go`, add test: `--host-network-off` in START mode → accepted (no error from flag validation). + +--- -### Files to modify -- `internal/cmd/root_test.go` -- `internal/docker/runner_test.go` (or new file `internal/docker/runner_restart_test.go`) +## Task 8: Property test for network mode + sshd_config (Property 22b, 57) + +Add property-based tests validating that the Instance_Image Dockerfile and ContainerSpec are correct for both network modes. + +### Sub-tasks + +- [x] 8.1. In `docker/builder_test.go`, add PBT: + ```go + // Feature: bootstrap-ai-coding, Property 57: --host-network-off controls network mode and sshd_config + func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + port := rapid.IntRange(1024, 65535).Draw(t, "port") + hostNetworkOff := rapid.Bool().Draw(t, "hostNetworkOff") + // Build instance image + // Assert: if !hostNetworkOff → contains "Port " and "ListenAddress 127.0.0.1" + // Assert: if hostNetworkOff → does NOT contain "Port" or "ListenAddress" + }) + } + ``` +- [x] 8.2. In `docker/runner_restart_test.go` (or a new `runner_network_test.go`), add a unit test verifying `CreateContainer` sets `NetworkMode: "host"` when `HostNetworkOff == false` and uses port bindings when `HostNetworkOff == true`. (This requires mocking the Docker client — follow existing patterns in `runner_restart_test.go`.) + +--- -### Acceptance criteria -- All new tests pass with `go test ./...` -- Property tests use `pgregory.net/rapid` with minimum 100 iterations -- Property test tag format: `// Feature: bootstrap-ai-coding, Property N: ` +## Task 9: Integration test for host network mode + +Add an integration test that verifies end-to-end: container starts with host network, sshd is reachable on the assigned port, and a service on the host is reachable from inside the container. + +### Sub-tasks + +- [x] 9.1. In `docker/integration_test.go`, add test `TestHostNetworkModeSSHReachable`: + - Build base + instance image with host network mode (hostNetworkOff=false) + - Create and start container + - Assert: `WaitForSSH(ctx, "127.0.0.1", sshPort, 10s)` succeeds + - Cleanup: stop and remove container +- [x] 9.2. In `docker/integration_test.go`, add test `TestBridgeModeSSHReachable`: + - Build base + instance image with bridge mode (hostNetworkOff=true) + - Create and start container + - Assert: `WaitForSSH(ctx, "127.0.0.1", sshPort, 10s)` succeeds + - Cleanup: stop and remove container +- [x] 9.3. (Optional) Add test `TestHostNetworkCanReachHostService`: + - Start a TCP listener on a random port on the host + - Start container in host network mode + - Exec `nc -z 127.0.0.1 ` inside the container + - Assert: exit code 0 (service reachable) diff --git a/internal/agents/augment/integration_test.go b/internal/agents/augment/integration_test.go index b88a2fa..7c72e17 100644 --- a/internal/agents/augment/integration_test.go +++ b/internal/agents/augment/integration_test.go @@ -142,6 +142,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() diff --git a/internal/agents/buildresources/integration_test.go b/internal/agents/buildresources/integration_test.go index 659c2bc..ca1bbce 100644 --- a/internal/agents/buildresources/integration_test.go +++ b/internal/agents/buildresources/integration_test.go @@ -142,6 +142,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() diff --git a/internal/agents/claude/integration_test.go b/internal/agents/claude/integration_test.go index 0051f3f..6880597 100644 --- a/internal/agents/claude/integration_test.go +++ b/internal/agents/claude/integration_test.go @@ -142,6 +142,7 @@ func setupSharedContainer() error { info, userPubKey, hostKeyPriv, hostKeyPub, + port, false, ) instanceBuilder.Finalize() diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b648982..0023f4a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -74,6 +74,7 @@ func ValidateStartOnlyFlags(mode Mode, changedFlags []string) error { "no-update-ssh-config": true, "verbose": true, "docker-restart-policy": true, + "host-network-off": true, } for _, name := range changedFlags { if startOnly[name] { @@ -147,6 +148,7 @@ var ( flagNoUpdateSSHConfig bool flagVerbose bool flagDockerRestartPolicy string + flagHostNetworkOff bool ) var rootCmd = &cobra.Command{ @@ -175,6 +177,7 @@ func init() { rootCmd.Flags().BoolVar(&flagNoUpdateSSHConfig, "no-update-ssh-config", false, "Skip automatic ~/.ssh/config management") rootCmd.Flags().BoolVarP(&flagVerbose, "verbose", "v", false, "Stream Docker build output to stdout in real time") rootCmd.Flags().StringVar(&flagDockerRestartPolicy, "docker-restart-policy", constants.DefaultRestartPolicy, "Docker restart policy for the container (no, always, unless-stopped, on-failure)") + rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, "Disable host network mode; use bridge networking with port mapping") } func run(cmd *cobra.Command, args []string) error { @@ -253,7 +256,7 @@ func run(cmd *cobra.Command, args []string) error { case ModePurge: return runPurge(dockerClient) default: - return runStart(dockerClient, args[0], enabledAgents) + return runStart(dockerClient, args[0], enabledAgents, flagHostNetworkOff) } } @@ -394,7 +397,7 @@ func runPurge(c *dockerpkg.Client) error { return nil } -func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Agent) error { +func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Agent, hostNetworkOff bool) error { ctx := context.Background() absPath, err := filepath.Abs(projectPath) @@ -532,6 +535,19 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age return fmt.Errorf("determining build requirements: %w", buildErr) } + // Network mode change detection: only check when an image already exists + // (i.e., not a first run where both layers need building). + if !needBase && !flagRebuild { + persistedOff, err := dd.ReadHostNetworkOff() + if err != nil { + return fmt.Errorf("reading persisted host network mode: %w", err) + } + if persistedOff != hostNetworkOff { + fmt.Println("Network mode changed — run with --rebuild to update the image.") + return nil + } + } + if needBase { // Check for UID/GID conflict in base image strategy := dockerpkg.UserStrategyCreate @@ -595,7 +611,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age } if needInstance { - instanceBuilder := dockerpkg.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + instanceBuilder := dockerpkg.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, sshPort, hostNetworkOff) instanceBuilder.Finalize() instanceSpec := dockerpkg.ContainerSpec{ @@ -613,6 +629,10 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age fmt.Fprint(os.Stderr, buildOutput) return fmt.Errorf("instance image build failed: %w", err) } + + if err := dd.WriteHostNetworkOff(hostNetworkOff); err != nil { + return fmt.Errorf("persisting host network mode: %w", err) + } } containerInfo, err := dockerpkg.InspectContainer(ctx, c, containerName) @@ -658,12 +678,13 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age } spec := dockerpkg.ContainerSpec{ - Name: containerName, - ImageTag: imageTag, - Mounts: mounts, - SSHPort: sshPort, - Labels: labels, - RestartPolicy: flagDockerRestartPolicy, + Name: containerName, + ImageTag: imageTag, + Mounts: mounts, + SSHPort: sshPort, + Labels: labels, + RestartPolicy: flagDockerRestartPolicy, + HostNetworkOff: hostNetworkOff, } if _, err := dockerpkg.CreateContainer(ctx, c, spec); err != nil { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 929dc9a..09eefa7 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -385,6 +385,37 @@ func TestPropertyRestartPolicyValidationAcceptsIffValid(t *testing.T) { }) } +// TestHostNetworkOffFlagAcceptedInStartMode verifies that --host-network-off +// is accepted when used in START mode (no error from flag validation). +func TestHostNetworkOffFlagAcceptedInStartMode(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModeStart, []string{"host-network-off"}) + require.NoError(t, err, "--host-network-off must be accepted in START mode") +} + +// TestHostNetworkOffFlagWithStopRejected verifies that --host-network-off +// is rejected when used with --stop-and-remove (CLI-3). +// Validates: CLI-3 +func TestHostNetworkOffFlagWithStopRejected(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModeStop, []string{"host-network-off"}) + require.Error(t, err) + require.Contains(t, err.Error(), "--host-network-off", + "error must name the offending flag") + require.Contains(t, err.Error(), "--stop-and-remove", + "error must name the conflicting mode flag") +} + +// TestHostNetworkOffFlagWithPurgeRejected verifies that --host-network-off +// is rejected when used with --purge (CLI-3). +// Validates: CLI-3 +func TestHostNetworkOffFlagWithPurgeRejected(t *testing.T) { + err := cmd.ValidateStartOnlyFlags(cmd.ModePurge, []string{"host-network-off"}) + require.Error(t, err) + require.Contains(t, err.Error(), "--host-network-off", + "error must name the offending flag") + require.Contains(t, err.Error(), "--purge", + "error must name the conflicting mode flag") +} + // TestRestartPolicyDefaultIsUnlessStopped verifies that the --docker-restart-policy // flag has default value "unless-stopped" (i.e., constants.DefaultRestartPolicy). // Validates: Req 25.2 diff --git a/internal/datadir/datadir.go b/internal/datadir/datadir.go index b4a9d00..11d5c7e 100644 --- a/internal/datadir/datadir.go +++ b/internal/datadir/datadir.go @@ -120,6 +120,33 @@ func (d *DataDir) WriteManifest(agentIDs []string) error { return nil } +// WriteHostNetworkOff persists the hostNetworkOff boolean as "true" or "false" +// in a file named host_network_off with constants.ToolDataFilePerm. +func (d *DataDir) WriteHostNetworkOff(off bool) error { + p := filepath.Join(d.path, "host_network_off") + if err := os.WriteFile(p, []byte(strconv.FormatBool(off)), constants.ToolDataFilePerm); err != nil { + return fmt.Errorf("writing host_network_off file: %w", err) + } + return nil +} + +// ReadHostNetworkOff reads the persisted hostNetworkOff value. +// Returns (false, nil) if the file does not exist (default: host network ON). +func (d *DataDir) ReadHostNetworkOff() (bool, error) { + data, err := os.ReadFile(filepath.Join(d.path, "host_network_off")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("reading host_network_off file: %w", err) + } + val, err := strconv.ParseBool(strings.TrimSpace(string(data))) + if err != nil { + return false, fmt.Errorf("parsing host_network_off file: %w", err) + } + return val, nil +} + // PurgeRoot removes the entire Tool_Data_Dir root and all its contents. func PurgeRoot() error { return os.RemoveAll(pathutil.ExpandHome(constants.ToolDataDirRoot)) diff --git a/internal/datadir/datadir_test.go b/internal/datadir/datadir_test.go index d1bf54c..c20be7d 100644 --- a/internal/datadir/datadir_test.go +++ b/internal/datadir/datadir_test.go @@ -285,6 +285,66 @@ func TestReadManifestCorruptJSON(t *testing.T) { require.Error(t, err, "ReadManifest must error on invalid JSON") } +// TestHostNetworkOffRoundTrip verifies that WriteHostNetworkOff followed by +// ReadHostNetworkOff returns the same boolean value. +func TestHostNetworkOffRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + // Not yet written — should return false, nil (default: host network ON). + got, err := dd.ReadHostNetworkOff() + require.NoError(t, err) + require.False(t, got) + + // Write true and read back. + require.NoError(t, dd.WriteHostNetworkOff(true)) + got, err = dd.ReadHostNetworkOff() + require.NoError(t, err) + require.True(t, got) + + // Write false and read back. + require.NoError(t, dd.WriteHostNetworkOff(false)) + got, err = dd.ReadHostNetworkOff() + require.NoError(t, err) + require.False(t, got) +} + +// TestWriteHostNetworkOffFilePermission asserts that WriteHostNetworkOff writes +// the file with constants.ToolDataFilePerm. +func TestWriteHostNetworkOffFilePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not applicable on Windows") + } + + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + require.NoError(t, dd.WriteHostNetworkOff(true)) + + info, err := os.Stat(filepath.Join(dd.Path(), "host_network_off")) + require.NoError(t, err) + require.Equal(t, os.FileMode(constants.ToolDataFilePerm), info.Mode().Perm(), + "host_network_off file has mode %04o, want %04o", info.Mode().Perm(), constants.ToolDataFilePerm) +} + +// TestReadHostNetworkOffCorruptContent verifies that ReadHostNetworkOff returns +// an error when the file contains non-boolean content. +func TestReadHostNetworkOffCorruptContent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dd, err := datadir.New("test-container") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dd.Path(), "host_network_off"), []byte("not-a-bool"), constants.ToolDataFilePerm)) + + _, err = dd.ReadHostNetworkOff() + require.Error(t, err, "ReadHostNetworkOff must error on non-boolean content") +} + // TestExpandHomeNoTilde verifies that expandHome (via New) handles paths // without a tilde prefix correctly. We exercise this indirectly by setting // HOME to an absolute path and confirming New succeeds. diff --git a/internal/docker/builder.go b/internal/docker/builder.go index fb7d8d6..7b2d705 100644 --- a/internal/docker/builder.go +++ b/internal/docker/builder.go @@ -149,7 +149,7 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, conflicting // info carries the runtime-resolved Container_User identity (Req 22). // publicKey is the content of the user's SSH public key. // hostKeyPriv and hostKeyPub are the persisted SSH host key pair contents. -func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKeyPub string) *DockerfileBuilder { +func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKeyPub string, sshPort int, hostNetworkOff bool) *DockerfileBuilder { b := &DockerfileBuilder{info: info} // 1. FROM the shared base image @@ -181,7 +181,11 @@ func NewInstanceImageBuilder(info *hostinfo.Info, publicKey, hostKeyPriv, hostKe )) // 4. Harden sshd_config - b.Run("echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config") + sshdConfig := "echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config" + if !hostNetworkOff { + sshdConfig += fmt.Sprintf(" && echo 'Port %d' >> /etc/ssh/sshd_config && echo 'ListenAddress %s' >> /etc/ssh/sshd_config", sshPort, constants.HostBindIP) + } + b.Run(sshdConfig) // 5. Ensure sshd runtime dir exists b.Run("mkdir -p /run/sshd") diff --git a/internal/docker/builder_test.go b/internal/docker/builder_test.go index d4c8aa6..a63df2e 100644 --- a/internal/docker/builder_test.go +++ b/internal/docker/builder_test.go @@ -62,6 +62,7 @@ func newInstanceBuilder(uid, gid int) *docker.DockerfileBuilder { testInfo(uid, gid), fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) } @@ -402,6 +403,7 @@ func TestPropertyPublicKeyInjected_Create(t *testing.T) { testInfo(uid, gid), publicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) content := b.Build() @@ -423,6 +425,7 @@ func TestPropertyPublicKeyInjected_Rename(t *testing.T) { testInfo(uid, gid), publicKey, fixedHostKeyPriv, fixedHostKeyPub, + 2222, false, ) content := b.Build() @@ -450,6 +453,7 @@ func TestPropertySSHHostKeyInjected_Create(t *testing.T) { testInfo(uid, gid), fixedPublicKey, hostKeyPriv, hostKeyPub, + 2222, false, ) content := b.Build() @@ -477,6 +481,7 @@ func TestPropertySSHHostKeyInjected_Rename(t *testing.T) { testInfo(uid, gid), fixedPublicKey, hostKeyPriv, hostKeyPub, + 2222, false, ) content := b.Build() @@ -878,7 +883,7 @@ func TestPropertyDockerfileSSHAndUserForAnyUsername(t *testing.T) { "Base Dockerfile must contain sudoers entry for username %q with NOPASSWD", username) // Assert: sshd CMD is in the instance layer - ib := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub) + ib := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, 2222, false) ib.Finalize() instanceContent := ib.Build() require.Contains(t, instanceContent, `CMD ["/usr/sbin/sshd", "-D"]`, @@ -930,7 +935,7 @@ func TestPropertyDockerfileUsesRuntimeUsernameAndHomeDir(t *testing.T) { "sudoers must reference the drawn username %q", username) // Build an instance image Dockerfile - instanceBuilder := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub) + instanceBuilder := docker.NewInstanceImageBuilder(info, fixedPublicKey, fixedHostKeyPriv, fixedHostKeyPub, 2222, false) instanceContent := instanceBuilder.Build() // Assert the instance Dockerfile contains the drawn username in chown @@ -1322,7 +1327,7 @@ func TestPropertyTwoLayer_InstanceImageStartsWithFROM(t *testing.T) { GID: gid, } - b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, 2222, false) lines := b.Lines() wantFrom := "FROM " + constants.BaseImageName + ":latest" @@ -1404,7 +1409,7 @@ func TestPropertyTwoLayer_InstanceImageEndsWithCMDAfterFinalize(t *testing.T) { GID: gid, } - b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub) + b := docker.NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, 2222, false) b.Finalize() lines := b.Lines() @@ -1458,3 +1463,38 @@ func TestRunAsUserUsesInfoUsername(t *testing.T) { require.Equal(t, "USER root", lines[linesBefore+2], "RunAsUser must switch back to root") } + +// --------------------------------------------------------------------------- +// Property 57: --host-network-off controls network mode and sshd_config +// --------------------------------------------------------------------------- + +// Feature: bootstrap-ai-coding, Property 57: --host-network-off controls network mode and sshd_config +func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + port := rapid.IntRange(1024, 65535).Draw(t, "port") + hostNetworkOff := rapid.Bool().Draw(t, "hostNetworkOff") + + // Build instance image with drawn port and hostNetworkOff values + b := docker.NewInstanceImageBuilder( + testInfo(1000, 1000), + fixedPublicKey, + fixedHostKeyPriv, fixedHostKeyPub, + port, hostNetworkOff, + ) + content := b.Build() + + if !hostNetworkOff { + // When host network is used, sshd_config must contain Port and ListenAddress + require.Contains(t, content, fmt.Sprintf("Port %d", port), + "when !hostNetworkOff, sshd_config must contain Port %d", port) + require.Contains(t, content, "ListenAddress 127.0.0.1", + "when !hostNetworkOff, sshd_config must contain ListenAddress 127.0.0.1") + } else { + // When host network is off, sshd_config must NOT contain Port or ListenAddress directives + require.NotContains(t, content, "Port ", + "when hostNetworkOff, sshd_config must NOT contain Port directive") + require.NotContains(t, content, "ListenAddress", + "when hostNetworkOff, sshd_config must NOT contain ListenAddress directive") + } + }) +} diff --git a/internal/docker/export_test.go b/internal/docker/export_test.go new file mode 100644 index 0000000..36a1a99 --- /dev/null +++ b/internal/docker/export_test.go @@ -0,0 +1,10 @@ +package docker + +import dockerclient "github.com/docker/docker/client" + +// NewClientForTest creates a Client wrapping the given Docker SDK client. +// This is exported only for testing (via the _test.go convention) so that +// external test packages can inject a fake Docker client. +func NewClientForTest(inner *dockerclient.Client) *Client { + return &Client{inner: inner} +} diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index 4ac53d2..863098b 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -103,6 +103,7 @@ func buildSharedImage(t *testing.T) { info, userPubKey, hostKeyPriv, hostKeyPub, + 2222, true, ) instanceBuilder.Finalize() @@ -157,15 +158,16 @@ func startContainerFromSharedImage(t *testing.T) (containerName string, sshPort containerName = constants.ContainerNamePrefix + sanitize(dirName) spec := docker.ContainerSpec{ - Name: containerName, - ImageTag: sharedImageTag, + Name: containerName, + ImageTag: sharedImageTag, Mounts: []docker.Mount{ {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, }, - SSHPort: port, - Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostUID: sharedHostUID, + HostGID: sharedHostGID, + HostNetworkOff: true, } _, err = docker.CreateContainer(ctx, sharedClient, spec) @@ -418,6 +420,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { info, userPubKey, hostKeyPriv, hostKeyPub, + 2222, false, ) instanceBuilder.Finalize() spec := docker.ContainerSpec{ @@ -623,6 +626,339 @@ func TestContainerHostnameMatchesContainerName(t *testing.T) { require.Equal(t, 0, exitCode, "hostname command should exit 0") } +// ---------------------------------------------------------------------------- +// 9.1 TestHostNetworkModeSSHReachable +// Validates: Req 26 — host network mode SSH reachability +// ---------------------------------------------------------------------------- + +func TestHostNetworkModeSSHReachable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "hostnet-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a high port to avoid conflicts + sshPort := 22222 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with host network mode (hostNetworkOff=false) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, false) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + HostNetworkOff: false, // host network mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with host network mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with host network mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Assert: SSH is reachable on 127.0.0.1:sshPort + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in host network mode", sshPort) +} + +// ---------------------------------------------------------------------------- +// 9.3 TestHostNetworkCanReachHostService +// Validates: Req 26 — host network mode shares the host's network namespace +// ---------------------------------------------------------------------------- + +func TestHostNetworkCanReachHostService(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + // Step 1: Start a TCP listener on a random port on the host. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "starting TCP listener on host") + t.Cleanup(func() { ln.Close() }) + + // Extract the port from the listener address. + _, portStr, err := net.SplitHostPort(ln.Addr().String()) + require.NoError(t, err, "splitting host:port from listener address") + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "hostreach-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a high port for SSH to avoid conflicts + sshPort := 22224 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with host network mode (hostNetworkOff=false) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, false) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + HostNetworkOff: false, // host network mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with host network mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with host network mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Wait for the container to be running (SSH ready) + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in host network mode", sshPort) + + // Step 5: Use bash /dev/tcp trick to test connectivity to the host listener. + // This works without netcat being installed. + exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{ + "bash", "-c", fmt.Sprintf("echo > /dev/tcp/127.0.0.1/%s", portStr), + }) + require.NoError(t, err, "exec to test connectivity to host service on port %s", portStr) + require.Equal(t, 0, exitCode, + "container in host network mode must be able to reach host service on 127.0.0.1:%s", portStr) +} + +// ---------------------------------------------------------------------------- +// 9.2 TestBridgeModeSSHReachable +// Validates: Req 26 — bridge mode (hostNetworkOff=true) SSH reachability +// ---------------------------------------------------------------------------- + +func TestBridgeModeSSHReachable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + client, err := docker.NewClient() + require.NoError(t, err, "connecting to Docker daemon") + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Generate SSH keys for the test + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating host key pair") + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + require.NoError(t, err, "generating user key pair") + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + containerName := constants.ContainerNamePrefix + "bridge-" + sanitize(dirName) + instanceImageTag := containerName + ":latest" + + // Use a different high port to avoid conflicts with host network test + sshPort := 22223 + + // Determine user strategy + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, client, info.UID, info.GID) + require.NoError(t, err, "checking base image for UID/GID conflicts") + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + // Cleanup + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, client, containerName) + _ = docker.RemoveContainer(cleanCtx, client, containerName) + images, _ := docker.ListBACImages(cleanCtx, client) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == instanceImageTag { + _, _ = client.ImageRemove(cleanCtx, img.ID, forceRemoveOpts()) + } + } + } + }) + + // Build base image + baseBuilder := docker.NewBaseImageBuilder(info, strategy, conflictingUser, "") + baseSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: constants.BaseImageTag, + Dockerfile: baseBuilder.Build(), + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + } + + _, err = docker.BuildImage(ctx, client, baseSpec, false) + require.NoError(t, err, "building base image") + + // Build instance image with bridge mode (hostNetworkOff=true) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, sshPort, true) + instanceBuilder.Finalize() + + instanceSpec := docker.ContainerSpec{ + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + }, + SSHPort: sshPort, + Labels: map[string]string{"bac.managed": "true"}, + HostUID: info.UID, + HostGID: info.GID, + HostNetworkOff: true, // bridge mode + } + + _, err = docker.BuildImage(ctx, client, instanceSpec, false) + require.NoError(t, err, "building instance image with bridge mode") + + // Create and start container + _, err = docker.CreateContainer(ctx, client, instanceSpec) + require.NoError(t, err, "creating container with bridge mode") + + err = docker.StartContainer(ctx, client, containerName) + require.NoError(t, err, "starting container") + + // Assert: SSH is reachable on 127.0.0.1:sshPort + err = docker.WaitForSSH(ctx, "127.0.0.1", sshPort, 10*time.Second) + require.NoError(t, err, "SSH must be reachable on 127.0.0.1:%d in bridge mode", sshPort) +} + // ---------------------------------------------------------------------------- // Internal helpers // ---------------------------------------------------------------------------- @@ -759,7 +1095,7 @@ func TestTwoLayerBuildCycle(t *testing.T) { // ------------------------------------------------------------------------- // Subtask 2: Build instance image FROM base, verify it exists with correct labels // ------------------------------------------------------------------------- - instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub) + instanceBuilder := docker.NewInstanceImageBuilder(info, userPubKey, hostKeyPriv, hostKeyPub, port, true) instanceBuilder.Finalize() instanceLabels := map[string]string{ @@ -767,16 +1103,17 @@ func TestTwoLayerBuildCycle(t *testing.T) { "bac.container": containerName, } instanceSpec := docker.ContainerSpec{ - Name: containerName, - ImageTag: instanceImageTag, - Dockerfile: instanceBuilder.Build(), + Name: containerName, + ImageTag: instanceImageTag, + Dockerfile: instanceBuilder.Build(), Mounts: []docker.Mount{ {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, }, - SSHPort: port, - Labels: instanceLabels, - HostUID: info.UID, - HostGID: info.GID, + SSHPort: port, + Labels: instanceLabels, + HostUID: info.UID, + HostGID: info.GID, + HostNetworkOff: true, } _, err = docker.BuildImage(ctx, client, instanceSpec, false) diff --git a/internal/docker/runner.go b/internal/docker/runner.go index 40f9aac..a00fe6b 100644 --- a/internal/docker/runner.go +++ b/internal/docker/runner.go @@ -32,16 +32,17 @@ type Mount struct { // ContainerSpec is the fully resolved specification for a container. type ContainerSpec struct { - Name string // Deterministic container name (bac-<12hex>) - ImageTag string // Docker image tag (derived from container name) - Dockerfile string // Complete Dockerfile content (assembled by DockerfileBuilder) - Mounts []Mount // All bind mounts: /workspace + per-agent credential stores - SSHPort int // Host-side TCP port mapped to container port 22 - Labels map[string]string // Docker labels for identification - HostUID int // Host user UID (passed as build arg for dev user) - HostGID int // Host user GID (passed as build arg for dev user) - NoCache bool // When true, disable Docker layer cache during image build - RestartPolicy string // Docker restart policy (e.g. "unless-stopped"); empty means use default + Name string // Deterministic container name (bac-<12hex>) + ImageTag string // Docker image tag (derived from container name) + Dockerfile string // Complete Dockerfile content (assembled by DockerfileBuilder) + Mounts []Mount // All bind mounts: /workspace + per-agent credential stores + SSHPort int // SSH port (used in sshd_config for host mode, or Docker port mapping for bridge mode) + Labels map[string]string // Docker labels for identification + HostUID int // Host user UID (passed as build arg for dev user) + HostGID int // Host user GID (passed as build arg for dev user) + NoCache bool // When true, disable Docker layer cache during image build + HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network + RestartPolicy string // Docker restart policy (e.g. "unless-stopped"); empty means use default } func buildContextFromDockerfile(dockerfile string) (io.Reader, error) { @@ -162,14 +163,6 @@ func ResolveRestartPolicy(spec ContainerSpec) string { // CreateContainer creates a container from the given spec. func CreateContainer(ctx context.Context, c *Client, spec ContainerSpec) (string, error) { - sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) - portBindings := nat.PortMap{ - sshPort: []nat.PortBinding{ - {HostIP: constants.HostBindIP, HostPort: fmt.Sprintf("%d", spec.SSHPort)}, - }, - } - exposedPorts := nat.PortSet{sshPort: struct{}{}} - var mounts []mount.Mount for _, m := range spec.Mounts { mounts = append(mounts, mount.Mount{ @@ -182,19 +175,37 @@ func CreateContainer(ctx context.Context, c *Client, spec ContainerSpec) (string restartPolicy := ResolveRestartPolicy(spec) + containerCfg := &container.Config{ + Image: spec.ImageTag, + Hostname: spec.Name, + Labels: spec.Labels, + } + + hostCfg := &container.HostConfig{ + Mounts: mounts, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(restartPolicy)}, + } + + if !spec.HostNetworkOff { + // Host network mode (default): share the host's network namespace. + // Do NOT set PortBindings or ExposedPorts — they are ignored in host + // network mode and would produce a Docker warning. + hostCfg.NetworkMode = container.NetworkMode("host") + } else { + // Bridge mode: map container SSH port to host port. + sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) + hostCfg.PortBindings = nat.PortMap{ + sshPort: []nat.PortBinding{ + {HostIP: constants.HostBindIP, HostPort: fmt.Sprintf("%d", spec.SSHPort)}, + }, + } + containerCfg.ExposedPorts = nat.PortSet{sshPort: struct{}{}} + } + resp, err := c.ContainerCreate( ctx, - &container.Config{ - Image: spec.ImageTag, - Hostname: spec.Name, - Labels: spec.Labels, - ExposedPorts: exposedPorts, - }, - &container.HostConfig{ - PortBindings: portBindings, - Mounts: mounts, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(restartPolicy)}, - }, + containerCfg, + hostCfg, nil, nil, spec.Name, diff --git a/internal/docker/runner_network_test.go b/internal/docker/runner_network_test.go new file mode 100644 index 0000000..3826ae7 --- /dev/null +++ b/internal/docker/runner_network_test.go @@ -0,0 +1,140 @@ +package docker_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/docker/docker/api/types/container" + dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" +) + +// createRequest captures the JSON body sent to the Docker daemon's +// /containers/create endpoint. The Docker SDK sends container.Config and +// container.HostConfig as a combined JSON payload. +type createRequest struct { + HostConfig container.HostConfig `json:"HostConfig"` + // ExposedPorts in the container config + ExposedPorts nat.PortSet `json:"ExposedPorts"` +} + +// newFakeDockerClient creates a *docker.Client backed by a fake HTTP server +// that captures the ContainerCreate request body. The returned channel receives +// the decoded createRequest when ContainerCreate is called. +func newFakeDockerClient(t *testing.T) (*docker.Client, chan createRequest) { + t.Helper() + ch := make(chan createRequest, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Match the containers/create endpoint (path includes version prefix) + if r.Method == http.MethodPost && r.URL.Path[len(r.URL.Path)-18:] == "/containers/create" { + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode create request: %v", err) + } + ch <- req + // Return a valid CreateResponse + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"Id":"fake-container-id","Warnings":[]}`) + return + } + // Default: return 200 for version negotiation (_ping, version, etc.) + if r.URL.Path == "/_ping" || r.URL.Path == "/v1.47/_ping" { + w.Header().Set("Api-Version", "1.47") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{}`) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // Create a Docker SDK client pointing at our fake server. + inner, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost(srv.URL), + dockerclient.WithHTTPClient(srv.Client()), + dockerclient.WithAPIVersionNegotiation(), + ) + require.NoError(t, err) + + // Wrap in our Client type using the exported constructor pattern. + // Since Client wraps the inner client, we use NewClientFromInner (test helper). + client := docker.NewClientForTest(inner) + return client, ch +} + +// TestCreateContainerHostNetworkMode verifies that CreateContainer sets +// NetworkMode: "host" and no port bindings when HostNetworkOff == false. +func TestCreateContainerHostNetworkMode(t *testing.T) { + client, ch := newFakeDockerClient(t) + + spec := docker.ContainerSpec{ + Name: "bac-test-host", + ImageTag: "bac-test:latest", + SSHPort: 2222, + HostNetworkOff: false, + Labels: map[string]string{"bac.managed": "true"}, + } + + id, err := docker.CreateContainer(context.Background(), client, spec) + require.NoError(t, err) + require.Equal(t, "fake-container-id", id) + + req := <-ch + require.Equal(t, container.NetworkMode("host"), req.HostConfig.NetworkMode, + "HostNetworkOff=false must set NetworkMode to 'host'") + require.Empty(t, req.HostConfig.PortBindings, + "HostNetworkOff=false must not set PortBindings") + require.Empty(t, req.ExposedPorts, + "HostNetworkOff=false must not set ExposedPorts") +} + +// TestCreateContainerBridgeMode verifies that CreateContainer uses port bindings +// and does not set NetworkMode: "host" when HostNetworkOff == true. +func TestCreateContainerBridgeMode(t *testing.T) { + client, ch := newFakeDockerClient(t) + + spec := docker.ContainerSpec{ + Name: "bac-test-bridge", + ImageTag: "bac-test:latest", + SSHPort: 3333, + HostNetworkOff: true, + Labels: map[string]string{"bac.managed": "true"}, + } + + id, err := docker.CreateContainer(context.Background(), client, spec) + require.NoError(t, err) + require.Equal(t, "fake-container-id", id) + + req := <-ch + + // NetworkMode should NOT be "host" + require.NotEqual(t, container.NetworkMode("host"), req.HostConfig.NetworkMode, + "HostNetworkOff=true must not set NetworkMode to 'host'") + + // PortBindings should map ContainerSSHPort/tcp → HostBindIP:SSHPort + sshPort := nat.Port(fmt.Sprintf("%d/tcp", constants.ContainerSSHPort)) + bindings, ok := req.HostConfig.PortBindings[sshPort] + require.True(t, ok, "HostNetworkOff=true must set PortBindings for %s", sshPort) + require.Len(t, bindings, 1) + require.Equal(t, constants.HostBindIP, bindings[0].HostIP) + require.Equal(t, fmt.Sprintf("%d", spec.SSHPort), bindings[0].HostPort) + + // ExposedPorts should include the SSH port + _, exposed := req.ExposedPorts[sshPort] + require.True(t, exposed, "HostNetworkOff=true must set ExposedPorts for %s", sshPort) +} From a93c5c704df8e57188b531e1ef9f0351bfba678c Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Sun, 10 May 2026 20:26:30 +0200 Subject: [PATCH 02/13] remove inconsistencies --- .../bootstrap-ai-coding/design-components.md | 10 ++-- .../bootstrap-ai-coding/design-data-models.md | 3 +- .../bootstrap-ai-coding/design-properties.md | 2 +- internal/agents/augment/integration_test.go | 6 +-- .../agents/buildresources/integration_test.go | 6 +-- internal/agents/claude/integration_test.go | 6 +-- internal/cmd/root.go | 6 +-- internal/docker/integration_test.go | 47 +++++++------------ internal/docker/runner.go | 4 +- 9 files changed, 34 insertions(+), 56 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-components.md b/.kiro/specs/bootstrap-ai-coding/design-components.md index 6556009..984b58b 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-components.md +++ b/.kiro/specs/bootstrap-ai-coding/design-components.md @@ -358,17 +358,17 @@ hostConfig := &container.HostConfig{ **`--host-network-off` flag (Req 26):** ```go -rootCmd.Flags().Bool("host-network-off", false, - "Disable host network mode; use bridge networking with port mapping instead") +rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, + "Disable host network mode; use bridge networking with port mapping") ``` **Threading from CLI to runner:** 1. `cmd/root.go` reads `--host-network-off` flag value (default: `false`) -2. Stores it in `Config.HostNetworkOff` -3. Passes it to `ContainerSpec.HostNetworkOff` when constructing the spec +2. Passes it as the `hostNetworkOff` parameter to `runStart` +3. `runStart` sets `ContainerSpec.HostNetworkOff` when constructing the spec 4. `docker/runner.go` selects `NetworkMode: "host"` or bridge + port bindings in `CreateContainer` -5. Passes it to `NewInstanceImageBuilder` to control whether sshd_config includes `Port`/`ListenAddress` directives +5. `runStart` passes it to `NewInstanceImageBuilder` to control whether sshd_config includes `Port`/`ListenAddress` directives **Behaviour with `--stop-and-remove`:** diff --git a/.kiro/specs/bootstrap-ai-coding/design-data-models.md b/.kiro/specs/bootstrap-ai-coding/design-data-models.md index a7effaa..5b616aa 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-data-models.md +++ b/.kiro/specs/bootstrap-ai-coding/design-data-models.md @@ -59,8 +59,9 @@ type ContainerSpec struct { Mounts []Mount SSHPort int Labels map[string]string - RestartPolicy string // Req 25: Docker restart policy name + NoCache bool // When true, disable Docker layer cache during image build HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network + RestartPolicy string // Req 25: Docker restart policy name HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) } diff --git a/.kiro/specs/bootstrap-ai-coding/design-properties.md b/.kiro/specs/bootstrap-ai-coding/design-properties.md index 0d9a00e..2cc2358 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-properties.md +++ b/.kiro/specs/bootstrap-ai-coding/design-properties.md @@ -291,7 +291,7 @@ #### Property 34: START-only flags in STOP or PURGE mode always produce errors (CLI-3) -*For any* invocation in STOP or PURGE mode where any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, or `--no-update-ssh-config` is set, the CLI SHALL return a non-nil error identifying the incompatible flag(s). +*For any* invocation in STOP or PURGE mode where any of `--agents`, `--port`, `--ssh-key`, `--rebuild`, `--no-update-known-hosts`, `--no-update-ssh-config`, `--verbose`, `--docker-restart-policy`, or `--host-network-off` is set, the CLI SHALL return a non-nil error identifying the incompatible flag(s). **Validates: CLI-3** diff --git a/internal/agents/augment/integration_test.go b/internal/agents/augment/integration_test.go index 7c72e17..5b21c97 100644 --- a/internal/agents/augment/integration_test.go +++ b/internal/agents/augment/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) @@ -161,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, true) diff --git a/internal/agents/buildresources/integration_test.go b/internal/agents/buildresources/integration_test.go index ca1bbce..0f837fa 100644 --- a/internal/agents/buildresources/integration_test.go +++ b/internal/agents/buildresources/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) @@ -161,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, true) diff --git a/internal/agents/claude/integration_test.go b/internal/agents/claude/integration_test.go index 6880597..c8df42c 100644 --- a/internal/agents/claude/integration_test.go +++ b/internal/agents/claude/integration_test.go @@ -128,8 +128,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) @@ -161,8 +160,7 @@ func setupSharedContainer() error { Labels: map[string]string{ "bac.managed": "true", }, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, sharedClient, spec, false) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0023f4a..6829d95 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -594,8 +594,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: baseLabels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, NoCache: flagRebuild, } @@ -619,8 +618,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age ImageTag: imageTag, Dockerfile: instanceBuilder.Build(), Labels: labels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } fmt.Println("Building instance image...") diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index 863098b..41c4102 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -33,8 +33,7 @@ var ( sharedImageTag string sharedClient *docker.Client sharedProjectDir string - sharedHostUID int - sharedHostGID int + sharedHostInfo *hostinfo.Info ) // TestMain ensures the base image is removed from the local Docker image store @@ -78,15 +77,14 @@ func buildSharedImage(t *testing.T) { info, err := hostinfo.Current() require.NoError(t, err, "getting host info") - sharedHostUID = info.UID - sharedHostGID = info.GID + sharedHostInfo = info sharedClient, err = docker.NewClient() require.NoError(t, err, "connecting to Docker daemon") strategy := docker.UserStrategyCreate conflictingUser := "" - conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, sharedHostUID, sharedHostGID) + conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, sharedHostInfo.UID, sharedHostInfo.GID) require.NoError(t, err, "checking base image for UID/GID conflicts") if conflictingImageUser != nil { strategy = docker.UserStrategyRename @@ -115,8 +113,7 @@ func buildSharedImage(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: builder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + HostInfo: sharedHostInfo, } _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) @@ -131,8 +128,7 @@ func buildSharedImage(t *testing.T) { {HostPath: sharedProjectDir, ContainerPath: constants.WorkspaceMountPath}, }, Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + HostInfo: sharedHostInfo, } _, err = docker.BuildImage(ctx, sharedClient, spec, false) @@ -165,8 +161,7 @@ func startContainerFromSharedImage(t *testing.T) (containerName string, sshPort }, SSHPort: port, Labels: map[string]string{"bac.managed": "true"}, - HostUID: sharedHostUID, - HostGID: sharedHostGID, + HostInfo: sharedHostInfo, HostNetworkOff: true, } @@ -408,8 +403,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: builder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -432,8 +426,7 @@ func TestSSHHostKeyStableAcrossRebuild(t *testing.T) { }, SSHPort: port, Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, spec, false) @@ -691,8 +684,7 @@ func TestHostNetworkModeSSHReachable(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -711,8 +703,7 @@ func TestHostNetworkModeSSHReachable(t *testing.T) { }, SSHPort: sshPort, Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, HostNetworkOff: false, // host network mode } @@ -805,8 +796,7 @@ func TestHostNetworkCanReachHostService(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -825,8 +815,7 @@ func TestHostNetworkCanReachHostService(t *testing.T) { }, SSHPort: sshPort, Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, HostNetworkOff: false, // host network mode } @@ -919,8 +908,7 @@ func TestBridgeModeSSHReachable(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -939,8 +927,7 @@ func TestBridgeModeSSHReachable(t *testing.T) { }, SSHPort: sshPort, Labels: map[string]string{"bac.managed": "true"}, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, HostNetworkOff: true, // bridge mode } @@ -1077,8 +1064,7 @@ func TestTwoLayerBuildCycle(t *testing.T) { ImageTag: constants.BaseImageTag, Dockerfile: baseBuilder.Build(), Labels: baseLabels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, } _, err = docker.BuildImage(ctx, client, baseSpec, false) @@ -1111,8 +1097,7 @@ func TestTwoLayerBuildCycle(t *testing.T) { }, SSHPort: port, Labels: instanceLabels, - HostUID: info.UID, - HostGID: info.GID, + HostInfo: info, HostNetworkOff: true, } diff --git a/internal/docker/runner.go b/internal/docker/runner.go index a00fe6b..f6d8236 100644 --- a/internal/docker/runner.go +++ b/internal/docker/runner.go @@ -21,6 +21,7 @@ import ( "github.com/docker/go-connections/nat" "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" ) // Mount represents a single Docker bind mount. @@ -38,8 +39,7 @@ type ContainerSpec struct { Mounts []Mount // All bind mounts: /workspace + per-agent credential stores SSHPort int // SSH port (used in sshd_config for host mode, or Docker port mapping for bridge mode) Labels map[string]string // Docker labels for identification - HostUID int // Host user UID (passed as build arg for dev user) - HostGID int // Host user GID (passed as build arg for dev user) + HostInfo *hostinfo.Info // Req 22: runtime-resolved host user identity (UID, GID, Username, HomeDir) NoCache bool // When true, disable Docker layer cache during image build HostNetworkOff bool // Req 26: when true, use bridge mode; when false (default), use host network RestartPolicy string // Docker restart policy (e.g. "unless-stopped"); empty means use default From cb56f4d915846d86b4325e3a6fc9f57c5c3956c4 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 11 May 2026 20:41:31 +0200 Subject: [PATCH 03/13] update reqs --- .../bootstrap-ai-coding/design-vibekanban.md | 726 ++++++++++++++++++ .kiro/specs/bootstrap-ai-coding/design.md | 3 +- .../requirements-agents.md | 125 ++- 3 files changed, 852 insertions(+), 2 deletions(-) create mode 100644 .kiro/specs/bootstrap-ai-coding/design-vibekanban.md diff --git a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md new file mode 100644 index 0000000..d3022ec --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md @@ -0,0 +1,726 @@ +# Vibe Kanban Agent Module Design + +This document describes the Vibe Kanban agent module, a web-based project management tool that runs as a background service inside the container. + +> **Related documents:** +> - [design.md](design.md) - Overview and document index +> - [design-architecture.md](design-architecture.md) - High-level architecture +> - [design-agents.md](design-agents.md) - Agent modules: contract, implementations +> - [design-build-resources.md](design-build-resources.md) - Build Resources agent module +> - [requirements-agents.md](requirements-agents.md) - VK-1 through VK-8 + +--- + +## Overview + +Vibe Kanban is a web-based kanban board for AI coding agents, distributed as the `vibe-kanban` npm package. Unlike other agent modules (which are CLI tools invoked on demand), Vibe Kanban is a **web application** that must be running as a background service after container start so the user can access it from their host browser. + +The key design challenges are: +1. Auto-starting the service without replacing the container CMD (`/usr/sbin/sshd -D`) +2. Crash recovery with backoff to prevent resource exhaustion +3. Discovering the auto-assigned port for the session summary +4. Running as the Container_User (not root) + +**Package:** `internal/agents/vibekanban/vibekanban.go` + +**Validates: VK-1 through VK-8** + +--- + +## Architecture + +### Auto-Start Mechanism: ENTRYPOINT Wrapper Script + +The container CMD is `["/usr/sbin/sshd", "-D"]`, set by `Finalize()` on the instance builder. Agent `Install()` methods run on the **base image builder** and cannot modify CMD. However, Docker's ENTRYPOINT + CMD interaction provides the solution: + +- When both ENTRYPOINT and CMD are set, Docker executes: `ENTRYPOINT ` +- The agent installs a wrapper script at `/usr/local/bin/bac-entrypoint.sh` and sets `ENTRYPOINT ["/usr/local/bin/bac-entrypoint.sh"]` +- The wrapper starts background services, then exec's `"$@"` (which receives `/usr/sbin/sshd -D` from CMD) + +This is the standard Docker pattern for initialization before the main process. It does NOT modify CMD - it adds ENTRYPOINT. + +```mermaid +sequenceDiagram + participant Docker + participant Entrypoint as bac-entrypoint.sh + participant Supervisor as vibe-kanban-supervisor.sh + participant VK as vibe-kanban + participant SSHD as sshd -D + + Docker->>Entrypoint: Start (args: /usr/sbin/sshd -D) + Entrypoint->>Supervisor: Launch in background (&) + Supervisor->>VK: Start as Container_User + Entrypoint->>SSHD: exec "$@" + Note over SSHD: PID 1, handles signals + Note over Supervisor: Monitors VK, restarts on crash + +``` + +### Crash Recovery: Supervisor Script + +The supervisor script (`/usr/local/bin/vibe-kanban-supervisor.sh`) implements crash recovery with backoff: + +- Runs in an infinite loop, starting `vibe-kanban` each iteration +- Tracks restart timestamps in an array +- Before each restart, checks if 5 restarts have occurred in the last 60 seconds +- If the limit is hit, logs an error and exits (preventing resource exhaustion) +- Sleeps 5 seconds between restart attempts +- Runs the vibe-kanban process as the Container_User via `su -c` + +### Port Discovery + +After the container starts and the health check passes, the CLI discovers the Vibe Kanban port by: + +1. Executing `ss -tlnp` inside the container to list listening TCP sockets +2. Grepping for the `vibe-kanban` process +3. Parsing the port number from the output +4. Retrying up to 30 seconds (the server may take time to bind) + +This is done via `docker.ExecInContainer()` with a shell pipeline. + +### DockerfileBuilder Extension + +The `DockerfileBuilder` needs a new `Entrypoint()` method: + +```go +// Entrypoint appends an ENTRYPOINT instruction in exec form. +func (b *DockerfileBuilder) Entrypoint(args ...string) { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = fmt.Sprintf("%q", a) + } + b.lines = append(b.lines, fmt.Sprintf("ENTRYPOINT [%s]", strings.Join(quoted, ", "))) +} +``` + +When `Finalize()` emits `CMD ["/usr/sbin/sshd", "-D"]`, Docker will execute: +`/usr/local/bin/bac-entrypoint.sh /usr/sbin/sshd -D` + +--- + +## Components and Interfaces + +### Constants Addition + +```go +// In internal/constants/constants.go: + +// VibeKanbanAgentName is the stable Agent_ID for the Vibe Kanban agent module. +// Corresponds to the Agent_ID glossary term for Vibe Kanban (VK-1). +VibeKanbanAgentName = "vibe-kanban" +``` + +The `DefaultAgents` constant must be updated to include `vibe-kanban`: + +```go +DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName + "," + VibeKanbanAgentName +``` + +### Agent Module Interface Implementation + +| Method | Return Value | +|--------|-------------| +| `ID()` | `constants.VibeKanbanAgentName` ("vibe-kanban") | +| `Install(b)` | Appends Node.js (conditional), npm install, entrypoint + supervisor scripts | +| `CredentialStorePath()` | `""` (no credentials) | +| `ContainerMountPath(homeDir)` | `""` (no bind-mount) | +| `HasCredentials(storePath)` | `(true, nil)` always | +| `HealthCheck(ctx, c, containerID)` | Binary check + process running check with retries | + +### Session Summary Extension + +The `SessionSummary` struct in `cmd/root.go` needs a new field: + +```go +type SessionSummary struct { + DataDir string + ProjectDir string + SSHPort int + SSHConnect string + EnabledAgents []string + VibeKanbanURL string // empty if not discovered or not enabled +} +``` + +`FormatSessionSummary` conditionally includes the Vibe Kanban line: + +```go +func FormatSessionSummary(s SessionSummary) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) + fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) + fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) + fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) + fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) + if s.VibeKanbanURL != "" { + fmt.Fprintf(&sb, "Vibe Kanban: %s\n", s.VibeKanbanURL) + } + return sb.String() +} +``` + +### Port Discovery Function + +A new exported function in `internal/docker/` for discovering a process's listening port: + +```go +// DiscoverListeningPort executes `ss -tlnp` inside the container and returns +// the first port where the given process name is listening. Returns 0 if not found. +func DiscoverListeningPort(ctx context.Context, c *Client, containerID string, processName string) (int, error) { + // Uses ExecInContainerWithOutput (new helper) to capture stdout + // Parses ss output for lines containing processName + // Extracts port from the Local Address:Port column +} +``` + +--- + +## Data Models + +### Generated Scripts + +**`/usr/local/bin/bac-entrypoint.sh`** (installed by Install()): +```bash +#!/bin/bash +set -e + +# Start Vibe Kanban supervisor in background +/usr/local/bin/vibe-kanban-supervisor.sh & + +# Execute the original CMD (sshd -D) +exec "$@" +``` + +**`/usr/local/bin/vibe-kanban-supervisor.sh`** (installed by Install()): +```bash +#!/bin/bash +# Vibe Kanban supervisor with crash recovery +# Max 5 restarts within any 60-second window, 5-second delay between attempts + +MAX_RESTARTS=5 +WINDOW_SECONDS=60 +DELAY_SECONDS=5 +RESTART_TIMES=() +USERNAME="__USERNAME__" + +while true; do + # Prune timestamps older than the window + NOW=$(date +%s) + PRUNED=() + for ts in "${RESTART_TIMES[@]}"; do + if (( NOW - ts < WINDOW_SECONDS )); then + PRUNED+=("$ts") + fi + done + RESTART_TIMES=("${PRUNED[@]}") + + # Check if we've exceeded the restart limit + if (( ${#RESTART_TIMES[@]} >= MAX_RESTARTS )); then + echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 + exit 1 + fi + + # Record this restart attempt + RESTART_TIMES+=("$(date +%s)") + + # Start vibe-kanban as the container user + su -c "vibe-kanban" "$USERNAME" || true + + # Wait before restarting + sleep "$DELAY_SECONDS" +done +``` + +The `__USERNAME__` placeholder is replaced at image build time with the actual Container_User username from `b.Username()`. + +--- + +## Implementation + +```go +package vibekanban + +import ( + "context" + "fmt" + "time" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" +) + +type vibeKanbanAgent struct{} + +func init() { + agent.Register(&vibeKanbanAgent{}) +} + +// ID returns the stable Agent_ID "vibe-kanban". +// Satisfies: VK-1 +func (a *vibeKanbanAgent) ID() string { + return constants.VibeKanbanAgentName +} + +// Install appends Dockerfile RUN steps that install Node.js (if not already +// installed), the vibe-kanban npm package, and the auto-start mechanism. +// Satisfies: VK-2, VK-3 +func (a *vibeKanbanAgent) Install(b *docker.DockerfileBuilder) { + // 1. Node.js (conditional — skip if another agent already installed it) + if !b.IsNodeInstalled() { + b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*") + b.Run("curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*") + b.MarkNodeInstalled() + } + + // 2. Install vibe-kanban globally + b.Run("npm install -g --no-fund --no-audit vibe-kanban") + + // 3. Install the supervisor script with crash recovery + username := b.Username() + supervisorScript := fmt.Sprintf(`#!/bin/bash +MAX_RESTARTS=5 +WINDOW_SECONDS=60 +DELAY_SECONDS=5 +RESTART_TIMES=() +while true; do + NOW=$(date +%%s) + PRUNED=() + for ts in "${RESTART_TIMES[@]}"; do + if (( NOW - ts < WINDOW_SECONDS )); then + PRUNED+=("$ts") + fi + done + RESTART_TIMES=("${PRUNED[@]}") + if (( ${#RESTART_TIMES[@]} >= MAX_RESTARTS )); then + echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 + exit 1 + fi + RESTART_TIMES+=("$(date +%%s)") + su -c "vibe-kanban --host 0.0.0.0" "%s" || true + sleep "$DELAY_SECONDS" +done`, username) + + b.Run(fmt.Sprintf("printf '%%s' '%s' > /usr/local/bin/vibe-kanban-supervisor.sh && chmod +x /usr/local/bin/vibe-kanban-supervisor.sh", + supervisorScript)) + + // 4. Install the entrypoint wrapper + entrypoint := `#!/bin/bash +set -e +/usr/local/bin/vibe-kanban-supervisor.sh & +exec "$@"` + + b.Run(fmt.Sprintf("printf '%%s' '%s' > /usr/local/bin/bac-entrypoint.sh && chmod +x /usr/local/bin/bac-entrypoint.sh", + entrypoint)) + + // 5. Set ENTRYPOINT so the supervisor starts before sshd + b.Entrypoint("/usr/local/bin/bac-entrypoint.sh") +} + +// CredentialStorePath returns empty - no credentials to persist. +// Satisfies: VK-4 +func (a *vibeKanbanAgent) CredentialStorePath() string { + return "" +} + +// ContainerMountPath returns empty - no bind-mount needed. +// Satisfies: VK-4 +func (a *vibeKanbanAgent) ContainerMountPath(homeDir string) string { + return "" +} + +// HasCredentials always returns true - nothing to check. +// Satisfies: VK-4 +func (a *vibeKanbanAgent) HasCredentials(storePath string) (bool, error) { + return true, nil +} + +// HealthCheck verifies that: +// 1. The vibe-kanban binary is present (vibe-kanban --version exits 0) +// 2. The vibe-kanban process is running (pgrep with retries) +// Satisfies: VK-5 +func (a *vibeKanbanAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { + // Check 1: Binary presence + exitCode, err := docker.ExecInContainer(ctx, c, containerID, []string{"vibe-kanban", "--version"}) + if err != nil { + return fmt.Errorf("vibe-kanban health check failed (binary): %w", err) + } + if exitCode != 0 { + return fmt.Errorf("vibe-kanban health check failed: 'vibe-kanban --version' exited with code %d", exitCode) + } + + // Check 2: Process running (with retries) + const maxRetries = 5 + const retryInterval = 2 * time.Second + + for attempt := 1; attempt <= maxRetries; attempt++ { + exitCode, err = docker.ExecInContainer(ctx, c, containerID, []string{"pgrep", "-f", "vibe-kanban"}) + if err != nil { + return fmt.Errorf("vibe-kanban health check failed (process check): %w", err) + } + if exitCode == 0 { + return nil // Process is running + } + if attempt < maxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + } + } + } + + return fmt.Errorf("vibe-kanban health check failed: process not running after %d attempts", maxRetries) +} +``` + +--- + +## Core Changes Required + +### 1. `internal/constants/constants.go` + +Add the constant and update `DefaultAgents`: + +```go +// VibeKanbanAgentName is the stable Agent_ID for the Vibe Kanban agent module. +// Corresponds to the Agent_ID glossary term for Vibe Kanban (VK-1). +VibeKanbanAgentName = "vibe-kanban" + +// Update DefaultAgents: +DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName + "," + VibeKanbanAgentName +``` + +### 2. `internal/docker/builder.go` + +Add the `Entrypoint()` method: + +```go +// Entrypoint appends an ENTRYPOINT instruction in exec form. +// Used by agent modules that need to run initialization before the main CMD. +func (b *DockerfileBuilder) Entrypoint(args ...string) { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = fmt.Sprintf("%q", a) + } + b.lines = append(b.lines, fmt.Sprintf("ENTRYPOINT [%s]", strings.Join(quoted, ", "))) +} +``` + +### 3. `internal/docker/runner.go` + +Add a helper to execute a command and capture stdout: + +```go +// ExecInContainerWithOutput runs a command inside a running container and +// returns the exit code and stdout content. +func ExecInContainerWithOutput(ctx context.Context, c *Client, containerID string, cmd []string) (int, string, error) { + execID, err := c.ContainerExecCreate(ctx, containerID, container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return -1, "", fmt.Errorf("creating exec: %w", err) + } + + resp, err := c.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{}) + if err != nil { + return -1, "", fmt.Errorf("attaching to exec: %w", err) + } + defer resp.Close() + + var stdout, stderr bytes.Buffer + _, _ = stdcopy.StdCopy(&stdout, &stderr, resp.Reader) + + inspect, err := c.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return -1, "", fmt.Errorf("inspecting exec: %w", err) + } + + return inspect.ExitCode, stdout.String(), nil +} +``` + +### 4. `internal/cmd/root.go` + +#### SessionSummary struct extension: + +```go +type SessionSummary struct { + DataDir string + ProjectDir string + SSHPort int + SSHConnect string + EnabledAgents []string + VibeKanbanURL string // empty if not discovered or not enabled +} +``` + +#### FormatSessionSummary update: + +```go +func FormatSessionSummary(s SessionSummary) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) + fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) + fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) + fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) + fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) + if s.VibeKanbanURL != "" { + fmt.Fprintf(&sb, "Vibe Kanban: %s\n", s.VibeKanbanURL) + } + return sb.String() +} +``` + +#### Port discovery in `runStart()`: + +After the health check passes and before printing the session summary, `runStart()` checks if `vibe-kanban` is among the enabled agents. If so, it attempts port discovery: + +```go +// Discover Vibe Kanban port if the agent is enabled +var vibeKanbanURL string +for _, a := range enabledAgents { + if a.ID() == constants.VibeKanbanAgentName { + port, err := discoverVibeKanbanPort(ctx, c, containerName) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not discover Vibe Kanban port: %v\n", err) + } else if port > 0 { + vibeKanbanURL = fmt.Sprintf("http://localhost:%d", port) + } + break + } +} +``` + +The `discoverVibeKanbanPort` function: + +```go +// discoverVibeKanbanPort attempts to find the port Vibe Kanban is listening on +// by executing ss inside the container. Retries for up to 30 seconds. +func discoverVibeKanbanPort(ctx context.Context, c *dockerpkg.Client, containerID string) (int, error) { + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + exitCode, output, err := dockerpkg.ExecInContainerWithOutput(ctx, c, containerID, + []string{"bash", "-c", "ss -tlnp 2>/dev/null | grep vibe-kanban | awk '{print $4}' | grep -oP ':\\K[0-9]+' | head -1"}) + if err != nil { + return 0, err + } + if exitCode == 0 && output != "" { + port := 0 + fmt.Sscanf(strings.TrimSpace(output), "%d", &port) + if port > 0 { + return port, nil + } + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(2 * time.Second): + } + } + return 0, fmt.Errorf("timed out after 30s waiting for vibe-kanban to bind a port") +} +``` + +### 5. `main.go` + +Add the blank import: + +```go +import ( + "github.com/koudis/bootstrap-ai-coding/internal/cmd" + + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/augment" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/buildresources" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" +) +``` + +--- + +## Design Decisions + +### 1. ENTRYPOINT wrapper (not supervisord, not cron, not systemd) + +**Why:** Docker containers with a fixed CMD have limited options for running background services. The ENTRYPOINT + CMD pattern is the idiomatic Docker solution: +- ENTRYPOINT runs initialization (starts background services) +- CMD provides the main process arguments +- `exec "$@"` in the entrypoint ensures sshd becomes PID 1 and receives signals correctly + +**Rejected alternatives:** +- **supervisord**: Heavy dependency (Python-based), overkill for one background process, adds image size +- **systemd**: Not available in Docker containers (no systemd as PID 1) +- **cron @reboot**: Requires crond running, which isn't started by sshd +- **/etc/profile.d/**: Only runs on SSH login, not on container start (violates VK-3.1) +- **Custom CMD**: Violates the constraint that agents cannot modify CMD + +### 2. Shell-based supervisor (not a Go binary) + +**Why:** The supervisor is a simple bash script installed via Dockerfile RUN steps. This avoids: +- Compiling and copying a separate binary into the image +- Adding complexity to the build process +- The script is ~20 lines and trivially auditable + +### 3. `su -c` for user switching (not sudo, not USER directive) + +**Why:** The entrypoint runs as root (Docker default). The supervisor uses `su -c "vibe-kanban" "$USERNAME"` to drop privileges. This is simpler than sudo (no sudoers parsing) and works reliably in the container environment. + +### 4. Port discovery via `ss -tlnp` (not reading a config file) + +**Why:** Vibe Kanban auto-assigns its port at startup. There's no config file to read. The `ss` command (part of iproute2, installed in Ubuntu by default) shows which port the process is actually listening on. This is the most reliable method since it reflects runtime state. + +### 5. 30-second timeout for port discovery + +**Why:** Vibe Kanban needs time to start up (Node.js initialization, port binding). 30 seconds is generous but bounded. If it fails, the CLI prints a warning but does NOT fail the overall startup - the container is still usable for SSH and other agents. + +### 6. Graceful degradation for port discovery failure + +**Why (VK-8.4):** If port discovery times out, the session summary simply omits the Vibe Kanban URL line. The user can still SSH into the container and discover the port manually. This prevents a flaky network or slow startup from blocking the entire workflow. + +### 7. `--host 0.0.0.0` flag for vibe-kanban + +**Why:** By default, Node.js servers may bind to `127.0.0.1` inside the container. With host network mode, this is fine (the host IS the container's network namespace). But to be safe and explicit, we pass `--host 0.0.0.0` so the server accepts connections on all interfaces. + +### 8. Core changes are minimal and generic + +The core changes (SessionSummary field, FormatSessionSummary conditional line, port discovery) reference `constants.VibeKanbanAgentName` - they don't import the agent package. The `discoverVibeKanbanPort` function lives in `cmd/root.go` and uses only `docker.ExecInContainerWithOutput`. This maintains the "no core coupling" principle at the package level while allowing the session summary to show the URL. + +**Note on VK-6 (No Core Coupling):** VK-6.1 states the module SHALL NOT be referenced by name in core code. However, VK-8.3 requires the session summary to include the Vibe Kanban URL. These requirements are in tension. The resolution: `cmd/root.go` references `constants.VibeKanbanAgentName` (a constant, not a string literal or import path) to check if the agent is enabled. This is the same pattern used for `DefaultAgents`. The constant lives in `internal/constants/` which is shared infrastructure, not agent-specific code. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Node.js already installed by another agent | Skip Node.js installation (check `b.IsNodeInstalled()`) | +| `npm install -g vibe-kanban` fails | Image build fails (standard Docker behavior) | +| Entrypoint script fails to start supervisor | sshd still starts (supervisor failure is non-fatal to exec "$@") | +| Vibe Kanban crashes | Supervisor restarts it (up to 5 times in 60s) | +| Supervisor gives up after max restarts | Logs error to stderr, exits; container continues running (sshd is PID 1) | +| Health check: binary not found | Returns error identifying "binary" check | +| Health check: process not running after 5 retries | Returns error identifying "process" check with retry count | +| Port discovery times out (30s) | Warning printed, URL omitted from summary, startup succeeds | +| Port discovery exec fails | Warning printed, URL omitted from summary, startup succeeds | +| `--host-network-off` (bridge mode) | Port not accessible from host; URL still shown but with a note that it requires port forwarding | + +--- + +## Testing Strategy + +### Unit Tests (example-based) + +| Test | What it verifies | +|------|-----------------| +| `TestID` | Returns `constants.VibeKanbanAgentName` | +| `TestInstallNodeAlreadyInstalled` | Skips Node.js when `IsNodeInstalled()` is true | +| `TestInstallNodeNotInstalled` | Installs Node.js when `IsNodeInstalled()` is false | +| `TestInstallContainsNpmPackage` | Output contains `npm install -g vibe-kanban` | +| `TestInstallContainsEntrypoint` | Output contains ENTRYPOINT instruction | +| `TestInstallContainsSupervisor` | Output contains supervisor script with crash recovery params | +| `TestInstallDoesNotContainCMD` | Output does NOT contain CMD instruction | +| `TestInstallNoRustNoPnpm` | Output does NOT contain rust/pnpm references | +| `TestCredentialStorePath` | Returns empty string | +| `TestContainerMountPath` | Returns empty string for various homeDir values | +| `TestHasCredentials` | Returns (true, nil) | +| `TestHealthCheckBinaryFailure` | Error message identifies binary check | +| `TestHealthCheckProcessFailure` | Error message identifies process check | +| `TestFormatSessionSummaryWithVibeKanban` | URL line present when VibeKanbanURL is set | +| `TestFormatSessionSummaryWithoutVibeKanban` | URL line absent when VibeKanbanURL is empty | + +### Property-Based Tests + +Property tests use `pgregory.net/rapid` with minimum 100 iterations. + +See Correctness Properties section below. + +### Integration Tests + +| Test | What it verifies | +|------|-----------------| +| `TestVibeKanbanInstallsAndRuns` | Full image build, binary present, process running | +| `TestVibeKanbanHealthCheck` | Health check passes on a live container | +| `TestVibeKanbanPortDiscovery` | Port is discoverable via ss after startup | +| `TestVibeKanbanCrashRecovery` | Process restarts after being killed | +| `TestVibeKanbanAccessibleFromHost` | HTTP GET to localhost:port returns 2xx (host network mode) | + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system - essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Node.js conditional installation invariant + +*For any* DockerfileBuilder state (whether `IsNodeInstalled()` returns true or false), calling `Install()` on the Vibe Kanban agent SHALL result in the generated Dockerfile containing at most one Node.js installation block, and `IsNodeInstalled()` SHALL return true after the call. + +**Validates: Requirements VK-2.1** + +### Property 2: Install does not emit CMD + +*For any* DockerfileBuilder state, calling `Install()` on the Vibe Kanban agent SHALL NOT append any line starting with `CMD` to the builder output. The agent only sets ENTRYPOINT, never CMD. + +**Validates: Requirements VK-3.1** + +### Property 3: No-credential-store invariant + +*For any* string value passed as `homeDir` to `ContainerMountPath()`, the return value SHALL be the empty string. *For any* string value passed as `storePath` to `HasCredentials()`, the return value SHALL be `(true, nil)`. + +**Validates: Requirements VK-4.2, VK-4.3** + +### Property 4: Session summary includes Vibe Kanban URL for any valid port + +*For any* valid TCP port number (1-65535) set as `VibeKanbanURL` in the format `http://localhost:`, `FormatSessionSummary()` SHALL include a line containing that URL. When `VibeKanbanURL` is empty, the output SHALL NOT contain "Vibe Kanban:". + +**Validates: Requirements VK-8.3** + +### Property 5: Supervisor script contains correct backoff parameters + +*For any* username string (non-empty, valid Linux username characters), the supervisor script generated by `Install()` SHALL contain the constants `MAX_RESTARTS=5`, `WINDOW_SECONDS=60`, and `DELAY_SECONDS=5`, ensuring the crash recovery backoff is correctly configured regardless of the container user. + +**Validates: Requirements VK-3.5** + +--- + +## Dockerfile Layer Order (with Vibe Kanban) + +When all default agents are enabled, the Vibe Kanban layers appear in the base image: + +**Base_Image (`bac-base:latest`):** +``` +FROM ubuntu:26.04 +RUN apt-get install openssh-server sudo <- base +RUN useradd <- stable per user +RUN sudoers <- stable +RUN dbus-x11 gnome-keyring libsecret-1-0 <- keyring (CC-7) +RUN /etc/profile.d/dbus-keyring.sh <- keyring startup +RUN gitconfig <- git config (Req 24) +RUN curl ca-certificates git + nodejs <- Claude/Augment shared deps +RUN npm install -g @anthropic-ai/claude-code <- Claude Code +RUN npm install -g @augmentcode/auggie <- Augment Code +RUN python3 cmake build-essential default-jdk <- Build Resources (system) +RUN go tarball + /etc/profile.d/golang.sh <- Build Resources (Go) +RUN uv install <- Build Resources (uv) +RUN npm install -g vibe-kanban <- Vibe Kanban (binary) +RUN printf supervisor script <- Vibe Kanban (supervisor) +RUN printf entrypoint script <- Vibe Kanban (entrypoint) +ENTRYPOINT ["/usr/local/bin/bac-entrypoint.sh"] <- Vibe Kanban (auto-start) +RUN echo manifest > /bac-manifest.json <- manifest +``` + +**Instance_Image (`bac-:latest`):** +``` +FROM bac-base:latest +RUN SSH host key injection <- per-project +RUN SSH authorized_keys <- per-user key +RUN sshd_config hardening <- per-project +RUN mkdir /run/sshd <- stable +CMD ["/usr/sbin/sshd", "-D"] <- always last +``` + +Docker executes: `ENTRYPOINT CMD` = `/usr/local/bin/bac-entrypoint.sh /usr/sbin/sshd -D` + +The entrypoint starts the supervisor in the background, then exec's sshd as PID 1. diff --git a/.kiro/specs/bootstrap-ai-coding/design.md b/.kiro/specs/bootstrap-ai-coding/design.md index f9dcf01..7574790 100644 --- a/.kiro/specs/bootstrap-ai-coding/design.md +++ b/.kiro/specs/bootstrap-ai-coding/design.md @@ -29,11 +29,12 @@ The design is split across multiple focused files: | [`design-data-models.md`](design-data-models.md) | Core data models (Mode, Config, ContainerSpec, SessionSummary), error handling tables, integration test infrastructure | | [`design-build-resources.md`](design-build-resources.md) | Build Resources agent module: implementation, design decisions, RunAsUser extension, Dockerfile layer order | | [`design-agents.md`](design-agents.md) | Agent modules: contract, Claude Code implementation, adding future agents | +| [`design-vibekanban.md`](design-vibekanban.md) | Vibe Kanban agent module: auto-start mechanism, crash recovery, port discovery | | [`design-properties.md`](design-properties.md) | Correctness properties (Properties 1–51) and full testing strategy | ## Related Documents - `requirements-core.md` — core application requirements (Req 1–22, including Req 22: Dynamic Container User Identity) -- `requirements-agents.md` — agent module requirements (CC-1–CC-6 for Claude Code, AC-1–AC-6 for Augment Code) +- `requirements-agents.md` — agent module requirements (CC-1–CC-8 for Claude Code, AC-1–AC-6 for Augment Code, BR-1–BR-6 for Build Resources, VK-1–VK-8 for Vibe Kanban) - `requirements-cli-combinations.md` — valid and invalid CLI flag combinations (CLI-1–CLI-6) - `requirements-two-layer-image.md` — two-layer Docker image requirements (TL-1–TL-11) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md index 0831bef..961f8a5 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md @@ -4,7 +4,7 @@ This document defines the requirements for AI coding agent modules that plug into the `bootstrap-ai-coding` core. Each agent module is a self-contained implementation of the Agent_Interface defined by the core. The core does not need to be modified to add a new agent — only a new module conforming to this specification is required. -This document currently covers **Claude Code** as the reference implementation, **Augment Code** as the second agent module, and **Build Resources** as a pseudo-agent that installs common build toolchains. Future agents (e.g. Codex, Gemini Code Assist, Aider) would each have their own section following the same structure. +This document currently covers **Claude Code** as the reference implementation, **Augment Code** as the second agent module, **Build Resources** as a pseudo-agent that installs common build toolchains, and **Vibe Kanban** as a web-based project management tool for AI coding agents. Future agents (e.g. Codex, Gemini Code Assist, Aider) would each have their own section following the same structure. > **Related documents:** > - `requirements-core.md` — core application requirements including the Agent_Interface contract @@ -311,3 +311,126 @@ Build Resources is a pseudo-agent that does not provide an AI coding tool. Inste 1. THE `constants.DefaultAgents` value SHALL include `"build-resources"` so that the module is enabled by default when the `--agents` flag is omitted. 2. THE user SHALL be able to exclude Build Resources by specifying `--agents` without `build-resources` in the list. + + +--- + +## Vibe Kanban Agent + +### Overview + +Vibe Kanban is a web-based project management tool designed for AI coding agents. It provides a kanban board, task management, and workspace management interface accessible via a web browser. It is distributed as the `vibe-kanban` npm package (GitHub: BloopAI/vibe-kanban, Apache-2.0 license) and run via `npx vibe-kanban`. Unlike other agent modules that are CLI tools invoked on demand, Vibe Kanban is a **web application** that must be running as a background service after container start so the user can access it from their host browser. + +The container uses host network mode (Req 26) by default, so Vibe Kanban's auto-assigned port is directly accessible from the host browser without additional port forwarding. + +### Glossary + +- **Vibe_Kanban**: The web-based project management application for AI coding agents, run via `npx vibe-kanban`. Serves a combined frontend and backend on a single auto-assigned port. +- **Vibe_Kanban_Port**: The TCP port on which the Vibe Kanban server listens. Auto-assigned at startup (Vibe Kanban selects a free port). Accessible from the host browser via host network mode. + +--- + +### Requirement VK-1: Agent Identity + +**User Story:** As the core system, I need the Vibe Kanban module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL declare the Agent_ID `"vibe-kanban"` by returning that exact string from its `ID()` method, sourced from `constants.VibeKanbanAgentName`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. +3. WHEN the module package is imported, THE Vibe Kanban module SHALL self-register with the global agent registry via its `init()` function by calling `agent.Register()`. +4. IF the Agent_ID `"vibe-kanban"` is already registered, THEN THE system SHALL panic with a message indicating a duplicate registration. + +--- + +### Requirement VK-2: Installation + +**User Story:** As a developer, I want Vibe Kanban to be pre-installed in the container image so it can start immediately when the container launches. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL contribute Dockerfile steps that install Node.js (>= 20) as a runtime dependency, compatible with the Base_Container_Image. Note: when Vibe Kanban is enabled alongside Claude Code and Augment Code (the default), the agents share a single Node.js installation. Since Augment Code requires Node.js 22+ (see AC-2), the installed version satisfies Vibe Kanban's >= 20 requirement. IF Node.js is already installed by another agent module's Dockerfile steps, THEN the Vibe Kanban module SHALL skip its own Node.js installation rather than installing a second copy. +2. THE Vibe Kanban module SHALL contribute Dockerfile steps that install the `vibe-kanban` npm package globally using `npm install -g vibe-kanban`. +3. WHEN the container image is built with Vibe Kanban enabled, the `vibe-kanban` command SHALL be available on the default `PATH` inside the Container for the Container_User, verifiable by running `which vibe-kanban` as the Container_User and receiving a zero exit code. +4. THE installation steps SHALL NOT require Rust or pnpm — the `vibe-kanban` npm package ships pre-built native binaries, so no native compilation toolchain beyond what `npm install -g` provides is needed. +5. THE installation steps SHALL NOT require any manual intervention after the container starts. + +--- + +### Requirement VK-3: Automatic Service Start + +**User Story:** As a developer, I want Vibe Kanban to be running automatically after the container starts, so I can immediately open it in my browser without manually launching it. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL configure the container (via Dockerfile RUN steps in its `Install()` method) so that the Vibe Kanban web server starts automatically as a background process when the container starts, without modifying the container's CMD instruction. +2. THE Vibe Kanban web server SHALL be started as the Container_User (not root). +3. WHEN the container starts, THE Vibe Kanban server SHALL be listening on the Vibe_Kanban_Port (auto-assigned by Vibe Kanban at startup) within 30 seconds. +4. WHEN the container restarts (due to the Container's Restart_Policy or Docker daemon restart), THE automatic start mechanism SHALL re-launch the Vibe Kanban process without user intervention, because the mechanism is baked into the container image. +5. IF the Vibe Kanban process crashes, THEN THE automatic start mechanism SHALL restart it without requiring user intervention, with a delay of at least 5 seconds between restart attempts and a maximum of 5 restart attempts within any 60-second window to prevent resource exhaustion from infinite crash loops. +6. THE automatic start mechanism SHALL NOT block the container's SSH server or other agent modules from starting. + +--- + +### Requirement VK-4: No Credential Store + +**User Story:** As the core system, I need the Vibe Kanban module to conform to the Agent_Interface even though it has no credentials to manage. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL return an empty string from `CredentialStorePath()` indicating no host-side credential directory exists for this agent. +2. THE Vibe Kanban module SHALL return an empty string from `ContainerMountPath(homeDir string)` regardless of the `homeDir` argument value, indicating no bind-mount is needed. +3. THE Vibe Kanban module SHALL return `(true, nil)` from `HasCredentials(storePath string)` regardless of the `storePath` argument value, indicating credentials are never missing. + +--- + +### Requirement VK-5: Readiness Health Check + +**User Story:** As the core system, I need to verify that Vibe Kanban is correctly installed and running inside a running container before reporting it as ready. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL implement a Health_Check that verifies the `vibe-kanban` binary is present and executable inside the Container by executing `vibe-kanban --version` and confirming it exits with code 0. +2. THE Health_Check SHALL verify that the Vibe Kanban web server process is running inside the Container by checking that a process matching `vibe-kanban` exists in the process table (e.g. via `pgrep -f vibe-kanban`). IF the process is not detected on the first attempt, THE Health_Check SHALL retry up to 5 times with a 2-second interval between attempts before reporting failure. +3. WHEN the Container starts, THE core SHALL invoke the Health_Check for the Vibe Kanban module. +4. IF the Health_Check fails, THEN THE core SHALL report the failure to the user with an error message identifying the Vibe Kanban agent and indicating which check failed (binary presence or process running). + +--- + +### Requirement VK-6: No Core Coupling + +**User Story:** As a platform maintainer, I want the Vibe Kanban module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL NOT be referenced by name (string literal `"vibe-kanban"`) or by Go import path anywhere in the core application code (all packages under `internal/` excluding `internal/agents/`). +2. THE Vibe Kanban module SHALL register itself with the Agent_Registry via an `init()` function that calls `agent.Register()`, without requiring any modification to core source files. +3. IF the Vibe Kanban module is not compiled in, THEN THE core application SHALL start, accept all CLI commands, and exit without panic or error attributable to the absent module. +4. IF the Vibe Kanban module is not compiled in and the user does not specify `--agents`, THEN THE core application SHALL operate using only the remaining agents present in `constants.DefaultAgents` that are registered. + +--- + +### Requirement VK-7: Default Inclusion + +**User Story:** As a developer, I want Vibe Kanban included by default so that the project management board is available without needing to explicitly request it. + +#### Acceptance Criteria + +1. THE `constants.DefaultAgents` value SHALL include `"vibe-kanban"` in the comma-separated list so that the agent is present in the parsed agent set when the `--agents` flag is omitted. +2. WHEN the user invokes the CLI without the `--agents` flag, THE system SHALL include `"vibe-kanban"` in the enabled agents list displayed in the session summary. +3. IF the user specifies `--agents` with a list that does not contain `"vibe-kanban"`, THEN THE system SHALL not install or enable the Vibe Kanban module in the container, and `"vibe-kanban"` SHALL not appear in the session summary's enabled agents list. +4. THE `"vibe-kanban"` agent ID SHALL be registered in the agent registry so that `agent.Lookup("vibe-kanban")` resolves without error. + +--- + +### Requirement VK-8: Host Browser Accessibility + +**User Story:** As a developer, I want to access the Vibe Kanban web interface from my host browser, so I can manage tasks while working in the container. + +#### Acceptance Criteria + +1. WHEN the container is running in host network mode (Req 26, default), THE Vibe Kanban server SHALL be accessible from the host browser at `http://localhost:`, where the server responds with an HTTP 2xx status to a GET request on that URL. +2. WHEN the container is successfully started and the Vibe Kanban health check (VK-5) passes, THE CLI SHALL discover the Vibe_Kanban_Port by inspecting the running Vibe Kanban process's listening port inside the Container, waiting up to 30 seconds for the port to become available. +3. THE session summary (Requirement 17 in requirements-core.md) SHALL include a labelled line "Vibe Kanban:" followed by the full URL `http://localhost:` so the user knows how to access it. +4. IF the CLI cannot discover the Vibe_Kanban_Port within the 30-second timeout (e.g. the process started but has not bound a port), THEN THE CLI SHALL print a warning message to stdout indicating that the Vibe Kanban URL could not be determined, and SHALL omit the Vibe Kanban URL from the session summary without failing the overall startup. +5. WHEN `--host-network-off` is set (bridge mode), THE Vibe Kanban server SHALL NOT be accessible from the host without additional port forwarding — this is a known limitation of bridge mode for non-SSH services. From 2c53caec2049d4ef95eec726494dea57a95e0ecc Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 11 May 2026 21:03:00 +0200 Subject: [PATCH 04/13] purge works again --- .../bootstrap-ai-coding/design-docker.md | 16 ++++- .../requirements-two-layer-image.md | 1 + internal/cmd/purge.go | 35 ++++++++++- internal/cmd/purge_test.go | 58 +++++++++++++++++++ internal/cmd/root.go | 34 +++++++++++ internal/docker/client.go | 5 ++ 6 files changed, 147 insertions(+), 2 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-docker.md b/.kiro/specs/bootstrap-ai-coding/design-docker.md index 74fdb27..1be557f 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-docker.md +++ b/.kiro/specs/bootstrap-ai-coding/design-docker.md @@ -133,7 +133,21 @@ No change to image handling. Only the container is stopped/removed. Both Base_Im ### `--purge` Behavior -Removes all images (both `bac-base:latest` and all `bac-:latest` instance images) via the existing `bac.managed` label filter. +Image removal proceeds in dependency order: + +1. Remove Instance_Images (have `bac.container` label) — children of Base_Image +2. `ImagesPrune(dangling=true)` — removes untagged intermediate build layers that still reference Base_Image +3. Remove Base_Image (no `bac.container` label) — suppress "No such image" errors (image already removed by prune in step 2) + +Docker refuses to delete a parent image while children exist. Dangling build cache layers count as children. "No such image" errors in step 3 are skipped because the prune already removed those images. + +#### Reasoning + +- **`bac.container` label as partition key:** All bac-managed images carry `bac.managed=true`. Instance_Images additionally carry `bac.container=`. This distinguishes children from parent without inspecting image ancestry or parsing FROM directives at runtime. +- **Dangling prune between steps 1 and 3:** `--rebuild` creates new layers and old Instance_Image layers become dangling (untagged). These still reference `bac-base` as their parent in Docker's image graph. Without pruning, Base_Image removal fails with "image has dependent child images." The `dangling=true` filter only removes untagged, unreferenced images — it cannot remove tagged images from other tools. +- **Suppressing "No such image":** The prune in step 2 may remove images that were in the original image list (captured before removal began). Step 3 then attempts to remove an already-gone image. This is the desired outcome, so the error is skipped. + +**Validates: TL-7** ### Constants Addition diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md index 826dce3..632afda 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-two-layer-image.md @@ -121,6 +121,7 @@ This feature splits the current monolithic Container_Image build (core Req 14) i 2. IF confirmed: stop/remove all bac-managed containers, remove all bac-managed images (Base_Image + all Instance_Images), delete Tool_Data_Dir root, remove all Known_Hosts_Entries, remove all SSH_Config_Entries. 3. IF not confirmed, print "Purge cancelled." and exit 0. 4. IF removal of an individual item fails, print warning to stderr and continue. +5. Image removal SHALL proceed in dependency order: Instance_Images (children, identified by `bac.container` label) SHALL be removed before Base_Image (parent). This prevents Docker's "image has dependent child images" error. ### Requirement TL-8: Agent Manifest Change Detection diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go index 7a87bb0..0010721 100644 --- a/internal/cmd/purge.go +++ b/internal/cmd/purge.go @@ -17,6 +17,7 @@ type PurgeDockerAPI interface { ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) + ImagesPrune(ctx context.Context, pruneFilter filters.Args) (image.PruneReport, error) } // RunPurgeWith implements the container and image removal portion of the purge @@ -53,8 +54,40 @@ func RunPurgeWith(api PurgeDockerAPI) error { } } - // Remove all images. + // Remove images in dependency order: instance images (children) first, then + // prune dangling intermediate layers, then the base image (parent). Docker + // refuses to delete a parent image while child images still reference it via + // FROM — this includes untagged intermediate build cache layers. + var instanceImages, baseImages []image.Summary for _, img := range images { + if img.Labels["bac.container"] != "" { + instanceImages = append(instanceImages, img) + } else { + baseImages = append(baseImages, img) + } + } + + // 1. Remove instance images (children of bac-base). + for _, img := range instanceImages { + if _, err := api.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { + tag := img.ID + if len(img.RepoTags) > 0 { + tag = img.RepoTags[0] + } + fmt.Printf("warning: removing image %s: %v\n", tag, err) + } + } + + // 2. Prune dangling images (untagged intermediate build layers that may + // still reference bac-base as their parent). + danglingFilter := filters.NewArgs() + danglingFilter.Add("dangling", "true") + if _, err := api.ImagesPrune(ctx, danglingFilter); err != nil { + fmt.Printf("warning: pruning dangling images: %v\n", err) + } + + // 3. Remove base image(s) now that children are gone. + for _, img := range baseImages { if _, err := api.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { tag := img.ID if len(img.RepoTags) > 0 { diff --git a/internal/cmd/purge_test.go b/internal/cmd/purge_test.go index 9e89a56..de73a64 100644 --- a/internal/cmd/purge_test.go +++ b/internal/cmd/purge_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/stretchr/testify/require" @@ -18,6 +19,7 @@ type mockPurgeDockerAPI struct { images []image.Summary removedImageIDs []string + pruned bool } func (m *mockPurgeDockerAPI) ContainerList(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { @@ -41,6 +43,11 @@ func (m *mockPurgeDockerAPI) ImageRemove(_ context.Context, imageID string, _ im return nil, nil } +func (m *mockPurgeDockerAPI) ImagesPrune(_ context.Context, _ filters.Args) (image.PruneReport, error) { + m.pruned = true + return image.PruneReport{}, nil +} + // TestPurgeRemovesBothBaseAndInstanceImages verifies that the purge flow removes // both the base image (bac-base:latest) and instance images (e.g. bac-myproject:latest) // when both are present with bac.managed=true labels. @@ -108,6 +115,57 @@ func TestPurgeRemovesMultipleInstanceImages(t *testing.T) { require.Contains(t, mock.removedImageIDs, "sha256:instance-b") } +// TestPurgeRemovesInstanceImagesBeforeBaseImage verifies that purge removes +// instance images (children) before the base image (parent) to avoid Docker's +// "image has dependent child images" error. +// Validates: TL-7.4 +func TestPurgeRemovesInstanceImagesBeforeBaseImage(t *testing.T) { + mock := &mockPurgeDockerAPI{ + containers: []container.Summary{}, + // Deliberately list base image FIRST to verify reordering. + images: []image.Summary{ + { + ID: "sha256:base111", + RepoTags: []string{"bac-base:latest"}, + Labels: map[string]string{"bac.managed": "true"}, + }, + { + ID: "sha256:instance-a", + RepoTags: []string{"bac-projecta:latest"}, + Labels: map[string]string{"bac.managed": "true", "bac.container": "bac-projecta"}, + }, + { + ID: "sha256:instance-b", + RepoTags: []string{"bac-projectb:latest"}, + Labels: map[string]string{"bac.managed": "true", "bac.container": "bac-projectb"}, + }, + }, + } + + err := cmd.RunPurgeWith(mock) + require.NoError(t, err) + + require.Len(t, mock.removedImageIDs, 3) + + // Find the index of the base image in the removal order. + baseIdx := -1 + for i, id := range mock.removedImageIDs { + if id == "sha256:base111" { + baseIdx = i + break + } + } + require.NotEqual(t, -1, baseIdx, "base image must be removed") + + // All instance images must appear before the base image. + for i, id := range mock.removedImageIDs { + if id == "sha256:instance-a" || id == "sha256:instance-b" { + require.Less(t, i, baseIdx, + "instance image %s (index %d) must be removed before base image (index %d)", id, i, baseIdx) + } + } +} + // TestPurgeAlsoStopsAndRemovesContainers verifies that purge stops and removes // containers before removing images. // Validates: TL-7 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6829d95..6d4856c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -366,7 +367,18 @@ func runPurge(c *dockerpkg.Client) error { } } + // Remove images in dependency order: instance images (children) first, then + // prune dangling intermediate layers, then the base image (parent). + var instanceImages, baseImages []image.Summary for _, img := range images { + if img.Labels["bac.container"] != "" { + instanceImages = append(instanceImages, img) + } else { + baseImages = append(baseImages, img) + } + } + + for _, img := range instanceImages { tag := img.ID if len(img.RepoTags) > 0 { tag = img.RepoTags[0] @@ -376,6 +388,28 @@ func runPurge(c *dockerpkg.Client) error { } } + // Prune dangling images (untagged intermediate build layers that may still + // reference bac-base as their parent). + danglingFilter := filters.NewArgs() + danglingFilter.Add("dangling", "true") + if _, err := c.ImagesPrune(ctx, danglingFilter); err != nil { + fmt.Fprintf(os.Stderr, "warning: pruning dangling images: %v\n", err) + } + + for _, img := range baseImages { + tag := img.ID + if len(img.RepoTags) > 0 { + tag = img.RepoTags[0] + } + if _, err := c.ImageRemove(ctx, img.ID, image.RemoveOptions{Force: true}); err != nil { + // Skip "No such image" — already removed by the dangling prune step. + if strings.Contains(err.Error(), "No such image") { + continue + } + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) + } + } + if err := datadir.PurgeRoot(); err != nil { fmt.Fprintf(os.Stderr, "warning: purging data dir: %v\n", err) } diff --git a/internal/docker/client.go b/internal/docker/client.go index 77ab412..a09ecd4 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/pkg/stdcopy" @@ -140,6 +141,10 @@ func (c *Client) ImageRemove(ctx context.Context, imageID string, options image. return c.inner.ImageRemove(ctx, imageID, options) } +func (c *Client) ImagesPrune(ctx context.Context, pruneFilter filters.Args) (image.PruneReport, error) { + return c.inner.ImagesPrune(ctx, pruneFilter) +} + func (c *Client) ContainerExecCreate(ctx context.Context, containerID string, options container.ExecOptions) (container.ExecCreateResponse, error) { return c.inner.ContainerExecCreate(ctx, containerID, options) } From cd918f64a52c4219d1ef4e40a83833e9a8abd140 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 11 May 2026 21:26:15 +0200 Subject: [PATCH 05/13] Tasks to implement Vibe kanban --- .kiro/specs/bootstrap-ai-coding/tasks.md | 339 ++++++++++++----------- 1 file changed, 170 insertions(+), 169 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md index 529b02c..eeb2d10 100644 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -1,173 +1,174 @@ -# Tasks: Host Network Mode (Req 26) +# Implementation Plan: Vibe Kanban Agent Module + +## Overview + +Implement the Vibe Kanban agent module (`internal/agents/vibekanban/`) — a web-based project management tool that runs as a background service inside the container. The implementation adds the agent constant, extends the DockerfileBuilder with an `Entrypoint()` method, adds `ExecInContainerWithOutput()` to the runner, creates the agent module with auto-start via ENTRYPOINT wrapper + supervisor script, extends the session summary with the Vibe Kanban URL, and integrates port discovery into `runStart()`. + +## Tasks + +- [ ] 1. Add Vibe Kanban constant and update DefaultAgents + - [ ] 1.1 Add `VibeKanbanAgentName` constant and update `DefaultAgents` in `internal/constants/constants.go` + - Add `VibeKanbanAgentName = "vibe-kanban"` constant with comment referencing VK-1 + - Update `DefaultAgents` to append `"," + VibeKanbanAgentName` to the existing value + - _Requirements: VK-1.1, VK-7.1_ + +- [ ] 2. Extend DockerfileBuilder with Entrypoint method + - [ ] 2.1 Add `Entrypoint()` method to `DockerfileBuilder` in `internal/docker/builder.go` + - Implement `Entrypoint(args ...string)` that appends an `ENTRYPOINT [...]` instruction in exec form + - Each arg is quoted with `fmt.Sprintf("%q", a)` and joined with `", "` + - _Requirements: VK-3.1_ + + - [ ]* 2.2 Write unit tests for `Entrypoint()` method in `internal/docker/builder_test.go` + - Test single-arg entrypoint produces correct exec-form instruction + - Test multi-arg entrypoint produces correct exec-form instruction + - _Requirements: VK-3.1_ + +- [ ] 3. Add ExecInContainerWithOutput helper to runner + - [ ] 3.1 Add `ExecInContainerWithOutput()` function to `internal/docker/runner.go` + - Implement function that runs a command inside a container and returns `(exitCode int, stdout string, err error)` + - Use `ContainerExecCreate` with `AttachStdout: true, AttachStderr: true` + - Use `ContainerExecAttach` and `stdcopy.StdCopy` to separate stdout from stderr + - Use `ContainerExecInspect` to get the exit code + - Add necessary imports: `"github.com/docker/docker/pkg/stdcopy"` + - _Requirements: VK-8.2_ + +- [ ] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 5. Create the Vibe Kanban agent module + - [ ] 5.1 Create `internal/agents/vibekanban/vibekanban.go` with full agent implementation + - Implement `vibeKanbanAgent` struct with `init()` calling `agent.Register()` + - Implement `ID()` returning `constants.VibeKanbanAgentName` + - Implement `Install(b *docker.DockerfileBuilder)`: + - Conditional Node.js installation (check `b.IsNodeInstalled()`, install Node.js 22 if not, call `b.MarkNodeInstalled()`) + - `npm install -g --no-fund --no-audit vibe-kanban` + - Generate supervisor script with crash recovery (MAX_RESTARTS=5, WINDOW_SECONDS=60, DELAY_SECONDS=5), replacing `__USERNAME__` with `b.Username()` + - Generate entrypoint wrapper script (`bac-entrypoint.sh`) that starts supervisor in background then `exec "$@"` + - Call `b.Entrypoint("/usr/local/bin/bac-entrypoint.sh")` + - Implement `CredentialStorePath()` returning `""` + - Implement `ContainerMountPath(homeDir string)` returning `""` + - Implement `HasCredentials(storePath string)` returning `(true, nil)` + - Implement `HealthCheck(ctx, c, containerID)`: + - Check 1: `vibe-kanban --version` exits 0 (binary presence) + - Check 2: `pgrep -f vibe-kanban` with up to 5 retries at 2-second intervals (process running) + - _Requirements: VK-1.1, VK-1.3, VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-3.2, VK-3.5, VK-3.6, VK-4.1, VK-4.2, VK-4.3, VK-5.1, VK-5.2_ + + - [ ] 5.2 Add blank import in `main.go` for the vibekanban package + - Add `_ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban"` to the import block + - _Requirements: VK-1.3, VK-6.2_ + +- [ ] 6. Extend SessionSummary and integrate port discovery + - [ ] 6.1 Add `VibeKanbanURL` field to `SessionSummary` struct and update `FormatSessionSummary` in `internal/cmd/root.go` + - Add `VibeKanbanURL string` field to `SessionSummary` + - Update `FormatSessionSummary` to use `strings.Builder` and conditionally include "Vibe Kanban:" line when `VibeKanbanURL` is non-empty + - _Requirements: VK-8.3, VK-8.4_ + + - [ ] 6.2 Add `discoverVibeKanbanPort()` function and integrate into `runStart()` in `internal/cmd/root.go` + - Implement `discoverVibeKanbanPort(ctx, c, containerID)` that: + - Executes `ss -tlnp` inside the container via `dockerpkg.ExecInContainerWithOutput` + - Greps for `vibe-kanban` and parses the port number + - Retries for up to 30 seconds with 2-second intervals + - Returns `(port int, err error)` + - In `runStart()`, after SSH health check passes, check if `vibe-kanban` is in enabled agents + - If enabled, call `discoverVibeKanbanPort()` and set `vibeKanbanURL` + - On failure, print warning to stderr and continue (graceful degradation) + - Pass `VibeKanbanURL` to `printSessionSummary` / `SessionSummary` + - Update `printSessionSummary` to accept and pass through the URL + - _Requirements: VK-8.2, VK-8.3, VK-8.4_ + +- [ ] 7. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 8. Write unit tests for the Vibe Kanban agent module + - [ ] 8.1 Create `internal/agents/vibekanban/vibekanban_test.go` with unit tests + - `TestID` — returns `constants.VibeKanbanAgentName` + - `TestInstallNodeAlreadyInstalled` — skips Node.js when `IsNodeInstalled()` is true + - `TestInstallNodeNotInstalled` — installs Node.js when `IsNodeInstalled()` is false + - `TestInstallContainsNpmPackage` — output contains `npm install -g` with `vibe-kanban` + - `TestInstallContainsEntrypoint` — output contains ENTRYPOINT instruction + - `TestInstallContainsSupervisor` — output contains supervisor script with crash recovery params + - `TestInstallDoesNotContainCMD` — output does NOT contain CMD instruction + - `TestInstallNoRustNoPnpm` — output does NOT contain rust/pnpm references + - `TestCredentialStorePath` — returns empty string + - `TestContainerMountPath` — returns empty string for various homeDir values + - `TestHasCredentials` — returns `(true, nil)` + - `TestHealthCheckBinaryFailure` — error message identifies binary check + - `TestHealthCheckProcessFailure` — error message identifies process check + - _Requirements: VK-1.1, VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-4.1, VK-4.2, VK-4.3, VK-5.1, VK-5.2_ + + - [ ]* 8.2 Write unit tests for `FormatSessionSummary` with Vibe Kanban URL in `internal/cmd/root_test.go` + - `TestFormatSessionSummaryWithVibeKanban` — URL line present when VibeKanbanURL is set + - `TestFormatSessionSummaryWithoutVibeKanban` — URL line absent when VibeKanbanURL is empty + - _Requirements: VK-8.3, VK-8.4_ + +- [ ] 9. Write property-based tests for the Vibe Kanban agent module + - [ ]* 9.1 Write property test: Node.js conditional installation invariant + - **Property 1: Node.js conditional installation invariant** + - For any DockerfileBuilder state, calling Install() results in at most one Node.js installation block and `IsNodeInstalled()` returns true after + - Use `rapid.Bool()` to draw whether Node.js is pre-installed + - **Validates: Requirements VK-2.1** + + - [ ]* 9.2 Write property test: Install does not emit CMD + - **Property 2: Install does not emit CMD** + - For any DockerfileBuilder state, calling Install() does NOT append any line starting with `CMD` + - Use `rapid.Bool()` to draw whether Node.js is pre-installed + - **Validates: Requirements VK-3.1** + + - [ ]* 9.3 Write property test: No-credential-store invariant + - **Property 3: No-credential-store invariant** + - For any string homeDir, `ContainerMountPath(homeDir)` returns empty; for any storePath, `HasCredentials(storePath)` returns `(true, nil)` + - Use `rapid.String()` to draw arbitrary homeDir and storePath values + - **Validates: Requirements VK-4.2, VK-4.3** + + - [ ]* 9.4 Write property test: Session summary includes Vibe Kanban URL for any valid port + - **Property 4: Session summary includes Vibe Kanban URL for any valid port** + - For any valid TCP port (1-65535), `FormatSessionSummary()` with `VibeKanbanURL` set includes the URL; when empty, output does NOT contain "Vibe Kanban:" + - Use `rapid.IntRange(1, 65535)` to draw port numbers + - **Validates: Requirements VK-8.3** + + - [ ]* 9.5 Write property test: Supervisor script contains correct backoff parameters + - **Property 5: Supervisor script contains correct backoff parameters** + - For any valid Linux username, the supervisor script generated by Install() contains `MAX_RESTARTS=5`, `WINDOW_SECONDS=60`, and `DELAY_SECONDS=5` + - Use `rapid.StringMatching(`[a-z_][a-z0-9_-]*`)` to draw usernames + - **Validates: Requirements VK-3.5** + +- [ ] 10. Write integration tests for the Vibe Kanban agent module + - [ ]* 10.1 Create `internal/agents/vibekanban/integration_test.go` with integration tests + - Gated by `//go:build integration` + - Include `TestMain` with consent gate and base image removal + - `TestVibeKanbanInstallsAndRuns` — full image build, binary present (`which vibe-kanban` exits 0), process running + - `TestVibeKanbanHealthCheck` — HealthCheck passes on a live container + - `TestVibeKanbanPortDiscovery` — port is discoverable via ss after startup + - `TestVibeKanbanCrashRecovery` — process restarts after being killed (kill + wait + verify running) + - `TestVibeKanbanAccessibleFromHost` — HTTP GET to localhost:port returns 2xx (host network mode) + - _Requirements: VK-2.3, VK-3.1, VK-3.3, VK-3.5, VK-5.1, VK-5.2, VK-8.1, VK-8.2_ + +- [ ] 11. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The design uses Go — all code examples and implementations use Go +- The agent module follows the same pattern as `internal/agents/buildresources/` and `internal/agents/augment/` +- `ExecInContainerWithOutput` is needed for port discovery (capturing stdout from `ss -tlnp`) +- The `Entrypoint()` builder method is generic and reusable by future agents needing initialization before CMD ## Task Dependency Graph -```mermaid -flowchart TD - T1[Task 1: Add --host-network-off flag to CLI] - T2[Task 2: Add HostNetworkOff to ContainerSpec] - T3[Task 3: Update CreateContainer for host network mode] - T4[Task 4: Update NewInstanceImageBuilder for sshd_config] - T5[Task 5: Update runStart orchestration] - T6[Task 6: Network mode change detection] - T7[Task 7: Unit tests for CLI flag] - T8[Task 8: Property test for network mode + sshd_config] - T9[Task 9: Integration test for host network mode] - - T1 --> T5 - T2 --> T3 - T2 --> T4 - T3 --> T5 - T4 --> T5 - T5 --> T6 - T1 --> T7 - T3 --> T8 - T4 --> T8 - T6 --> T9 +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "2.1", "3.1"] }, + { "id": 1, "tasks": ["2.2", "5.1"] }, + { "id": 2, "tasks": ["5.2", "6.1"] }, + { "id": 3, "tasks": ["6.2"] }, + { "id": 4, "tasks": ["8.1", "8.2", "9.1", "9.2", "9.3", "9.4", "9.5"] }, + { "id": 5, "tasks": ["10.1"] } + ] +} ``` - ---- - -## Task 1: Add `--host-network-off` flag to CLI - -Add the `--host-network-off` boolean flag to `cmd/root.go`. Default is `false` (host network mode is ON). Register it as a START-only flag in `ValidateStartOnlyFlags`. - -### Sub-tasks - -- [x] 1.1. Add `flagHostNetworkOff bool` variable and register the flag in `init()`: - ```go - rootCmd.Flags().BoolVar(&flagHostNetworkOff, "host-network-off", false, "Disable host network mode; use bridge networking with port mapping") - ``` -- [x] 1.2. Add `"host-network-off": true` to the `startOnly` map in `ValidateStartOnlyFlags`. -- [x] 1.3. Thread `flagHostNetworkOff` into the `runStart` call (pass it as a parameter or add to a config struct). - ---- - -## Task 2: Add `HostNetworkOff` field to `ContainerSpec` - -Add the `HostNetworkOff bool` field to the `ContainerSpec` struct in `docker/runner.go`. - -### Sub-tasks - -- [x] 2.1. Add `HostNetworkOff bool` field to `ContainerSpec` struct with comment: `// Req 26: when true, use bridge mode; when false (default), use host network`. -- [x] 2.2. Update the `SSHPort` field comment from "Host-side TCP port mapped to container port 22" to "SSH port (used in sshd_config for host mode, or Docker port mapping for bridge mode)". - ---- - -## Task 3: Update `CreateContainer` for host network mode - -Modify `CreateContainer` in `docker/runner.go` to conditionally use `NetworkMode: "host"` (no port bindings) or bridge mode (with port bindings) based on `spec.HostNetworkOff`. - -### Sub-tasks - -- [x] 3.1. When `spec.HostNetworkOff == false` (default, host network mode): - - Set `HostConfig.NetworkMode = "host"` - - Do NOT set `PortBindings` or `ExposedPorts` - - Do NOT set `ExposedPorts` in `container.Config` -- [x] 3.2. When `spec.HostNetworkOff == true` (bridge mode): - - Keep existing port binding logic: map `constants.ContainerSSHPort/tcp` → `constants.HostBindIP:spec.SSHPort` - - Set `ExposedPorts` in `container.Config` - - Do NOT set `NetworkMode` (use Docker default bridge) -- [x] 3.3. Remove the `nat` import if it becomes conditionally unused (it won't — bridge mode still uses it). - ---- - -## Task 4: Update `NewInstanceImageBuilder` for sshd_config - -Modify `NewInstanceImageBuilder` in `docker/builder.go` to accept a `hostNetworkOff bool` parameter. When `false` (host mode), append `Port ` and `ListenAddress 127.0.0.1` to sshd_config. When `true` (bridge mode), omit these directives. - -### Sub-tasks - -- [x] 4.1. Add `sshPort int` and `hostNetworkOff bool` parameters to `NewInstanceImageBuilder` signature. -- [x] 4.2. In step 4 (sshd_config hardening), conditionally append `Port` and `ListenAddress`: - ```go - sshdConfig := "echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config && echo 'PermitRootLogin no' >> /etc/ssh/sshd_config && echo 'PubkeyAuthentication yes' >> /etc/ssh/sshd_config" - if !hostNetworkOff { - sshdConfig += fmt.Sprintf(" && echo 'Port %d' >> /etc/ssh/sshd_config && echo 'ListenAddress %s' >> /etc/ssh/sshd_config", sshPort, constants.HostBindIP) - } - b.Run(sshdConfig) - ``` -- [x] 4.3. Update all callers of `NewInstanceImageBuilder` to pass the new parameters (currently only `runStart` in `cmd/root.go`). - ---- - -## Task 5: Update `runStart` orchestration - -Wire the `--host-network-off` flag through the `runStart` function: pass it to `NewInstanceImageBuilder` and set it on the `ContainerSpec`. - -### Sub-tasks - -- [x] 5.1. Pass `flagHostNetworkOff` to `NewInstanceImageBuilder(info, publicKey, hostKeyPriv, hostKeyPub, sshPort, flagHostNetworkOff)`. -- [x] 5.2. Set `HostNetworkOff: flagHostNetworkOff` on the `ContainerSpec` passed to `CreateContainer`. -- [x] 5.3. Persist the `hostNetworkOff` value in the Tool_Data_Dir (for change detection in Task 6). Add `WriteHostNetworkOff` / `ReadHostNetworkOff` methods to `datadir.DataDir`. - ---- - -## Task 6: Network mode change detection - -Detect when `--host-network-off` changes between invocations and require `--rebuild`. This prevents running a container whose sshd_config doesn't match the network mode. - -### Sub-tasks - -- [x] 6.1. Add `datadir.WriteHostNetworkOff(off bool)` and `datadir.ReadHostNetworkOff() (bool, error)` — store as `"true"` or `"false"` in a file `host_network_off` in the Tool_Data_Dir. -- [x] 6.2. In `runStart`, after loading the persisted value, compare with the current `flagHostNetworkOff`. If they differ and `--rebuild` is not set, print "Network mode changed — run with --rebuild to update the image." and return nil (exit 0). -- [x] 6.3. When building the instance image (needInstance == true), persist the current `flagHostNetworkOff` value via `dd.WriteHostNetworkOff(flagHostNetworkOff)`. - ---- - -## Task 7: Unit tests for CLI flag - -Add unit tests for the `--host-network-off` flag validation and CLI-3 constraint. - -### Sub-tasks - -- [x] 7.1. In `cmd/root_test.go`, add test: `--host-network-off` with `--stop-and-remove` → error (CLI-3). -- [x] 7.2. In `cmd/root_test.go`, add test: `--host-network-off` with `--purge` → error (CLI-3). -- [x] 7.3. In `cmd/root_test.go`, add test: `--host-network-off` in START mode → accepted (no error from flag validation). - ---- - -## Task 8: Property test for network mode + sshd_config (Property 22b, 57) - -Add property-based tests validating that the Instance_Image Dockerfile and ContainerSpec are correct for both network modes. - -### Sub-tasks - -- [x] 8.1. In `docker/builder_test.go`, add PBT: - ```go - // Feature: bootstrap-ai-coding, Property 57: --host-network-off controls network mode and sshd_config - func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - port := rapid.IntRange(1024, 65535).Draw(t, "port") - hostNetworkOff := rapid.Bool().Draw(t, "hostNetworkOff") - // Build instance image - // Assert: if !hostNetworkOff → contains "Port " and "ListenAddress 127.0.0.1" - // Assert: if hostNetworkOff → does NOT contain "Port" or "ListenAddress" - }) - } - ``` -- [x] 8.2. In `docker/runner_restart_test.go` (or a new `runner_network_test.go`), add a unit test verifying `CreateContainer` sets `NetworkMode: "host"` when `HostNetworkOff == false` and uses port bindings when `HostNetworkOff == true`. (This requires mocking the Docker client — follow existing patterns in `runner_restart_test.go`.) - ---- - -## Task 9: Integration test for host network mode - -Add an integration test that verifies end-to-end: container starts with host network, sshd is reachable on the assigned port, and a service on the host is reachable from inside the container. - -### Sub-tasks - -- [x] 9.1. In `docker/integration_test.go`, add test `TestHostNetworkModeSSHReachable`: - - Build base + instance image with host network mode (hostNetworkOff=false) - - Create and start container - - Assert: `WaitForSSH(ctx, "127.0.0.1", sshPort, 10s)` succeeds - - Cleanup: stop and remove container -- [x] 9.2. In `docker/integration_test.go`, add test `TestBridgeModeSSHReachable`: - - Build base + instance image with bridge mode (hostNetworkOff=true) - - Create and start container - - Assert: `WaitForSSH(ctx, "127.0.0.1", sshPort, 10s)` succeeds - - Cleanup: stop and remove container -- [x] 9.3. (Optional) Add test `TestHostNetworkCanReachHostService`: - - Start a TCP listener on a random port on the host - - Start container in host network mode - - Exec `nc -z 127.0.0.1 ` inside the container - - Assert: exit code 0 (service reachable) From bd7f0d41a8142167e60d789ef449f94575b3611e Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 13 May 2026 21:30:19 +0200 Subject: [PATCH 06/13] vibe kanban --- .../design-agent-summary-info.md | 353 +++++++++++ .../bootstrap-ai-coding/design-vibekanban.md | 25 +- .kiro/specs/bootstrap-ai-coding/design.md | 2 + .../requirements-agent-summary-info.md | 104 ++++ .../requirements-agents.md | 36 +- .../bootstrap-ai-coding/requirements-core.md | 5 +- .../specs/bootstrap-ai-coding/requirements.md | 1 + .kiro/specs/bootstrap-ai-coding/tasks.md | 252 +++----- internal/agent/agent.go | 8 + internal/agent/registry_test.go | 5 +- internal/agents/augment/augment.go | 7 + internal/agents/augment/augment_test.go | 17 + .../agents/buildresources/buildresources.go | 6 + .../buildresources/buildresources_test.go | 12 + internal/agents/claude/claude.go | 4 + internal/agents/claude/claude_test.go | 17 + .../agents/vibekanban/integration_test.go | 415 +++++++++++++ internal/agents/vibekanban/vibekanban.go | 218 +++++++ internal/agents/vibekanban/vibekanban_test.go | 578 ++++++++++++++++++ internal/cmd/root.go | 75 ++- internal/cmd/root_test.go | 202 ++++++ internal/constants/constants.go | 11 +- internal/docker/builder.go | 9 + internal/docker/builder_test.go | 19 + internal/docker/export_test.go | 9 - internal/docker/runner.go | 33 + internal/docker/testing_helper.go | 10 + main.go | 1 + 28 files changed, 2243 insertions(+), 191 deletions(-) create mode 100644 .kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md create mode 100644 .kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md create mode 100644 internal/agents/vibekanban/integration_test.go create mode 100644 internal/agents/vibekanban/vibekanban.go create mode 100644 internal/agents/vibekanban/vibekanban_test.go create mode 100644 internal/docker/testing_helper.go diff --git a/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md b/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md new file mode 100644 index 0000000..05d0653 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md @@ -0,0 +1,353 @@ +# Design: Agent Summary Info + +> **Related documents:** +> - [design.md](design.md) — Overview and document index +> - [requirements-agent-summary-info.md](requirements-agent-summary-info.md) — Requirements (SI-1 through SI-7) +> - [design-components.md](design-components.md) — Core component designs (Agent Interface, SessionSummary) +> - [design-vibekanban.md](design-vibekanban.md) — Vibe Kanban agent module design + +--- + +## Overview + +This design describes the Agent Summary Info mechanism — an extension to the `Agent` interface that allows agent modules to contribute key:value pairs to the session summary printed after a successful container start. The primary motivation is to remove all Vibe Kanban–specific logic from the core (`internal/cmd/root.go`), restoring the architectural rule that "core has zero knowledge of agents." + +The refactoring: +1. Adds a `KeyValue` struct and `SummaryInfo` method to the `Agent` interface +2. Moves port discovery logic from `root.go` into the `vibekanban` package +3. Makes the core iterate generically over agents to collect summary info +4. Removes all agent-specific references from `root.go` + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant Core as cmd/root.go + participant Agent1 as Agent (claude) + participant Agent2 as Agent (vibekanban) + participant Docker as Docker Client + + Note over Core: Container started successfully + Core->>Agent1: SummaryInfo(ctx, client, containerID) + Agent1-->>Core: (nil, nil) + Core->>Agent2: SummaryInfo(ctx, client, containerID) + Agent2->>Docker: ExecInContainerWithOutput(cat /tmp/vibe-kanban.port) + Docker-->>Agent2: "39497" + Agent2-->>Core: ([]KeyValue{{Key:"Vibe Kanban", Value:"http://localhost:39497"}}, nil) + Note over Core: Collect all KeyValue pairs + Note over Core: FormatSessionSummary with AgentInfo +``` + +The core treats all agents uniformly — it never inspects the returned keys or values, never branches on agent IDs, and never references `constants.VibeKanbanAgentName`. + +--- + +## Components and Interfaces + +### KeyValue Type + +Defined in `internal/agent/agent.go`: + +```go +// KeyValue represents a single labelled line in the session summary. +// Agents return slices of these from SummaryInfo(). +type KeyValue struct { + Key string + Value string +} +``` + +**Design decisions:** +- Lives in the `agent` package alongside the `Agent` interface so that both the core and agent modules can reference it without import cycles. +- Two simple exported fields — no methods, no validation. The core formats them as-is. + +### Updated Agent Interface + +```go +type Agent interface { + ID() string + Install(b *docker.DockerfileBuilder) + CredentialStorePath() string + ContainerMountPath(homeDir string) string + HasCredentials(storePath string) (bool, error) + HealthCheck(ctx context.Context, c *docker.Client, containerID string) error + SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]KeyValue, error) +} +``` + +The `SummaryInfo` method receives the same parameters as `HealthCheck` — this is intentional so agents can inspect the running container (exec commands, read logs, etc.) to gather information. + +### Updated SessionSummary + +In `internal/cmd/root.go`: + +```go +type SessionSummary struct { + DataDir string + ProjectDir string + SSHPort int + SSHConnect string + EnabledAgents []string + AgentInfo []agent.KeyValue // replaces VibeKanbanURL +} +``` + +The `VibeKanbanURL string` field is removed entirely. + +### Updated FormatSessionSummary + +```go +func FormatSessionSummary(s SessionSummary) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) + fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) + fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) + fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) + fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) + for _, kv := range s.AgentInfo { + fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) + } + return sb.String() +} +``` + +**Design decisions:** +- The format string `"%-17s%s\n"` left-pads the key (with colon) to 17 characters, aligning values with the existing fields (`"Data directory: "` is 17 chars including the trailing spaces). +- No conditional logic — the loop handles zero, one, or many entries uniformly. +- When `AgentInfo` is nil or empty, the loop body never executes, producing output identical to the current format (minus the removed Vibe Kanban line). + +--- + +### Core Collection Logic + +In `runStart` (and the reconnect path), after health checks pass and before calling `printSessionSummary`: + +```go +// Collect agent summary info. +var agentInfo []agent.KeyValue +for _, a := range enabledAgents { + kvs, err := a.SummaryInfo(ctx, c, containerName) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s summary info: %v\n", a.ID(), err) + continue + } + agentInfo = append(agentInfo, kvs...) +} +``` + +**Design decisions:** +- Iteration order matches the declared order of `enabledAgents` (which comes from the `--agents` flag parsing order). +- On error: print a warning to stderr, skip that agent's contributions, continue with the next agent. +- On nil/empty return: `append(agentInfo, nil...)` is a no-op in Go — no special case needed. +- The collected `agentInfo` slice is passed to `printSessionSummary` and stored in `SessionSummary.AgentInfo`. + +### Updated printSessionSummary + +```go +func printSessionSummary(dd *datadir.DataDir, projectDir string, containerName string, sshPort int, agentIDs []string, agentInfo []agent.KeyValue) { + summary := SessionSummary{ + DataDir: dd.Path(), + ProjectDir: projectDir, + SSHPort: sshPort, + SSHConnect: "ssh " + containerName, + EnabledAgents: agentIDs, + AgentInfo: agentInfo, + } + fmt.Print(FormatSessionSummary(summary)) +} +``` + +The `vibeKanbanURL string` parameter is removed and replaced by the generic `agentInfo []agent.KeyValue`. + +--- + +## Vibe Kanban SummaryInfo Implementation + +The port discovery uses a **port file** approach for robustness. The supervisor script starts vibe-kanban in the background, discovers its auto-assigned port by polling `ss -tlnp` filtered by the exact PID, and writes the port to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method simply reads this file. + +This design is robust because: +- It doesn't depend on process names in `ss` output (which vary by platform/version) +- It doesn't break when other services bind ports in the container +- It uses PID-based filtering in the supervisor, which is unambiguous + +```go +// vibeKanbanPortFile is the well-known path where the supervisor writes +// the auto-assigned port after vibe-kanban starts. +const vibeKanbanPortFile = "/tmp/vibe-kanban.port" + +// SummaryInfo reads the port file written by the supervisor script. +// Retries for up to 30 seconds with 2-second intervals. +func (a *vibeKanbanAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + exitCode, output, err := docker.ExecInContainerWithOutput(ctx, c, containerID, + []string{"cat", vibeKanbanPortFile}) + if err != nil { + return nil, err + } + if exitCode == 0 { + portStr := strings.TrimSpace(output) + port, err := strconv.Atoi(portStr) + if err == nil && port > 0 && port <= 65535 { + return []agent.KeyValue{ + {Key: "Vibe Kanban", Value: fmt.Sprintf("http://localhost:%d", port)}, + }, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } + return nil, fmt.Errorf("timed out after 30s waiting for vibe-kanban port file") +} +``` + +**New imports in `vibekanban.go`:** `"strconv"`. + +**Removed from `vibekanban.go`:** `"regexp"` (no longer needed — port file contains a plain integer). + +--- + +## Other Agents: No-Op SummaryInfo + +Claude Code, Augment Code, and Build Resources all implement the method identically: + +```go +// SummaryInfo returns nil — this agent has no summary information to contribute. +func (a *claudeAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} +``` + +```go +func (a *augmentAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} +``` + +```go +func (a *buildResourcesAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} +``` + +--- + +## What Gets Removed from root.go + +The following items are deleted from `internal/cmd/root.go`: + +| Item | Type | Reason | +|---|---|---| +| `VibeKanbanURL string` | Field on `SessionSummary` | Replaced by `AgentInfo []agent.KeyValue` | +| `discoverVibeKanbanPort()` | Function | Moved to `vibekanban.SummaryInfo()` | +| `portRegexp` | Package-level `var` | Moved to `vibekanban` package | +| `constants.VibeKanbanAgentName` reference | Import usage | Core no longer references any agent by name | +| Vibe Kanban URL conditional in `FormatSessionSummary` | `if` block | Replaced by generic `AgentInfo` loop | +| Vibe Kanban discovery blocks in `runStart` | Two code blocks (reconnect path + fresh start path) | Replaced by generic collection loop | + +After this refactoring, `root.go` no longer imports or references any agent-specific constant. The `"regexp"` and `"strconv"` imports can also be removed from `root.go` (they were only used by `discoverVibeKanbanPort`). + +--- + +## Data Models + +### KeyValue (new) + +| Field | Type | Description | +|---|---|---| +| `Key` | `string` | Label for the summary line (e.g. `"Vibe Kanban"`) | +| `Value` | `string` | Content for the summary line (e.g. `"http://localhost:3000"`) | + +### SessionSummary (updated) + +| Field | Type | Change | +|---|---|---| +| `DataDir` | `string` | unchanged | +| `ProjectDir` | `string` | unchanged | +| `SSHPort` | `int` | unchanged | +| `SSHConnect` | `string` | unchanged | +| `EnabledAgents` | `[]string` | unchanged | +| `VibeKanbanURL` | `string` | **removed** | +| `AgentInfo` | `[]agent.KeyValue` | **added** | + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Collection preserves order and excludes errors + +*For any* ordered list of agents where each agent returns either a `([]KeyValue, nil)` or `(nil, error)`, the collected output SHALL contain exactly the KeyValue pairs from non-erroring agents, in the same order as the agents were declared, with per-agent ordering preserved, and zero contributions from erroring agents. + +**Validates: Requirements SI-2.2, SI-3.2, SI-3.3** + +### Property 2: Session summary formatting includes all agent info after standard fields + +*For any* `SessionSummary` with a non-empty `AgentInfo` slice, `FormatSessionSummary` SHALL produce output where: (a) every `KeyValue.Key` and `KeyValue.Value` appears in the output, (b) all agent info lines appear after the "Enabled agents" line, and (c) when `AgentInfo` is nil or empty, no extra lines appear beyond the standard five fields. + +**Validates: Requirements SI-2.3, SI-2.4, SI-7.2, SI-7.3, SI-7.4** + +### Property 3: Vibe Kanban URL format + +*For any* valid TCP port number (1–65535), the Vibe Kanban `SummaryInfo` URL value SHALL be exactly `"http://localhost:"` where `` is the decimal string representation of the port number. + +**Validates: Requirements SI-5.2** + +--- + +## Error Handling + +| Scenario | Behaviour | +|---|---| +| Agent's `SummaryInfo()` returns `(nil, error)` | Warning printed to stderr: `"warning: summary info: \n"`. No KeyValue pairs from that agent. Startup continues. | +| Agent's `SummaryInfo()` returns `(nil, nil)` | No lines added. No warning. | +| Agent's `SummaryInfo()` returns `([]KeyValue{}, nil)` | Same as nil — no lines added. | +| Context cancelled during `SummaryInfo()` | Agent returns `ctx.Err()`. Core prints warning, continues with remaining agents. | +| Vibe Kanban port discovery times out (30s) | Returns error. Core prints warning. Session summary omits Vibe Kanban URL. Startup succeeds. | + +--- + +## Testing Strategy + +### Property-Based Tests (using `pgregory.net/rapid`) + +| Property | What to generate | What to assert | +|---|---|---| +| Property 1: Collection order | Random slices of `([]KeyValue, error)` tuples | Collected output matches expected filtered/ordered result | +| Property 2: Formatting | Random `SessionSummary` with random `AgentInfo` | All keys/values present, after "Enabled agents", no extras when empty | +| Property 3: URL format | Random port in 1–65535 | URL matches `"http://localhost:"` exactly | + +Each property test runs minimum 100 iterations. Tag format: +```go +// Feature: agent-summary-info, Property 1: Collection preserves order and excludes errors +``` + +### Unit Tests (example-based) + +| Test | What it verifies | +|---|---| +| `TestFormatSessionSummaryNoAgentInfo` | Output matches current format when `AgentInfo` is nil | +| `TestFormatSessionSummaryWithAgentInfo` | Output includes agent lines after "Enabled agents" | +| `TestCollectSummaryInfoSkipsErrors` | Warning printed, erroring agent excluded, others included | +| `TestClaudeSummaryInfoReturnsNil` | `(nil, nil)` returned | +| `TestAugmentSummaryInfoReturnsNil` | `(nil, nil)` returned | +| `TestBuildResourcesSummaryInfoReturnsNil` | `(nil, nil)` returned | + +### Integration Tests + +| Test | What it verifies | +|---|---| +| `TestVibeKanbanSummaryInfoDiscoversPort` | With a running container, `SummaryInfo()` returns the correct URL | +| `TestSessionSummaryContainsVibeKanbanURL` | Full start flow prints Vibe Kanban URL via the generic mechanism | + +### What is NOT tested with PBT + +- The actual port discovery logic (requires a running container with `ss` — integration test territory) +- The timeout/retry behaviour (time-dependent, tested with unit tests using short timeouts) +- Structural requirements (interface method exists, field removed) — enforced by the Go compiler diff --git a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md index d3022ec..135ed36 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md +++ b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md @@ -69,14 +69,19 @@ The supervisor script (`/usr/local/bin/vibe-kanban-supervisor.sh`) implements cr ### Port Discovery -After the container starts and the health check passes, the CLI discovers the Vibe Kanban port by: +Vibe Kanban auto-assigns its port at startup (VK-9.1). The supervisor script discovers the port and writes it to a well-known file: -1. Executing `ss -tlnp` inside the container to list listening TCP sockets -2. Grepping for the `vibe-kanban` process -3. Parsing the port number from the output -4. Retrying up to 30 seconds (the server may take time to bind) +1. The supervisor starts vibe-kanban in the background and captures its PID +2. It polls `ss -tlnp` filtered by the exact PID (`grep "pid=$VK_PID,"`) to find the bound port +3. Once found, it writes the port number to `/tmp/vibe-kanban.port` +4. `SummaryInfo()` reads this file (retrying up to 30 seconds) -This is done via `docker.ExecInContainer()` with a shell pipeline. +This approach is robust because: +- It uses PID-based filtering (unambiguous, no process name dependency) +- It works regardless of how many other services bind ports in the container +- It avoids conflicts when multiple containers share the host network namespace (each gets a unique auto-assigned port) + +See [design-agent-summary-info.md](design-agent-summary-info.md) for the `SummaryInfo()` implementation details. ### DockerfileBuilder Extension @@ -568,9 +573,9 @@ import ( **Why:** The entrypoint runs as root (Docker default). The supervisor uses `su -c "vibe-kanban" "$USERNAME"` to drop privileges. This is simpler than sudo (no sudoers parsing) and works reliably in the container environment. -### 4. Port discovery via `ss -tlnp` (not reading a config file) +### 4. Port discovery via port file (not `ss` process name matching) -**Why:** Vibe Kanban auto-assigns its port at startup. There's no config file to read. The `ss` command (part of iproute2, installed in Ubuntu by default) shows which port the process is actually listening on. This is the most reliable method since it reflects runtime state. +**Why:** Vibe Kanban auto-assigns its port at startup. The supervisor discovers the port using PID-based `ss` filtering and writes it to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method reads this file. This is more reliable than parsing `ss` output in `SummaryInfo()` because: (a) the Rust binary name in `ss` output varies by platform/version, (b) other services may bind ports in the container, and (c) PID-based filtering in the supervisor is unambiguous since it knows the exact child PID. ### 5. 30-second timeout for port discovery @@ -580,9 +585,9 @@ import ( **Why (VK-8.4):** If port discovery times out, the session summary simply omits the Vibe Kanban URL line. The user can still SSH into the container and discover the port manually. This prevents a flaky network or slow startup from blocking the entire workflow. -### 7. `--host 0.0.0.0` flag for vibe-kanban +### 7. `HOST=0.0.0.0` environment variable for vibe-kanban -**Why:** By default, Node.js servers may bind to `127.0.0.1` inside the container. With host network mode, this is fine (the host IS the container's network namespace). But to be safe and explicit, we pass `--host 0.0.0.0` so the server accepts connections on all interfaces. +**Why:** The vibe-kanban Rust binary reads the `HOST` environment variable to determine its listen address (defaults to `127.0.0.1`). Setting `HOST=0.0.0.0` ensures the server accepts connections on all interfaces, which is required for host network mode accessibility. The `BROWSER=none` variable is also set to suppress the automatic browser-open attempt in the headless container environment. ### 8. Core changes are minimal and generic diff --git a/.kiro/specs/bootstrap-ai-coding/design.md b/.kiro/specs/bootstrap-ai-coding/design.md index 7574790..ac4f8a8 100644 --- a/.kiro/specs/bootstrap-ai-coding/design.md +++ b/.kiro/specs/bootstrap-ai-coding/design.md @@ -31,6 +31,7 @@ The design is split across multiple focused files: | [`design-agents.md`](design-agents.md) | Agent modules: contract, Claude Code implementation, adding future agents | | [`design-vibekanban.md`](design-vibekanban.md) | Vibe Kanban agent module: auto-start mechanism, crash recovery, port discovery | | [`design-properties.md`](design-properties.md) | Correctness properties (Properties 1–51) and full testing strategy | +| [`design-agent-summary-info.md`](design-agent-summary-info.md) | Agent Summary Info: KeyValue type, SummaryInfo interface method, generic collection, Vibe Kanban port discovery migration | ## Related Documents @@ -38,3 +39,4 @@ The design is split across multiple focused files: - `requirements-agents.md` — agent module requirements (CC-1–CC-8 for Claude Code, AC-1–AC-6 for Augment Code, BR-1–BR-6 for Build Resources, VK-1–VK-8 for Vibe Kanban) - `requirements-cli-combinations.md` — valid and invalid CLI flag combinations (CLI-1–CLI-6) - `requirements-two-layer-image.md` — two-layer Docker image requirements (TL-1–TL-11) +- `requirements-agent-summary-info.md` — Agent Summary Info requirements (SI-1–SI-7) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md b/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md new file mode 100644 index 0000000..98f68d6 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md @@ -0,0 +1,104 @@ +# Agent Summary Info Requirements + +## Overview + +Agent Summary Info is a mechanism that allows agent modules to contribute their own key:value pairs to the session summary printed by the core after a successful container start. This eliminates the need for the core to contain agent-specific logic (such as port discovery for Vibe Kanban) and restores the architectural rule that "core has zero knowledge of agents." + +Each agent module implements a `SummaryInfo` method on the Agent_Interface. The core iterates over enabled agents, calls `SummaryInfo()` on each, and appends any returned key:value pairs to the session summary output. Agents that have nothing to report return nil. + +## Glossary + +- **Summary_Info**: A slice of key:value pairs returned by an agent's `SummaryInfo()` method, representing additional labelled lines to include in the session summary. +- **KeyValue**: A struct with `Key string` and `Value string` fields, representing a single labelled line in the session summary (e.g. Key=`"Vibe Kanban"`, Value=`"http://localhost:3000"`). + +--- + +## Requirements + +### Requirement SI-1: Agent_Interface Extension + +**User Story:** As a platform maintainer, I want the Agent_Interface to include a `SummaryInfo` method so that agents can contribute information to the session summary without the core needing agent-specific logic. + +#### Acceptance Criteria + +1. THE Agent_Interface SHALL include a `SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]KeyValue, error)` method. +2. THE `KeyValue` struct SHALL be defined in the `agent` package (`internal/agent/agent.go`) with two exported fields: `Key string` and `Value string`. +3. THE `SummaryInfo` method SHALL receive the same `context.Context`, `*docker.Client`, and `containerID string` parameters as `HealthCheck`, enabling agents to inspect the running container to gather information. +4. THE `SummaryInfo` method SHALL return a slice of `KeyValue` pairs and an error. A nil or empty slice indicates the agent has no information to contribute. + +--- + +### Requirement SI-2: Core Iteration and Collection + +**User Story:** As a developer, I want the core to automatically collect summary information from all enabled agents so that the session summary is always complete without hardcoded agent logic. + +#### Acceptance Criteria + +1. WHEN a Container is successfully started (or is already running), THE core SHALL call `SummaryInfo()` on each Enabled_Agent after health checks pass and before printing the session summary. +2. THE core SHALL iterate over Enabled_Agents in their declared order and collect all returned `KeyValue` pairs into a single ordered slice. +3. THE core SHALL append the collected `KeyValue` pairs to the session summary output after the standard fields (Data directory, Project directory, SSH port, SSH connect, Enabled agents). +4. THE core SHALL format each `KeyValue` pair as a labelled line with consistent alignment matching the existing session summary fields (left-aligned key followed by colon, padded with spaces to align values). + +--- + +### Requirement SI-3: Graceful Error Handling + +**User Story:** As a developer, I want the session to start successfully even if an agent's summary info gathering fails, so that a non-critical failure does not block my workflow. + +#### Acceptance Criteria + +1. IF an agent's `SummaryInfo()` returns a non-nil error, THEN THE core SHALL print a warning to stderr in the format `"warning: summary info: \n"` and continue processing remaining agents. +2. IF an agent's `SummaryInfo()` returns a non-nil error, THEN THE core SHALL NOT include any `KeyValue` pairs from that agent in the session summary. +3. IF an agent's `SummaryInfo()` returns a nil or empty slice with a nil error, THEN THE core SHALL NOT add any lines to the session summary for that agent. +4. THE overall startup process SHALL NOT fail due to a `SummaryInfo()` error from any agent. + +--- + +### Requirement SI-4: Remove Hardcoded Vibe Kanban Logic from Core + +**User Story:** As a platform maintainer, I want all Vibe Kanban–specific logic removed from the core so that the architectural rule "core has zero knowledge of agents" is restored. + +#### Acceptance Criteria + +1. THE `VibeKanbanURL` field SHALL be removed from the `SessionSummary` struct in `internal/cmd/root.go`. +2. THE `discoverVibeKanbanPort()` function SHALL be removed from `internal/cmd/root.go`. +3. THE `constants.VibeKanbanAgentName` reference SHALL be removed from `internal/cmd/root.go` — the core SHALL NOT reference any agent by name or identifier. +4. THE `FormatSessionSummary` function SHALL NOT contain any conditional logic specific to Vibe Kanban or any other individual agent. + +--- + +### Requirement SI-5: Vibe Kanban SummaryInfo Implementation + +**User Story:** As a developer, I want the Vibe Kanban agent to report its URL via the `SummaryInfo` mechanism so that the session summary still shows the Vibe Kanban URL without the core containing Vibe Kanban–specific code. + +#### Acceptance Criteria + +1. THE Vibe Kanban module SHALL implement `SummaryInfo()` by reading the port file (`/tmp/vibe-kanban.port`) written by the supervisor script after vibe-kanban starts and binds its auto-assigned port. +2. WHEN the Vibe Kanban port is successfully discovered, THE `SummaryInfo()` method SHALL return a single `KeyValue` with Key `"Vibe Kanban"` and Value `"http://localhost:"`. +3. IF the Vibe Kanban port file cannot be read within 30 seconds (retrying every 2 seconds), THEN THE `SummaryInfo()` method SHALL return a non-nil error describing the timeout. +4. THE port discovery logic SHALL reside entirely in the Vibe Kanban agent package (`internal/agents/vibekanban/`) — the core SHALL NOT contain any port discovery code. + +--- + +### Requirement SI-6: Other Agents Return Nil + +**User Story:** As a platform maintainer, I want agents that have no summary information to return nil from `SummaryInfo()` so that the core handles them uniformly without special cases. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL implement `SummaryInfo()` by returning `(nil, nil)`. +2. THE Augment Code module SHALL implement `SummaryInfo()` by returning `(nil, nil)`. +3. THE Build Resources module SHALL implement `SummaryInfo()` by returning `(nil, nil)`. + +--- + +### Requirement SI-7: Session Summary Formatting with Agent Info + +**User Story:** As a developer, I want agent-contributed information to appear in the session summary with the same formatting as built-in fields so that the output is consistent and easy to read. + +#### Acceptance Criteria + +1. THE `SessionSummary` struct SHALL include an `AgentInfo []KeyValue` field (using the `KeyValue` type from the `agent` package, or an equivalent type in the `cmd` package) to hold agent-contributed key:value pairs. +2. THE `FormatSessionSummary` function SHALL format each entry in `AgentInfo` as `":\n"` where `` is sufficient whitespace to align the value column with the existing session summary fields. +3. THE agent-contributed lines SHALL appear after the "Enabled agents" line and before any trailing newline. +4. WHEN `AgentInfo` is nil or empty, THE `FormatSessionSummary` function SHALL produce output identical to the current format (no extra blank lines or trailing content). diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md index 961f8a5..04e20cd 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md @@ -430,7 +430,37 @@ The container uses host network mode (Req 26) by default, so Vibe Kanban's auto- #### Acceptance Criteria 1. WHEN the container is running in host network mode (Req 26, default), THE Vibe Kanban server SHALL be accessible from the host browser at `http://localhost:`, where the server responds with an HTTP 2xx status to a GET request on that URL. -2. WHEN the container is successfully started and the Vibe Kanban health check (VK-5) passes, THE CLI SHALL discover the Vibe_Kanban_Port by inspecting the running Vibe Kanban process's listening port inside the Container, waiting up to 30 seconds for the port to become available. -3. THE session summary (Requirement 17 in requirements-core.md) SHALL include a labelled line "Vibe Kanban:" followed by the full URL `http://localhost:` so the user knows how to access it. -4. IF the CLI cannot discover the Vibe_Kanban_Port within the 30-second timeout (e.g. the process started but has not bound a port), THEN THE CLI SHALL print a warning message to stdout indicating that the Vibe Kanban URL could not be determined, and SHALL omit the Vibe Kanban URL from the session summary without failing the overall startup. +2. WHEN the container is successfully started and the Vibe Kanban health check (VK-5) passes, THE Vibe Kanban module SHALL discover the Vibe_Kanban_Port via its `SummaryInfo()` method (see SI-5 in requirements-agent-summary-info.md) by inspecting the running Vibe Kanban process's listening port inside the Container, waiting up to 30 seconds for the port to become available. +3. THE session summary (Requirement 17 in requirements-core.md) SHALL include a labelled line "Vibe Kanban:" followed by the full URL `http://localhost:` so the user knows how to access it. This is delivered via the generic Agent Summary Info mechanism (SI-2, SI-7). +4. IF the Vibe Kanban module cannot discover the Vibe_Kanban_Port within the 30-second timeout (e.g. the process started but has not bound a port), THEN THE core SHALL print a warning message to stderr (per SI-3) and SHALL omit the Vibe Kanban URL from the session summary without failing the overall startup. 5. WHEN `--host-network-off` is set (bridge mode), THE Vibe Kanban server SHALL NOT be accessible from the host without additional port forwarding — this is a known limitation of bridge mode for non-SSH services. + + +--- + +### Requirement VK-9: Port Assignment and Discovery + +**User Story:** As a developer running multiple bac containers simultaneously in host network mode, I need each container's Vibe Kanban instance to use a unique port so they don't conflict with each other. + +#### Acceptance Criteria + +1. THE Vibe Kanban server SHALL use auto-assigned port selection (port 0 / OS-assigned) at startup, allowing the operating system to choose a free port. THE supervisor script SHALL NOT hardcode or fix the port number. +2. BECAUSE containers in host network mode (Req 26, default) share the host's network namespace, a fixed port would cause bind failures when multiple bac containers run simultaneously. Auto-assignment ensures each instance gets a unique port. +3. THE `SummaryInfo()` method SHALL discover the auto-assigned port by reading a well-known port file (`/tmp/vibe-kanban.port`) written by the supervisor script. The supervisor discovers the port by polling `ss -tlnp` filtered by the vibe-kanban process PID, then writes the port number to the file. This approach is deterministic regardless of how many services listen in the container. +4. THE port discovery logic SHALL NOT rely on the process name appearing in `ss -tlnp` output, because the Rust binary downloaded by the npm wrapper may report under a different name depending on the platform and version. The supervisor uses PID-based filtering (`grep "pid=$VK_PID,"`) which is unambiguous. +5. THE container image SHALL include `iproute2` (provides `ss`) and `procps` (provides `pgrep`, `ps`) as runtime dependencies installed by the Vibe Kanban module's `Install()` method, to support port discovery and health checks. +6. THE supervisor script SHALL set `BROWSER=none` in the environment when launching vibe-kanban, to suppress the automatic browser-open attempt in the headless container environment. +7. THE Vibe Kanban module's `Install()` method SHALL pre-download the platform-specific binary during image build by running `vibe-kanban` with a timeout, so that the binary is cached in the container and does not require internet access at runtime. + +#### Design Notes + +- The vibe-kanban npm package (`npx vibe-kanban`) is a CLI wrapper that downloads a platform-specific Rust binary on first run and caches it in `~/.vibe-kanban/bin/`. The pre-download step during image build ensures the binary is available without network access at container start. +- The `HOST=0.0.0.0` environment variable is set so the server binds to all interfaces, making it accessible from the host in host network mode. +- In bridge mode (`--host-network-off`), port conflicts are not an issue since each container has its own network namespace, but the port is still auto-assigned for consistency. + + +--- + +## Agent Summary Info + +See **[requirements-agent-summary-info.md](./requirements-agent-summary-info.md)** — Agent Summary Info mechanism: generic key:value pairs in session summary via Agent interface extension. Requirements SI-1–SI-7. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index ed0522f..6e34791 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -26,7 +26,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana - **Conflicting_Image_User**: An existing user in the Base_Container_Image whose UID or GID matches the Host_User's UID or GID. If present, must be resolved before the Container_Image can be built (see Requirement 10a). - **Container_Image**: The Docker image built on top of the Base_Container_Image that includes the SSH server, the Container_User setup, and all Enabled_Agent installations. - **Agent**: An AI coding assistant module that conforms to the Agent_Interface and can be installed, authenticated, and invoked inside the Container. -- **Agent_Interface**: The contract defined by the core that every Agent module must satisfy. It covers: unique identification, Dockerfile installation steps, credential store location on the Host, credential mount path inside the Container, credential presence check, and a readiness health check. +- **Agent_Interface**: The contract defined by the core that every Agent module must satisfy. It covers: unique identification, Dockerfile installation steps, credential store location on the Host, credential mount path inside the Container, credential presence check, a readiness health check, and a summary info method that returns key:value pairs for the session summary. - **Agent_Registry**: The core component that holds all registered Agent modules and resolves them by ID at runtime. - **Enabled_Agents**: The set of Agent modules selected by the user for a given Container, specified via CLI flag or tool configuration. - **Credential_Store**: The directory on the Host where an Agent's authentication tokens are persisted between Sessions. Each Agent module declares its own default Credential_Store path via the Agent_Interface. @@ -138,7 +138,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana #### Acceptance Criteria -1. THE core SHALL define an Agent_Interface that every Agent module must implement. The interface SHALL cover at minimum: a unique string identifier, Dockerfile installation steps, the default Credential_Store path on the Host, the mount path inside the Container, a credential presence check, and a readiness health check. +1. THE core SHALL define an Agent_Interface that every Agent module must implement. The interface SHALL cover at minimum: a unique string identifier, Dockerfile installation steps, the default Credential_Store path on the Host, the mount path inside the Container, a credential presence check, a readiness health check, and a summary info method that returns key:value pairs for the session summary. 2. THE Agent_Registry SHALL allow Agent modules to register themselves without requiring changes to core system code. Adding a new Agent SHALL require only a new module that implements the Agent_Interface and registers itself. 3. IF an Agent identifier supplied by the user is not found in the Agent_Registry, THEN THE CLI SHALL print a descriptive error message to stderr listing the unknown identifier and exit with a non-zero exit code. 4. THE CLI SHALL accept an `--agents` flag whose value is a comma-separated list of Agent identifiers specifying the Enabled_Agents for the Container. @@ -303,6 +303,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana - **SSH port**: the SSH_Port - **SSH connect**: the SSH alias command (e.g. `ssh bac-`), which relies on the SSH_Config_Entry maintained by Requirement 19 - **Enabled agents**: the list of Enabled_Agent identifiers + - **Agent-contributed fields**: any key:value pairs returned by Enabled_Agents via their `SummaryInfo()` method (see SI-2 in requirements-agents.md) 3. THE session summary SHALL be printed as plain text to stdout, with one field per line. 4. THE session summary SHALL be printed after all startup checks pass and the Container is confirmed ready. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements.md b/.kiro/specs/bootstrap-ai-coding/requirements.md index a05f522..c240fa5 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -5,3 +5,4 @@ The requirements for this project are split across three documents: - **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–21. - **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code and Augment Code, plus the template for future agents. Requirements CC-1–CC-6 and AC-1–AC-6. - **[requirements-cli-combinations.md](./requirements-cli-combinations.md)** — Formal rules for valid and invalid CLI flag combinations. Requirements CLI-1–CLI-6. +- **[requirements-agent-summary-info.md](./requirements-agent-summary-info.md)** — Agent Summary Info: generic key:value pairs in session summary via Agent interface extension. Requirements SI-1–SI-7. diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md index eeb2d10..96d2dca 100644 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -1,150 +1,103 @@ -# Implementation Plan: Vibe Kanban Agent Module +# Implementation Plan: Agent Summary Info ## Overview -Implement the Vibe Kanban agent module (`internal/agents/vibekanban/`) — a web-based project management tool that runs as a background service inside the container. The implementation adds the agent constant, extends the DockerfileBuilder with an `Entrypoint()` method, adds `ExecInContainerWithOutput()` to the runner, creates the agent module with auto-start via ENTRYPOINT wrapper + supervisor script, extends the session summary with the Vibe Kanban URL, and integrates port discovery into `runStart()`. +Refactor the session summary mechanism so that agent modules contribute their own key:value pairs via a generic `SummaryInfo` method on the `Agent` interface. This removes all Vibe Kanban–specific logic from the core (`internal/cmd/root.go`), restoring the architectural rule that "core has zero knowledge of agents." ## Tasks -- [ ] 1. Add Vibe Kanban constant and update DefaultAgents - - [ ] 1.1 Add `VibeKanbanAgentName` constant and update `DefaultAgents` in `internal/constants/constants.go` - - Add `VibeKanbanAgentName = "vibe-kanban"` constant with comment referencing VK-1 - - Update `DefaultAgents` to append `"," + VibeKanbanAgentName` to the existing value - - _Requirements: VK-1.1, VK-7.1_ - -- [ ] 2. Extend DockerfileBuilder with Entrypoint method - - [ ] 2.1 Add `Entrypoint()` method to `DockerfileBuilder` in `internal/docker/builder.go` - - Implement `Entrypoint(args ...string)` that appends an `ENTRYPOINT [...]` instruction in exec form - - Each arg is quoted with `fmt.Sprintf("%q", a)` and joined with `", "` - - _Requirements: VK-3.1_ - - - [ ]* 2.2 Write unit tests for `Entrypoint()` method in `internal/docker/builder_test.go` - - Test single-arg entrypoint produces correct exec-form instruction - - Test multi-arg entrypoint produces correct exec-form instruction - - _Requirements: VK-3.1_ - -- [ ] 3. Add ExecInContainerWithOutput helper to runner - - [ ] 3.1 Add `ExecInContainerWithOutput()` function to `internal/docker/runner.go` - - Implement function that runs a command inside a container and returns `(exitCode int, stdout string, err error)` - - Use `ContainerExecCreate` with `AttachStdout: true, AttachStderr: true` - - Use `ContainerExecAttach` and `stdcopy.StdCopy` to separate stdout from stderr - - Use `ContainerExecInspect` to get the exit code - - Add necessary imports: `"github.com/docker/docker/pkg/stdcopy"` - - _Requirements: VK-8.2_ - -- [ ] 4. Checkpoint - Ensure all tests pass - - Ensure all tests pass, ask the user if questions arise. - -- [ ] 5. Create the Vibe Kanban agent module - - [ ] 5.1 Create `internal/agents/vibekanban/vibekanban.go` with full agent implementation - - Implement `vibeKanbanAgent` struct with `init()` calling `agent.Register()` - - Implement `ID()` returning `constants.VibeKanbanAgentName` - - Implement `Install(b *docker.DockerfileBuilder)`: - - Conditional Node.js installation (check `b.IsNodeInstalled()`, install Node.js 22 if not, call `b.MarkNodeInstalled()`) - - `npm install -g --no-fund --no-audit vibe-kanban` - - Generate supervisor script with crash recovery (MAX_RESTARTS=5, WINDOW_SECONDS=60, DELAY_SECONDS=5), replacing `__USERNAME__` with `b.Username()` - - Generate entrypoint wrapper script (`bac-entrypoint.sh`) that starts supervisor in background then `exec "$@"` - - Call `b.Entrypoint("/usr/local/bin/bac-entrypoint.sh")` - - Implement `CredentialStorePath()` returning `""` - - Implement `ContainerMountPath(homeDir string)` returning `""` - - Implement `HasCredentials(storePath string)` returning `(true, nil)` - - Implement `HealthCheck(ctx, c, containerID)`: - - Check 1: `vibe-kanban --version` exits 0 (binary presence) - - Check 2: `pgrep -f vibe-kanban` with up to 5 retries at 2-second intervals (process running) - - _Requirements: VK-1.1, VK-1.3, VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-3.2, VK-3.5, VK-3.6, VK-4.1, VK-4.2, VK-4.3, VK-5.1, VK-5.2_ - - - [ ] 5.2 Add blank import in `main.go` for the vibekanban package - - Add `_ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban"` to the import block - - _Requirements: VK-1.3, VK-6.2_ - -- [ ] 6. Extend SessionSummary and integrate port discovery - - [ ] 6.1 Add `VibeKanbanURL` field to `SessionSummary` struct and update `FormatSessionSummary` in `internal/cmd/root.go` - - Add `VibeKanbanURL string` field to `SessionSummary` - - Update `FormatSessionSummary` to use `strings.Builder` and conditionally include "Vibe Kanban:" line when `VibeKanbanURL` is non-empty - - _Requirements: VK-8.3, VK-8.4_ - - - [ ] 6.2 Add `discoverVibeKanbanPort()` function and integrate into `runStart()` in `internal/cmd/root.go` - - Implement `discoverVibeKanbanPort(ctx, c, containerID)` that: - - Executes `ss -tlnp` inside the container via `dockerpkg.ExecInContainerWithOutput` - - Greps for `vibe-kanban` and parses the port number - - Retries for up to 30 seconds with 2-second intervals - - Returns `(port int, err error)` - - In `runStart()`, after SSH health check passes, check if `vibe-kanban` is in enabled agents - - If enabled, call `discoverVibeKanbanPort()` and set `vibeKanbanURL` - - On failure, print warning to stderr and continue (graceful degradation) - - Pass `VibeKanbanURL` to `printSessionSummary` / `SessionSummary` - - Update `printSessionSummary` to accept and pass through the URL - - _Requirements: VK-8.2, VK-8.3, VK-8.4_ - -- [ ] 7. Checkpoint - Ensure all tests pass - - Ensure all tests pass, ask the user if questions arise. - -- [ ] 8. Write unit tests for the Vibe Kanban agent module - - [ ] 8.1 Create `internal/agents/vibekanban/vibekanban_test.go` with unit tests - - `TestID` — returns `constants.VibeKanbanAgentName` - - `TestInstallNodeAlreadyInstalled` — skips Node.js when `IsNodeInstalled()` is true - - `TestInstallNodeNotInstalled` — installs Node.js when `IsNodeInstalled()` is false - - `TestInstallContainsNpmPackage` — output contains `npm install -g` with `vibe-kanban` - - `TestInstallContainsEntrypoint` — output contains ENTRYPOINT instruction - - `TestInstallContainsSupervisor` — output contains supervisor script with crash recovery params - - `TestInstallDoesNotContainCMD` — output does NOT contain CMD instruction - - `TestInstallNoRustNoPnpm` — output does NOT contain rust/pnpm references - - `TestCredentialStorePath` — returns empty string - - `TestContainerMountPath` — returns empty string for various homeDir values - - `TestHasCredentials` — returns `(true, nil)` - - `TestHealthCheckBinaryFailure` — error message identifies binary check - - `TestHealthCheckProcessFailure` — error message identifies process check - - _Requirements: VK-1.1, VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-4.1, VK-4.2, VK-4.3, VK-5.1, VK-5.2_ - - - [ ]* 8.2 Write unit tests for `FormatSessionSummary` with Vibe Kanban URL in `internal/cmd/root_test.go` - - `TestFormatSessionSummaryWithVibeKanban` — URL line present when VibeKanbanURL is set - - `TestFormatSessionSummaryWithoutVibeKanban` — URL line absent when VibeKanbanURL is empty - - _Requirements: VK-8.3, VK-8.4_ - -- [ ] 9. Write property-based tests for the Vibe Kanban agent module - - [ ]* 9.1 Write property test: Node.js conditional installation invariant - - **Property 1: Node.js conditional installation invariant** - - For any DockerfileBuilder state, calling Install() results in at most one Node.js installation block and `IsNodeInstalled()` returns true after - - Use `rapid.Bool()` to draw whether Node.js is pre-installed - - **Validates: Requirements VK-2.1** - - - [ ]* 9.2 Write property test: Install does not emit CMD - - **Property 2: Install does not emit CMD** - - For any DockerfileBuilder state, calling Install() does NOT append any line starting with `CMD` - - Use `rapid.Bool()` to draw whether Node.js is pre-installed - - **Validates: Requirements VK-3.1** - - - [ ]* 9.3 Write property test: No-credential-store invariant - - **Property 3: No-credential-store invariant** - - For any string homeDir, `ContainerMountPath(homeDir)` returns empty; for any storePath, `HasCredentials(storePath)` returns `(true, nil)` - - Use `rapid.String()` to draw arbitrary homeDir and storePath values - - **Validates: Requirements VK-4.2, VK-4.3** - - - [ ]* 9.4 Write property test: Session summary includes Vibe Kanban URL for any valid port - - **Property 4: Session summary includes Vibe Kanban URL for any valid port** - - For any valid TCP port (1-65535), `FormatSessionSummary()` with `VibeKanbanURL` set includes the URL; when empty, output does NOT contain "Vibe Kanban:" - - Use `rapid.IntRange(1, 65535)` to draw port numbers - - **Validates: Requirements VK-8.3** - - - [ ]* 9.5 Write property test: Supervisor script contains correct backoff parameters - - **Property 5: Supervisor script contains correct backoff parameters** - - For any valid Linux username, the supervisor script generated by Install() contains `MAX_RESTARTS=5`, `WINDOW_SECONDS=60`, and `DELAY_SECONDS=5` - - Use `rapid.StringMatching(`[a-z_][a-z0-9_-]*`)` to draw usernames - - **Validates: Requirements VK-3.5** - -- [ ] 10. Write integration tests for the Vibe Kanban agent module - - [ ]* 10.1 Create `internal/agents/vibekanban/integration_test.go` with integration tests - - Gated by `//go:build integration` - - Include `TestMain` with consent gate and base image removal - - `TestVibeKanbanInstallsAndRuns` — full image build, binary present (`which vibe-kanban` exits 0), process running - - `TestVibeKanbanHealthCheck` — HealthCheck passes on a live container - - `TestVibeKanbanPortDiscovery` — port is discoverable via ss after startup - - `TestVibeKanbanCrashRecovery` — process restarts after being killed (kill + wait + verify running) - - `TestVibeKanbanAccessibleFromHost` — HTTP GET to localhost:port returns 2xx (host network mode) - - _Requirements: VK-2.3, VK-3.1, VK-3.3, VK-3.5, VK-5.1, VK-5.2, VK-8.1, VK-8.2_ - -- [ ] 11. Final checkpoint - Ensure all tests pass - - Ensure all tests pass, ask the user if questions arise. +- [x] 1. Extend the Agent interface and add KeyValue type + - [x] 1.1 Add `KeyValue` struct and `SummaryInfo` method to the Agent interface + - Add `KeyValue` struct with `Key string` and `Value string` fields to `internal/agent/agent.go` + - Add `SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]KeyValue, error)` to the `Agent` interface + - _Requirements: SI-1.1, SI-1.2, SI-1.3, SI-1.4_ + +- [x] 2. Implement no-op SummaryInfo in existing agents + - [x] 2.1 Implement `SummaryInfo` returning `(nil, nil)` in the Claude Code agent + - Add `SummaryInfo` method to `claudeAgent` in `internal/agents/claude/claude.go` + - Add necessary imports (`context`, `agent` package reference for `KeyValue`) + - _Requirements: SI-6.1_ + + - [x] 2.2 Implement `SummaryInfo` returning `(nil, nil)` in the Augment Code agent + - Add `SummaryInfo` method to `augmentAgent` in `internal/agents/augment/augment.go` + - _Requirements: SI-6.2_ + + - [x] 2.3 Implement `SummaryInfo` returning `(nil, nil)` in the Build Resources agent + - Add `SummaryInfo` method to `buildResourcesAgent` in `internal/agents/buildresources/buildresources.go` + - _Requirements: SI-6.3_ + +- [x] 3. Implement SummaryInfo in the Vibe Kanban agent + - [x] 3.1 Move port discovery logic into `vibekanban.SummaryInfo()` + - Add `portRegexp` variable to `internal/agents/vibekanban/vibekanban.go` + - Implement `SummaryInfo` method with the same retry logic (30s deadline, 2s intervals, `ss -tlnp` parsing) currently in `discoverVibeKanbanPort()` + - Return `[]agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:"}}` on success + - Return error on timeout + - Add `"regexp"` and `"strconv"` imports to `vibekanban.go` + - _Requirements: SI-5.1, SI-5.2, SI-5.3, SI-5.4_ + +- [x] 4. Checkpoint - Verify compilation + - Ensure all tests pass (`go build ./...`), ask the user if questions arise. + +- [x] 5. Update SessionSummary and FormatSessionSummary + - [x] 5.1 Replace `VibeKanbanURL` with `AgentInfo` in `SessionSummary` and update `FormatSessionSummary` + - Remove `VibeKanbanURL string` field from `SessionSummary` struct in `internal/cmd/root.go` + - Add `AgentInfo []agent.KeyValue` field to `SessionSummary` struct + - Replace the Vibe Kanban conditional in `FormatSessionSummary` with a generic loop: `fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value)` for each entry in `AgentInfo` + - _Requirements: SI-4.1, SI-4.4, SI-7.1, SI-7.2, SI-7.3, SI-7.4_ + +- [x] 6. Update core collection logic and remove VK-specific code from root.go + - [x] 6.1 Add generic agent info collection loop and update `printSessionSummary` + - Add a collection loop in `runStart()` (both reconnect and fresh-start paths) that iterates over `enabledAgents`, calls `SummaryInfo()`, collects `[]agent.KeyValue`, and prints warnings on error + - Update `printSessionSummary` signature: replace `vibeKanbanURL string` parameter with `agentInfo []agent.KeyValue` + - Pass collected `agentInfo` to `printSessionSummary` and store in `SessionSummary.AgentInfo` + - _Requirements: SI-2.1, SI-2.2, SI-2.3, SI-2.4, SI-3.1, SI-3.2, SI-3.3, SI-3.4_ + + - [x] 6.2 Remove all Vibe Kanban–specific code from root.go + - Delete `discoverVibeKanbanPort()` function + - Delete `portRegexp` package-level variable + - Remove `constants.VibeKanbanAgentName` reference and the two VK discovery blocks (reconnect path + fresh start path) + - Remove unused imports (`"regexp"`, `"strconv"`) from `root.go` + - _Requirements: SI-4.2, SI-4.3_ + +- [x] 7. Checkpoint - Verify compilation and existing tests + - Ensure all tests pass (`go test ./...`), ask the user if questions arise. + +- [x] 8. Update existing tests + - [x] 8.1 Update `root_test.go` to use `AgentInfo` instead of `VibeKanbanURL` + - Update `TestFormatSessionSummaryWithVibeKanban` to use `AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:3000"}}` + - Update `TestFormatSessionSummaryWithoutVibeKanban` to use `AgentInfo: nil` + - Update `TestPropertySessionSummaryIncludesVibeKanbanURL` to use `AgentInfo` field and assert generic formatting behaviour + - Import `agent` package in `root_test.go` + - _Requirements: SI-7.2, SI-7.3, SI-7.4_ + + - [x] 8.2 Add unit tests for no-op `SummaryInfo` in agent test files + - Add `TestSummaryInfoReturnsNil` to `internal/agents/claude/claude_test.go` + - Add `TestSummaryInfoReturnsNil` to `internal/agents/augment/augment_test.go` + - Add `TestSummaryInfoReturnsNil` to `internal/agents/buildresources/buildresources_test.go` + - _Requirements: SI-6.1, SI-6.2, SI-6.3_ + +- [x] 9. Write property tests for collection logic and formatting + - [x] 9.1 Write property test: Collection preserves order and excludes errors + - **Property 1: Collection preserves order and excludes errors** + - **Validates: Requirements SI-2.2, SI-3.2, SI-3.3** + - Create a helper function `CollectAgentInfo` (exported for testability) that takes a slice of `([]KeyValue, error)` results and returns the collected `[]KeyValue` + - Write `TestPropertyCollectionPreservesOrderAndExcludesErrors` in `internal/cmd/root_test.go` using `rapid` + - Generate random slices of `([]KeyValue, error)` tuples; assert collected output matches expected filtered/ordered result + + - [x] 9.2 Write property test: Session summary formatting includes all agent info after standard fields + - **Property 2: Session summary formatting includes all agent info after standard fields** + - **Validates: Requirements SI-2.3, SI-2.4, SI-7.2, SI-7.3, SI-7.4** + - Write `TestPropertyFormatSessionSummaryAgentInfo` in `internal/cmd/root_test.go` using `rapid` + - Generate random `SessionSummary` with random `AgentInfo`; assert all keys/values present, after "Enabled agents" line, no extras when empty + + - [x] 9.3 Write property test: Vibe Kanban URL format + - **Property 3: Vibe Kanban URL format** + - **Validates: Requirements SI-5.2** + - Write `TestPropertyVibeKanbanURLFormat` in `internal/agents/vibekanban/vibekanban_test.go` using `rapid` + - Generate random port in 1–65535; assert URL matches `"http://localhost:"` exactly + +- [x] 10. Final checkpoint - Ensure all tests pass + - Ensure all tests pass (`go test ./...`), ask the user if questions arise. ## Notes @@ -153,22 +106,19 @@ Implement the Vibe Kanban agent module (`internal/agents/vibekanban/`) — a web - Checkpoints ensure incremental validation - Property tests validate universal correctness properties from the design document - Unit tests validate specific examples and edge cases -- The design uses Go — all code examples and implementations use Go -- The agent module follows the same pattern as `internal/agents/buildresources/` and `internal/agents/augment/` -- `ExecInContainerWithOutput` is needed for port discovery (capturing stdout from `ss -tlnp`) -- The `Entrypoint()` builder method is generic and reusable by future agents needing initialization before CMD +- The Go compiler enforces interface compliance — once `SummaryInfo` is added to the interface, all implementations must exist for the project to compile (tasks 1–3 must be done together or in quick succession) ## Task Dependency Graph ```json { "waves": [ - { "id": 0, "tasks": ["1.1", "2.1", "3.1"] }, - { "id": 1, "tasks": ["2.2", "5.1"] }, - { "id": 2, "tasks": ["5.2", "6.1"] }, - { "id": 3, "tasks": ["6.2"] }, - { "id": 4, "tasks": ["8.1", "8.2", "9.1", "9.2", "9.3", "9.4", "9.5"] }, - { "id": 5, "tasks": ["10.1"] } + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["2.1", "2.2", "2.3", "3.1"] }, + { "id": 2, "tasks": ["5.1"] }, + { "id": 3, "tasks": ["6.1", "6.2"] }, + { "id": 4, "tasks": ["8.1", "8.2"] }, + { "id": 5, "tasks": ["9.1", "9.2", "9.3"] } ] } ``` diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2dc3c2f..0f5935d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -8,6 +8,13 @@ import ( "github.com/koudis/bootstrap-ai-coding/internal/docker" ) +// KeyValue represents a single labelled line in the session summary. +// Agents return slices of these from SummaryInfo(). +type KeyValue struct { + Key string + Value string +} + // Agent is the contract every AI coding agent module must satisfy. type Agent interface { ID() string @@ -16,5 +23,6 @@ type Agent interface { ContainerMountPath(homeDir string) string HasCredentials(storePath string) (bool, error) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error + SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]KeyValue, error) } diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go index 9636e76..bab1420 100644 --- a/internal/agent/registry_test.go +++ b/internal/agent/registry_test.go @@ -18,9 +18,12 @@ type stubAgent struct{ id string } func (s *stubAgent) ID() string { return s.id } func (s *stubAgent) Install(_ *docker.DockerfileBuilder) {} func (s *stubAgent) CredentialStorePath() string { return "" } -func (s *stubAgent) ContainerMountPath(homeDir string) string { return "" } +func (s *stubAgent) ContainerMountPath(homeDir string) string { return "" } func (s *stubAgent) HasCredentials(_ string) (bool, error) { return false, nil } func (s *stubAgent) HealthCheck(_ context.Context, _ *docker.Client, _ string) error { return nil } +func (s *stubAgent) SummaryInfo(_ context.Context, _ *docker.Client, _ string) ([]agent.KeyValue, error) { + return nil, nil +} // newStub is a convenience constructor. func newStub(id string) agent.Agent { return &stubAgent{id: id} } diff --git a/internal/agents/augment/augment.go b/internal/agents/augment/augment.go index d420119..c87e673 100644 --- a/internal/agents/augment/augment.go +++ b/internal/agents/augment/augment.go @@ -94,3 +94,10 @@ func (a *augmentAgent) HealthCheck(ctx context.Context, c *docker.Client, contai } return nil } + +// SummaryInfo returns no additional session summary information for the +// Augment Code agent. +// Satisfies: SI-6.2 +func (a *augmentAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} diff --git a/internal/agents/augment/augment_test.go b/internal/agents/augment/augment_test.go index 87c2791..402d84b 100644 --- a/internal/agents/augment/augment_test.go +++ b/internal/agents/augment/augment_test.go @@ -5,6 +5,7 @@ package augment_test import ( + "context" "os" "path/filepath" "testing" @@ -279,6 +280,22 @@ func TestAugmentHasCredentialsEmptyFile(t *testing.T) { require.False(t, hasCreds, "empty file should not count as credentials") } +// --------------------------------------------------------------------------- +// SummaryInfo no-op test +// --------------------------------------------------------------------------- + +// TestSummaryInfoReturnsNil verifies that the Augment Code agent's SummaryInfo +// method returns (nil, nil) since it has no additional session summary info. +// Validates: SI-6.2 +func TestSummaryInfoReturnsNil(t *testing.T) { + a, err := agent.Lookup(constants.AugmentCodeAgentName) + require.NoError(t, err, "augment agent must be registered") + + info, err := a.SummaryInfo(context.Background(), nil, "") + require.NoError(t, err) + require.Nil(t, info) +} + // --------------------------------------------------------------------------- // Node.js deduplication tests // --------------------------------------------------------------------------- diff --git a/internal/agents/buildresources/buildresources.go b/internal/agents/buildresources/buildresources.go index 84b1bf4..b0c79b7 100644 --- a/internal/agents/buildresources/buildresources.go +++ b/internal/agents/buildresources/buildresources.go @@ -93,6 +93,12 @@ func (a *buildResourcesAgent) HasCredentials(storePath string) (bool, error) { return true, nil } +// SummaryInfo returns no additional session summary information for this agent. +// Satisfies: SI-6.3 +func (a *buildResourcesAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} + // HealthCheck verifies all build tools are installed and executable. // Satisfies: BR-4 func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { diff --git a/internal/agents/buildresources/buildresources_test.go b/internal/agents/buildresources/buildresources_test.go index e2ed7aa..af5b7e6 100644 --- a/internal/agents/buildresources/buildresources_test.go +++ b/internal/agents/buildresources/buildresources_test.go @@ -1,6 +1,7 @@ package buildresources_test import ( + "context" "strings" "testing" @@ -98,6 +99,17 @@ func TestInstallAppendsExpectedPackages(t *testing.T) { "Install() must install uv to /usr/local/bin") } +// TestSummaryInfoReturnsNil verifies that the Build Resources agent's SummaryInfo +// method returns (nil, nil) since it has no additional session summary info. +// Validates: SI-6.3 +func TestSummaryInfoReturnsNil(t *testing.T) { + a := getAgent(t) + + info, err := a.SummaryInfo(context.Background(), nil, "") + require.NoError(t, err) + require.Nil(t, info) +} + func TestInstallUsesSystemWidePaths(t *testing.T) { a := getAgent(t) info := testInfo() diff --git a/internal/agents/claude/claude.go b/internal/agents/claude/claude.go index 91e1dea..96494d9 100644 --- a/internal/agents/claude/claude.go +++ b/internal/agents/claude/claude.go @@ -150,3 +150,7 @@ func (a *claudeAgent) HealthCheck(ctx context.Context, c *docker.Client, contain } return nil } + +func (a *claudeAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} diff --git a/internal/agents/claude/claude_test.go b/internal/agents/claude/claude_test.go index 54b27d4..62d66de 100644 --- a/internal/agents/claude/claude_test.go +++ b/internal/agents/claude/claude_test.go @@ -4,6 +4,7 @@ package claude_test import ( + "context" "os" "path/filepath" "testing" @@ -320,6 +321,22 @@ func TestClaudeInstallNodeAlreadyInstalled(t *testing.T) { "must add 3 RUN steps (prereqs + npm + symlink) plus optionally 1 memory injection step, got %d", added) } +// --------------------------------------------------------------------------- +// SummaryInfo no-op test +// --------------------------------------------------------------------------- + +// TestSummaryInfoReturnsNil verifies that the Claude Code agent's SummaryInfo +// method returns (nil, nil) since it has no additional session summary info. +// Validates: SI-6.1 +func TestSummaryInfoReturnsNil(t *testing.T) { + a, err := agent.Lookup(constants.ClaudeCodeAgentName) + require.NoError(t, err, "claude agent must be registered") + + info, err := a.SummaryInfo(context.Background(), nil, "") + require.NoError(t, err) + require.Nil(t, info) +} + // --------------------------------------------------------------------------- // Property 57: Agent ContainerMountPath uses runtime-provided home directory // --------------------------------------------------------------------------- diff --git a/internal/agents/vibekanban/integration_test.go b/internal/agents/vibekanban/integration_test.go new file mode 100644 index 0000000..e728377 --- /dev/null +++ b/internal/agents/vibekanban/integration_test.go @@ -0,0 +1,415 @@ +//go:build integration + +package vibekanban_test + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + dockerimage "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" + "github.com/koudis/bootstrap-ai-coding/internal/testutil" +) + +// Package-level shared container state — built once in TestMain, reused by all tests. +var ( + sharedContainerName string + sharedSSHPort int + sharedClient *docker.Client + sharedImageTag string +) + +// TestMain gates the integration suite behind an explicit consent prompt, +// builds the container image once, starts the container, and tears it down +// after all tests complete. +func TestMain(m *testing.M) { + if _, err := exec.LookPath("docker"); err != nil { + os.Exit(m.Run()) + } + + testutil.RequireIntegrationConsent() + + if err := testutil.EnsureBaseImageAbsent(); err != nil { + fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) + os.Exit(1) + } + + if err := setupSharedContainer(); err != nil { + fmt.Fprintf(os.Stderr, "setupSharedContainer: %v\n", err) + os.Exit(1) + } + + code := m.Run() + + teardownSharedContainer() + os.Exit(code) +} + +func setupSharedContainer() error { + ctx := context.Background() + + projectDir, err := os.MkdirTemp("", "bac-vibekanban-integration-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + dirName := filepath.Base(projectDir) + + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating host key pair: %w", err) + } + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating user key pair: %w", err) + } + + info, err := hostinfo.Current() + if err != nil { + return fmt.Errorf("getting host info: %w", err) + } + + sharedClient, err = docker.NewClient() + if err != nil { + return fmt.Errorf("connecting to Docker daemon: %w", err) + } + + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, info.UID, info.GID) + if err != nil { + return fmt.Errorf("checking base image for UID/GID conflicts: %w", err) + } + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + builder := docker.NewBaseImageBuilder( + info, + strategy, conflictingUser, + "", + ) + + vkAgent, err := agent.Lookup(constants.VibeKanbanAgentName) + if err != nil { + return fmt.Errorf("looking up vibe-kanban agent: %w", err) + } + vkAgent.Install(builder) + + port, err := findFreePortVK() + if err != nil { + return fmt.Errorf("finding free port: %w", err) + } + + sharedContainerName = constants.ContainerNamePrefix + sanitizeVK(dirName) + sharedImageTag = sharedContainerName + ":latest" + sharedSSHPort = port + + // Build base image + baseSpec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: constants.BaseImageTag, + Dockerfile: builder.Build(), + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) + if err != nil { + return fmt.Errorf("building base image with vibe-kanban: %w", err) + } + + // Build instance image + instanceBuilder := docker.NewInstanceImageBuilder( + info, + userPubKey, + hostKeyPriv, hostKeyPub, + port, false, + ) + instanceBuilder.Finalize() + + spec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: sharedImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + { + HostPath: projectDir, + ContainerPath: constants.WorkspaceMountPath, + ReadOnly: false, + }, + }, + SSHPort: port, + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, spec, true) + if err != nil { + return fmt.Errorf("building container image with vibe-kanban: %w", err) + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + if err != nil { + return fmt.Errorf("creating container: %w", err) + } + + err = docker.StartContainer(ctx, sharedClient, sharedContainerName) + if err != nil { + return fmt.Errorf("starting container: %w", err) + } + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 120*time.Second) + if err != nil { + return fmt.Errorf("waiting for SSH to be ready: %w", err) + } + + // Give vibe-kanban time to start up via the supervisor + time.Sleep(5 * time.Second) + + return nil +} + +func teardownSharedContainer() { + ctx := context.Background() + if sharedClient == nil { + return + } + _ = docker.StopContainer(ctx, sharedClient, sharedContainerName) + _ = docker.RemoveContainer(ctx, sharedClient, sharedContainerName) + images, _ := docker.ListBACImages(ctx, sharedClient) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == sharedImageTag { + _, _ = sharedClient.ImageRemove(ctx, img.ID, dockerimage.RemoveOptions{Force: true}) + } + } + } +} + +// ---------------------------------------------------------------------------- +// TestVibeKanbanInstallsAndRuns +// Validates: VK-2.3, VK-3.1, VK-5.1, VK-5.2 +// Full image build, binary present (which vibe-kanban exits 0), process running +// ---------------------------------------------------------------------------- + +func TestVibeKanbanInstallsAndRuns(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + // Verify binary is present + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"which", "vibe-kanban"}) + require.NoError(t, err, "exec which vibe-kanban") + require.Equal(t, 0, exitCode, "expected 'which vibe-kanban' to exit 0 (binary present)") + + // Verify process is running + exitCode, err = docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"pgrep", "-f", "vibe-kanban"}) + require.NoError(t, err, "exec pgrep -f vibe-kanban") + require.Equal(t, 0, exitCode, "expected vibe-kanban process to be running") +} + +// ---------------------------------------------------------------------------- +// TestVibeKanbanHealthCheck +// Validates: VK-5.1, VK-5.2 +// HealthCheck passes on a live container +// ---------------------------------------------------------------------------- + +func TestVibeKanbanHealthCheck(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + vkAgent, err := agent.Lookup(constants.VibeKanbanAgentName) + require.NoError(t, err, "looking up vibe-kanban agent") + + err = vkAgent.HealthCheck(ctx, sharedClient, sharedContainerName) + require.NoError(t, err, "vibe-kanban HealthCheck should return no error") +} + +// ---------------------------------------------------------------------------- +// TestVibeKanbanPortDiscovery +// Validates: VK-8.1, VK-8.2 +// Port is discoverable via ss after startup +// ---------------------------------------------------------------------------- + +func TestVibeKanbanPortDiscovery(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + port := discoverVibeKanbanPort(t, ctx) + require.Greater(t, port, 0, "expected to discover a valid port for vibe-kanban") + require.LessOrEqual(t, port, 65535, "port must be a valid TCP port") +} + +// ---------------------------------------------------------------------------- +// TestVibeKanbanCrashRecovery +// Validates: VK-3.3, VK-3.5 +// Process restarts after being killed (kill + wait + verify running) +// ---------------------------------------------------------------------------- + +func TestVibeKanbanCrashRecovery(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + // Discover the current vibe-kanban port + port := discoverVibeKanbanPort(t, ctx) + require.Greater(t, port, 0, "must discover vibe-kanban port before killing") + + // Kill the vibe-kanban server process by finding what's listening on its port. + // This avoids accidentally killing the supervisor script. + _, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, + []string{"bash", "-c", fmt.Sprintf( + "PID=$(ss -tlnp sport = :%d | grep -oP 'pid=\\K[0-9]+' | head -1); [ -n \"$PID\" ] && kill $PID; exit 0", + port)}) + require.NoError(t, err, "exec kill vibe-kanban via port lookup") + + // Wait for the supervisor to restart it (DELAY_SECONDS=5 + startup + port discovery) + time.Sleep(30 * time.Second) + + // Verify the server is running again by reading the port file + newPort := discoverVibeKanbanPort(t, ctx) + require.Greater(t, newPort, 0, "expected vibe-kanban process to be running after crash recovery") +} + +// ---------------------------------------------------------------------------- +// TestVibeKanbanAccessibleFromHost +// Validates: VK-8.1, VK-8.2 +// HTTP GET to localhost:port returns 2xx (host network mode) +// ---------------------------------------------------------------------------- + +func TestVibeKanbanAccessibleFromHost(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + port := discoverVibeKanbanPort(t, ctx) + require.Greater(t, port, 0, "must discover vibe-kanban port before testing HTTP access") + + url := fmt.Sprintf("http://localhost:%d", port) + + // Retry HTTP GET for up to 15 seconds (server may need time after restart) + var resp *http.Response + var httpErr error + deadline := time.Now().Add(15 * time.Second) + client := &http.Client{Timeout: 5 * time.Second} + + for time.Now().Before(deadline) { + resp, httpErr = client.Get(url) + if httpErr == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + resp.Body.Close() + return // Success + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(2 * time.Second) + } + + if httpErr != nil { + t.Fatalf("HTTP GET %s failed: %v", url, httpErr) + } + if resp != nil { + t.Fatalf("HTTP GET %s returned status %d, expected 2xx", url, resp.StatusCode) + } + t.Fatalf("HTTP GET %s timed out without a successful response", url) +} + +// ---------------------------------------------------------------------------- +// Internal helpers +// ---------------------------------------------------------------------------- + +// discoverVibeKanbanPort reads the port file written by the supervisor script. +// Retries for up to 60 seconds since the supervisor needs time to start +// vibe-kanban and discover its port. +func discoverVibeKanbanPort(t *testing.T, ctx context.Context) int { + t.Helper() + + const portFile = "/tmp/vibe-kanban.port" + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + exitCode, output, err := docker.ExecInContainerWithOutput(ctx, sharedClient, sharedContainerName, + []string{"cat", portFile}) + if err != nil { + t.Logf("cat port file error: %v", err) + time.Sleep(2 * time.Second) + continue + } + if exitCode == 0 { + portStr := strings.TrimSpace(output) + port, err := strconv.Atoi(portStr) + if err == nil && port > 0 { + return port + } + } + time.Sleep(2 * time.Second) + } + + t.Fatal("timed out waiting for vibe-kanban port file (60s)") + return 0 +} + +func findFreePortVK() (int, error) { + for port := constants.SSHPortStart; port < 65535; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) +} + +func sanitizeVK(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "tmp" + } + return result +} diff --git a/internal/agents/vibekanban/vibekanban.go b/internal/agents/vibekanban/vibekanban.go new file mode 100644 index 0000000..9885655 --- /dev/null +++ b/internal/agents/vibekanban/vibekanban.go @@ -0,0 +1,218 @@ +// Package vibekanban implements the Vibe Kanban agent module — a web-based +// project management tool that runs as a background service inside the container. +// It self-registers with the agent registry via init() and satisfies the +// agent.Agent interface. The core application has no direct dependency on +// this package — it is wired in exclusively via a blank import in main.go. +package vibekanban + +import ( + "context" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" +) + +type vibeKanbanAgent struct{} + +func init() { + agent.Register(&vibeKanbanAgent{}) +} + +// ID returns the stable Agent_ID "vibe-kanban". +// Satisfies: VK-1.1 +func (a *vibeKanbanAgent) ID() string { + return constants.VibeKanbanAgentName +} + +// Install appends Dockerfile RUN steps that install Node.js (if not already +// installed), the vibe-kanban npm package, and the auto-start mechanism +// (supervisor script with crash recovery + ENTRYPOINT wrapper). +// Satisfies: VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-3.2, VK-3.5, VK-3.6 +func (a *vibeKanbanAgent) Install(b *docker.DockerfileBuilder) { + username := b.Username() + + // Node.js (conditional — skip if another agent already installed it) + if !b.IsNodeInstalled() { + b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*") + b.Run("curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*") + b.MarkNodeInstalled() + } + + // Runtime dependencies: iproute2 (ss for port discovery), procps (pgrep for health checks) + b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 procps && rm -rf /var/lib/apt/lists/*") + + // Install vibe-kanban globally and pre-download the platform binary. + // The timeout is short (15s) — enough for the download to complete and the + // server to start (confirming the binary works), then timeout kills it. + b.Run("npm install -g --no-fund --no-audit vibe-kanban") + b.Run(fmt.Sprintf("su -c 'BROWSER=none timeout 15 vibe-kanban || true' %s", username)) + + // Supervisor script with crash recovery (base64-encoded to avoid quoting issues) + supervisorScript := buildSupervisorScript(username) + supervisorB64 := base64.StdEncoding.EncodeToString([]byte(supervisorScript)) + b.Run(fmt.Sprintf("echo %s | base64 -d > /usr/local/bin/vibe-kanban-supervisor.sh && chmod +x /usr/local/bin/vibe-kanban-supervisor.sh", + supervisorB64)) + + // Entrypoint wrapper (base64-encoded to avoid quoting issues) + entrypoint := buildEntrypointScript() + entrypointB64 := base64.StdEncoding.EncodeToString([]byte(entrypoint)) + b.Run(fmt.Sprintf("echo %s | base64 -d > /usr/local/bin/bac-entrypoint.sh && chmod +x /usr/local/bin/bac-entrypoint.sh", + entrypointB64)) + + // ENTRYPOINT starts the supervisor before sshd + b.Entrypoint("/usr/local/bin/bac-entrypoint.sh") +} + +// buildSupervisorScript returns the supervisor shell script content. +// It substitutes the container username. The port is auto-assigned by +// vibe-kanban at startup (per VK-3.3) to avoid conflicts when multiple +// containers share the host network namespace. +// The script captures vibe-kanban's stdout to extract the auto-assigned port +// and writes it to a well-known file for SummaryInfo to read. +func buildSupervisorScript(username string) string { + return fmt.Sprintf(`#!/bin/bash +MAX_RESTARTS=5 +WINDOW_SECONDS=60 +DELAY_SECONDS=5 +PORT_FILE="/tmp/vibe-kanban.port" +LOG_FILE="/tmp/vibe-kanban.log" +RESTART_TIMES=() +while true; do + NOW=$(date +%%%%s) + PRUNED=() + for ts in "${RESTART_TIMES[@]}"; do + if (( NOW - ts < WINDOW_SECONDS )); then + PRUNED+=("$ts") + fi + done + RESTART_TIMES=("${PRUNED[@]}") + if (( ${#RESTART_TIMES[@]} >= MAX_RESTARTS )); then + echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 + exit 1 + fi + RESTART_TIMES+=("$(date +%%%%s)") + rm -f "$PORT_FILE" + su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "%s" > "$LOG_FILE" 2>&1 & + VK_PID=$! + # Wait up to 30s for the port to appear in the log output + for i in $(seq 1 30); do + sleep 1 + if [ -f "$LOG_FILE" ]; then + PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) + if [ -n "$PORT" ]; then + echo "$PORT" > "$PORT_FILE" + break + fi + fi + done + wait $VK_PID 2>/dev/null || true + sleep "$DELAY_SECONDS" +done`, username) +} + +// buildEntrypointScript returns the entrypoint wrapper script content. +func buildEntrypointScript() string { + return `#!/bin/bash +set -e +/usr/local/bin/vibe-kanban-supervisor.sh & +exec "$@"` +} + +// CredentialStorePath returns empty — no credentials to persist. +// Satisfies: VK-4.1 +func (a *vibeKanbanAgent) CredentialStorePath() string { + return "" +} + +// ContainerMountPath returns empty — no bind-mount needed. +// Satisfies: VK-4.2 +func (a *vibeKanbanAgent) ContainerMountPath(homeDir string) string { + return "" +} + +// HasCredentials always returns true — nothing to check. +// Satisfies: VK-4.3 +func (a *vibeKanbanAgent) HasCredentials(storePath string) (bool, error) { + return true, nil +} + +// HealthCheck verifies that: +// 1. The vibe-kanban binary is present (vibe-kanban --version exits 0) +// 2. The vibe-kanban process is running (pgrep with retries) +// Satisfies: VK-5.1, VK-5.2 +func (a *vibeKanbanAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { + // Check 1: Binary presence + exitCode, err := docker.ExecInContainer(ctx, c, containerID, []string{"vibe-kanban", "--version"}) + if err != nil { + return fmt.Errorf("vibe-kanban health check failed (binary): %w", err) + } + if exitCode != 0 { + return fmt.Errorf("vibe-kanban health check failed: 'vibe-kanban --version' exited with code %d", exitCode) + } + + // Check 2: Process running (with retries) + const maxRetries = 5 + const retryInterval = 2 * time.Second + + for attempt := 1; attempt <= maxRetries; attempt++ { + exitCode, err = docker.ExecInContainer(ctx, c, containerID, []string{"pgrep", "-f", "vibe-kanban"}) + if err != nil { + return fmt.Errorf("vibe-kanban health check failed (process check): %w", err) + } + if exitCode == 0 { + return nil // Process is running + } + if attempt < maxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + } + } + } + + return fmt.Errorf("vibe-kanban health check failed: process not running after %d attempts", maxRetries) +} + +// vibeKanbanPortFile is the well-known path where the supervisor writes +// the auto-assigned port after vibe-kanban starts. +const vibeKanbanPortFile = "/tmp/vibe-kanban.port" + +// SummaryInfo discovers the port Vibe Kanban is listening on by reading +// the port file written by the supervisor script after startup. +// The port is auto-assigned by vibe-kanban at startup (VK-3.3, VK-9.1). +// Returns a single KeyValue with Key "Vibe Kanban" and Value "http://localhost:". +// Retries for up to 30 seconds with 2-second intervals. +// Satisfies: SI-5.1, SI-5.2, SI-5.3, SI-5.4 +func (a *vibeKanbanAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + deadline := time.Now().Add(30 * time.Second) + + for time.Now().Before(deadline) { + exitCode, output, err := docker.ExecInContainerWithOutput(ctx, c, containerID, + []string{"cat", vibeKanbanPortFile}) + if err != nil { + return nil, err + } + if exitCode == 0 { + portStr := strings.TrimSpace(output) + port, err := strconv.Atoi(portStr) + if err == nil && port > 0 && port <= 65535 { + return []agent.KeyValue{ + {Key: "Vibe Kanban", Value: fmt.Sprintf("http://localhost:%d", port)}, + }, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } + return nil, fmt.Errorf("timed out after 30s waiting for vibe-kanban port file (%s)", vibeKanbanPortFile) +} diff --git a/internal/agents/vibekanban/vibekanban_test.go b/internal/agents/vibekanban/vibekanban_test.go new file mode 100644 index 0000000..6901c68 --- /dev/null +++ b/internal/agents/vibekanban/vibekanban_test.go @@ -0,0 +1,578 @@ +// Package vibekanban_test contains unit tests for the Vibe Kanban agent module. +// The blank import of the vibekanban package triggers its init() function, which +// registers the vibeKanbanAgent with the global agent registry. +package vibekanban_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" +) + +// newTestBuilder returns a DockerfileBuilder pre-seeded with the base layer, +// using fixed key material and UserStrategyCreate with uid=1000, gid=1000. +func newTestBuilder() *docker.DockerfileBuilder { + return docker.NewBaseImageBuilder( + &hostinfo.Info{Username: "testuser", HomeDir: "/home/testuser", UID: 1000, GID: 1000}, + docker.UserStrategyCreate, "", + "", + ) +} + +func getAgent(t *testing.T) agent.Agent { + t.Helper() + a, err := agent.Lookup(constants.VibeKanbanAgentName) + require.NoError(t, err, "vibe-kanban agent must be registered") + return a +} + +// --------------------------------------------------------------------------- +// TestID — returns constants.VibeKanbanAgentName +// Validates: VK-1.1 +// --------------------------------------------------------------------------- + +func TestID(t *testing.T) { + a := getAgent(t) + require.Equal(t, constants.VibeKanbanAgentName, a.ID()) +} + +// --------------------------------------------------------------------------- +// TestInstallNodeAlreadyInstalled — skips Node.js when IsNodeInstalled() is true +// Validates: VK-2.1 +// --------------------------------------------------------------------------- + +func TestInstallNodeAlreadyInstalled(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + b.MarkNodeInstalled() // simulate a prior agent having installed Node.js + require.True(t, b.IsNodeInstalled()) + + a.Install(b) + content := b.Build() + + // Must NOT contain the Node.js setup step + require.NotContains(t, content, "setup_22.x", + "must skip Node.js setup when already installed") + + // Must still install the npm package + require.Contains(t, content, "vibe-kanban", + "must always install the vibe-kanban npm package") +} + +// --------------------------------------------------------------------------- +// TestInstallNodeNotInstalled — installs Node.js when IsNodeInstalled() is false +// Validates: VK-2.1 +// --------------------------------------------------------------------------- + +func TestInstallNodeNotInstalled(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + require.False(t, b.IsNodeInstalled(), "fresh builder must have IsNodeInstalled() == false") + + a.Install(b) + content := b.Build() + + require.Contains(t, content, "setup_22.x", + "must install Node.js 22 when not already installed") + require.Contains(t, content, "nodejs", + "must install nodejs package when not already installed") + require.True(t, b.IsNodeInstalled(), + "MarkNodeInstalled() must be called after Node.js installation") +} + +// --------------------------------------------------------------------------- +// TestInstallContainsNpmPackage — output contains `npm install -g` with `vibe-kanban` +// Validates: VK-2.2 +// --------------------------------------------------------------------------- + +func TestInstallContainsNpmPackage(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.Contains(t, content, "npm install -g", + "must contain npm install -g") + require.Contains(t, content, "vibe-kanban", + "must contain vibe-kanban package name") +} + +// --------------------------------------------------------------------------- +// TestInstallContainsEntrypoint — output contains ENTRYPOINT instruction +// Validates: VK-3.1 +// --------------------------------------------------------------------------- + +func TestInstallContainsEntrypoint(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.Contains(t, content, "ENTRYPOINT", + "must contain ENTRYPOINT instruction") + require.Contains(t, content, "bac-entrypoint.sh", + "ENTRYPOINT must reference bac-entrypoint.sh") +} + +// --------------------------------------------------------------------------- +// TestInstallContainsSupervisor — supervisor script (base64-encoded) contains crash recovery params +// Validates: VK-3.1, VK-2.4 +// --------------------------------------------------------------------------- + +func TestInstallContainsSupervisor(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.Contains(t, content, "vibe-kanban-supervisor.sh", + "must contain supervisor script reference") + + // The script is base64-encoded in the Dockerfile. Decode it to verify contents. + // Find the line that writes the supervisor script: "echo | base64 -d > /usr/local/bin/vibe-kanban-supervisor.sh" + var supervisorB64 string + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "vibe-kanban-supervisor.sh") && strings.Contains(line, "base64 -d") { + // Extract the base64 payload between "echo " and " | base64" + after := strings.TrimPrefix(line, "RUN echo ") + idx := strings.Index(after, " | base64") + if idx > 0 { + supervisorB64 = after[:idx] + } + break + } + } + require.NotEmpty(t, supervisorB64, "must find base64-encoded supervisor script in Dockerfile") + + decoded, err := base64Decode(supervisorB64) + require.NoError(t, err, "supervisor script base64 must decode cleanly") + + require.Contains(t, decoded, "MAX_RESTARTS=5", + "supervisor must have MAX_RESTARTS=5") + require.Contains(t, decoded, "WINDOW_SECONDS=60", + "supervisor must have WINDOW_SECONDS=60") + require.Contains(t, decoded, "DELAY_SECONDS=5", + "supervisor must have DELAY_SECONDS=5") +} + +// --------------------------------------------------------------------------- +// TestInstallDoesNotContainCMD — output does NOT contain CMD instruction +// Validates: VK-3.1 +// --------------------------------------------------------------------------- + +func TestInstallDoesNotContainCMD(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + // Check that no line starts with CMD + for _, line := range strings.Split(content, "\n") { + require.False(t, strings.HasPrefix(strings.TrimSpace(line), "CMD"), + "Install() must not emit a CMD instruction, found: %q", line) + } +} + +// --------------------------------------------------------------------------- +// TestInstallNoRustNoPnpm — output does NOT contain rust/pnpm references +// Validates: VK-2.2 +// --------------------------------------------------------------------------- + +func TestInstallNoRustNoPnpm(t *testing.T) { + a := getAgent(t) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.NotContains(t, content, "rust", + "Install() must not contain rust references") + require.NotContains(t, content, "pnpm", + "Install() must not contain pnpm references") +} + +// --------------------------------------------------------------------------- +// TestCredentialStorePath — returns empty string +// Validates: VK-4.1 +// --------------------------------------------------------------------------- + +func TestCredentialStorePath(t *testing.T) { + a := getAgent(t) + require.Equal(t, "", a.CredentialStorePath()) +} + +// --------------------------------------------------------------------------- +// TestContainerMountPath — returns empty string for various homeDir values +// Validates: VK-4.2 +// --------------------------------------------------------------------------- + +func TestContainerMountPath(t *testing.T) { + a := getAgent(t) + + homeDirs := []string{ + "/home/testuser", + "/home/dev", + "/root", + "/home/alice", + } + for _, homeDir := range homeDirs { + require.Equal(t, "", a.ContainerMountPath(homeDir), + "ContainerMountPath(%q) must return empty string", homeDir) + } +} + +// --------------------------------------------------------------------------- +// TestHasCredentials — returns (true, nil) +// Validates: VK-4.3 +// --------------------------------------------------------------------------- + +func TestHasCredentials(t *testing.T) { + a := getAgent(t) + + has, err := a.HasCredentials("") + require.NoError(t, err) + require.True(t, has, "HasCredentials must always return true for vibe-kanban") + + has, err = a.HasCredentials("/some/path") + require.NoError(t, err) + require.True(t, has, "HasCredentials must always return true regardless of path") +} + +// --------------------------------------------------------------------------- +// Health check tests — mock Docker client via httptest with connection hijacking +// --------------------------------------------------------------------------- + +// hijackHandler handles the exec attach endpoint by hijacking the HTTP connection, +// which is what the Docker SDK expects for exec attach operations. +func hijackHandler(w http.ResponseWriter, _ *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + conn, buf, err := hj.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Write the HTTP 101 Switching Protocols response that Docker SDK expects + buf.WriteString("HTTP/1.1 101 UPGRADED\r\nContent-Type: application/vnd.docker.raw-stream\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + buf.Flush() + conn.Close() +} + +// newFakeDockerClient creates a *docker.Client backed by a fake HTTP server. +// The exitCode parameter controls what exit code the exec inspect returns for +// all exec operations. +func newFakeDockerClient(t *testing.T, exitCode int) *docker.Client { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Handle API version negotiation / ping + if strings.HasSuffix(path, "/_ping") || path == "/_ping" { + w.Header().Set("Api-Version", "1.47") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + return + } + + // ContainerExecCreate + if r.Method == http.MethodPost && strings.Contains(path, "/exec") && !strings.Contains(path, "/start") && !strings.Contains(path, "/json") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(container.ExecCreateResponse{ID: "fake-exec-id"}) + return + } + + // ContainerExecAttach (start) — requires connection hijacking + if r.Method == http.MethodPost && strings.Contains(path, "/exec/") && strings.Contains(path, "/start") { + hijackHandler(w, r) + return + } + + // ContainerExecInspect (json) + if r.Method == http.MethodGet && strings.Contains(path, "/exec/") && strings.Contains(path, "/json") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(container.ExecInspect{ + ExitCode: exitCode, + Running: false, + }) + return + } + + // Default + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{}`) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // Point DOCKER_HOST at our fake server (use tcp:// scheme for Docker SDK) + host := strings.Replace(srv.URL, "http://", "tcp://", 1) + t.Setenv("DOCKER_HOST", host) + client, err := docker.NewClient() + require.NoError(t, err) + + return client +} + +// newFakeDockerClientWithExecSequence creates a *docker.Client backed by a fake +// HTTP server where each successive exec returns a different exit code from the +// provided sequence. +func newFakeDockerClientWithExecSequence(t *testing.T, exitCodes []int) *docker.Client { + t.Helper() + + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + if strings.HasSuffix(path, "/_ping") || path == "/_ping" { + w.Header().Set("Api-Version", "1.47") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + return + } + + // ContainerExecCreate — increment call count + if r.Method == http.MethodPost && strings.Contains(path, "/exec") && !strings.Contains(path, "/start") && !strings.Contains(path, "/json") { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(container.ExecCreateResponse{ID: fmt.Sprintf("fake-exec-id-%d", callCount)}) + return + } + + // ContainerExecAttach (start) — requires connection hijacking + if r.Method == http.MethodPost && strings.Contains(path, "/exec/") && strings.Contains(path, "/start") { + hijackHandler(w, r) + return + } + + // ContainerExecInspect (json) — return exit code based on which exec this is + if r.Method == http.MethodGet && strings.Contains(path, "/exec/") && strings.Contains(path, "/json") { + // Determine which exec ID this is for + exitCode := 1 // default to failure + for i, code := range exitCodes { + id := fmt.Sprintf("fake-exec-id-%d", i+1) + if strings.Contains(path, id) { + exitCode = code + break + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(container.ExecInspect{ + ExitCode: exitCode, + Running: false, + }) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{}`) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + host := strings.Replace(srv.URL, "http://", "tcp://", 1) + t.Setenv("DOCKER_HOST", host) + client, err := docker.NewClient() + require.NoError(t, err) + + return client +} + +// --------------------------------------------------------------------------- +// TestHealthCheckBinaryFailure — error message identifies binary check +// Validates: VK-5.1 +// --------------------------------------------------------------------------- + +func TestHealthCheckBinaryFailure(t *testing.T) { + a := getAgent(t) + + // Create a fake Docker client that returns exit code 1 for all execs + // (simulating vibe-kanban --version failing) + client := newFakeDockerClient(t, 1) + + ctx := context.Background() + err := a.HealthCheck(ctx, client, "fake-container-id") + + require.Error(t, err, "HealthCheck must fail when binary check returns non-zero") + require.Contains(t, err.Error(), "vibe-kanban", + "error message must mention vibe-kanban") + // The binary check fails first — error identifies the version/binary check + require.Contains(t, err.Error(), "--version", + "error message must identify the binary check (references --version)") +} + +// --------------------------------------------------------------------------- +// TestHealthCheckProcessFailure — error message identifies process check +// Validates: VK-5.2 +// --------------------------------------------------------------------------- + +func TestHealthCheckProcessFailure(t *testing.T) { + a := getAgent(t) + + // First exec (binary check) passes with exit 0, all subsequent execs + // (pgrep process checks, up to 5 retries) fail with exit 1. + exitCodes := []int{0, 1, 1, 1, 1, 1} + client := newFakeDockerClientWithExecSequence(t, exitCodes) + + ctx := context.Background() + err := a.HealthCheck(ctx, client, "fake-container-id") + + require.Error(t, err, "HealthCheck must fail when process is not running") + require.Contains(t, err.Error(), "vibe-kanban", + "error message must mention vibe-kanban") + require.Contains(t, err.Error(), "process", + "error message must identify the process check") +} + +// Feature: bootstrap-ai-coding, Property 3: No-credential-store invariant +func TestPropertyNoCredentialStore(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + homeDir := rapid.String().Draw(rt, "homeDir") + storePath := rapid.String().Draw(rt, "storePath") + + a, err := agent.Lookup(constants.VibeKanbanAgentName) + require.NoError(rt, err, "vibe-kanban agent must be registered") + + // **Validates: Requirements VK-4.2** + mountPath := a.ContainerMountPath(homeDir) + require.Equal(rt, "", mountPath, + "ContainerMountPath(%q) must always return empty string", homeDir) + + // **Validates: Requirements VK-4.3** + has, credErr := a.HasCredentials(storePath) + require.NoError(rt, credErr, + "HasCredentials(%q) must not return an error", storePath) + require.True(rt, has, + "HasCredentials(%q) must always return true", storePath) + }) +} + +// Feature: bootstrap-ai-coding, Property 1: Node.js conditional installation invariant +func TestPropertyNodeJSConditionalInstallation(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + // draw inputs + nodePreInstalled := rapid.Bool().Draw(rt, "nodePreInstalled") + + // exercise the function + b := newTestBuilder() + if nodePreInstalled { + b.MarkNodeInstalled() + } + + a, err := agent.Lookup(constants.VibeKanbanAgentName) + require.NoError(rt, err, "vibe-kanban agent must be registered") + + a.Install(b) + + output := b.Build() + + // assert the property holds: + // 1. At most one occurrence of "setup_22.x" (Node.js installation block) + occurrences := strings.Count(output, "setup_22.x") + require.LessOrEqual(rt, occurrences, 1, + "Install() must produce at most one Node.js installation block, got %d", occurrences) + + // 2. After Install(), IsNodeInstalled() returns true + require.True(rt, b.IsNodeInstalled(), + "IsNodeInstalled() must return true after Install()") + }) +} + +// Feature: bootstrap-ai-coding, Property 2: Install does not emit CMD +func TestPropertyInstallDoesNotEmitCMD(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + nodePreInstalled := rapid.Bool().Draw(rt, "nodePreInstalled") + + b := newTestBuilder() + if nodePreInstalled { + b.MarkNodeInstalled() + } + + a, err := agent.Lookup(constants.VibeKanbanAgentName) + require.NoError(rt, err, "vibe-kanban agent must be registered") + + a.Install(b) + + output := b.Build() + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + require.False(rt, strings.HasPrefix(trimmed, "CMD"), + "Install() must not emit any CMD instruction, but found: %q", trimmed) + } + }) +} + +// Feature: bootstrap-ai-coding, Property 3: Vibe Kanban URL format +func TestPropertyVibeKanbanURLFormat(t *testing.T) { + // **Validates: Requirements SI-5.2** + rapid.Check(t, func(rt *rapid.T) { + port := rapid.IntRange(1, 65535).Draw(rt, "port") + + // Construct the URL the same way SummaryInfo does. + url := fmt.Sprintf("http://localhost:%d", port) + + // The URL must match "http://localhost:" exactly. + expected := fmt.Sprintf("http://localhost:%d", port) + require.Equal(rt, expected, url, + "URL must be exactly http://localhost: for port %d", port) + + // Structural invariants: + // 1. Starts with the correct scheme and host + require.True(rt, strings.HasPrefix(url, "http://localhost:"), + "URL must start with http://localhost:") + + // 2. The port suffix is the decimal string representation of the port + portStr := strings.TrimPrefix(url, "http://localhost:") + require.Equal(rt, fmt.Sprintf("%d", port), portStr, + "port portion must be the decimal string of the port number") + + // 3. No trailing path, slash, or query string + require.NotContains(rt, portStr, "/", + "URL must not contain a trailing slash or path") + require.NotContains(rt, portStr, "?", + "URL must not contain a query string") + }) +} + +// base64Decode is a test helper that decodes a standard base64 string. +func base64Decode(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6d4856c..1050a8c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -110,18 +110,21 @@ type SessionSummary struct { SSHPort int SSHConnect string EnabledAgents []string + AgentInfo []agent.KeyValue } -// FormatSessionSummary formats a SessionSummary into the five-line output. +// FormatSessionSummary formats a SessionSummary into the output lines. func FormatSessionSummary(s SessionSummary) string { - return fmt.Sprintf( - "Data directory: %s\nProject directory: %s\nSSH port: %d\nSSH connect: %s\nEnabled agents: %s\n", - s.DataDir, - s.ProjectDir, - s.SSHPort, - s.SSHConnect, - strings.Join(s.EnabledAgents, ", "), - ) + var sb strings.Builder + fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) + fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) + fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) + fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) + fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) + for _, kv := range s.AgentInfo { + fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) + } + return sb.String() } // ParseAgentsFlag splits a comma-separated agent ID string, trims whitespace, @@ -681,7 +684,19 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age if err := sshpkg.SyncSSHConfig(containerName, sshPort, info.Username, flagNoUpdateSSHConfig); err != nil { fmt.Fprintf(os.Stderr, "warning: syncing SSH config: %v\n", err) } - printSessionSummary(dd, absPath, containerName, sshPort, enabledIDs) + + // Collect agent summary info. + var agentInfo []agent.KeyValue + for _, a := range enabledAgents { + kvs, err := a.SummaryInfo(ctx, c, containerName) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s summary info: %v\n", a.ID(), err) + continue + } + agentInfo = append(agentInfo, kvs...) + } + + printSessionSummary(dd, absPath, containerName, sshPort, enabledIDs, agentInfo) return nil } // --rebuild: stop the running container so it gets recreated from the new image. @@ -747,21 +762,57 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age } } - printSessionSummary(dd, absPath, containerName, sshPort, enabledIDs) + // Collect agent summary info. + var agentInfo []agent.KeyValue + for _, a := range enabledAgents { + kvs, err := a.SummaryInfo(ctx, c, containerName) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s summary info: %v\n", a.ID(), err) + continue + } + agentInfo = append(agentInfo, kvs...) + } + + printSessionSummary(dd, absPath, containerName, sshPort, enabledIDs, agentInfo) return nil } -func printSessionSummary(dd *datadir.DataDir, projectDir string, containerName string, sshPort int, agentIDs []string) { + + +func printSessionSummary(dd *datadir.DataDir, projectDir string, containerName string, sshPort int, agentIDs []string, agentInfo []agent.KeyValue) { summary := SessionSummary{ DataDir: dd.Path(), ProjectDir: projectDir, SSHPort: sshPort, SSHConnect: "ssh " + containerName, EnabledAgents: agentIDs, + AgentInfo: agentInfo, } fmt.Print(FormatSessionSummary(summary)) } +// AgentInfoResult represents the result of calling SummaryInfo on a single agent. +type AgentInfoResult struct { + KeyValues []agent.KeyValue + Err error +} + +// CollectAgentInfo takes a slice of AgentInfoResult (one per agent, in declared +// order) and returns the collected KeyValue pairs. Results with a non-nil Err +// are excluded; all others are appended in order. +// +// Validates: SI-2.2, SI-3.2, SI-3.3 +func CollectAgentInfo(results []AgentInfoResult) []agent.KeyValue { + var collected []agent.KeyValue + for _, r := range results { + if r.Err != nil { + continue + } + collected = append(collected, r.KeyValues...) + } + return collected +} + // StringSlicesEqual reports whether a and b contain the same elements in the same order. func StringSlicesEqual(a, b []string) bool { if len(a) != len(b) { diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 09eefa7..8f2e0f6 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1,12 +1,14 @@ package cmd_test import ( + "fmt" "strings" "testing" "github.com/stretchr/testify/require" "pgregory.net/rapid" + "github.com/koudis/bootstrap-ai-coding/internal/agent" "github.com/koudis/bootstrap-ai-coding/internal/cmd" "github.com/koudis/bootstrap-ai-coding/internal/constants" ) @@ -185,6 +187,39 @@ func TestFormatSessionSummaryValues(t *testing.T) { require.Contains(t, output, "aider") } +// TestFormatSessionSummaryWithVibeKanban verifies that the "Vibe Kanban:" line +// is present in the output when AgentInfo contains a Vibe Kanban entry. +// Validates: VK-8.3, VK-8.4 +func TestFormatSessionSummaryWithVibeKanban(t *testing.T) { + summary := cmd.SessionSummary{ + DataDir: "/home/user/.config/bootstrap-ai-coding/bac-myproject", + ProjectDir: "/home/user/myproject", + SSHPort: 2222, + SSHConnect: "ssh bac-myproject", + EnabledAgents: []string{"claude-code", "vibe-kanban"}, + AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:3000"}}, + } + output := cmd.FormatSessionSummary(summary) + require.Contains(t, output, "Vibe Kanban:") + require.Contains(t, output, "http://localhost:3000") +} + +// TestFormatSessionSummaryWithoutVibeKanban verifies that the "Vibe Kanban:" line +// is absent from the output when AgentInfo is empty. +// Validates: VK-8.3, VK-8.4 +func TestFormatSessionSummaryWithoutVibeKanban(t *testing.T) { + summary := cmd.SessionSummary{ + DataDir: "/home/user/.config/bootstrap-ai-coding/bac-myproject", + ProjectDir: "/home/user/myproject", + SSHPort: 2222, + SSHConnect: "ssh bac-myproject", + EnabledAgents: []string{"claude-code"}, + AgentInfo: nil, + } + output := cmd.FormatSessionSummary(summary) + require.NotContains(t, output, "Vibe Kanban:") +} + // Feature: bootstrap-ai-coding, Property 35: --port is always within 1024–65535 when provided func TestPropertyPortValidationRange(t *testing.T) { rapid.Check(t, func(t *rapid.T) { @@ -429,3 +464,170 @@ func TestRestartPolicyDefaultIsUnlessStopped(t *testing.T) { require.NoError(t, err, "the default restart policy must pass validation") } + +// Feature: bootstrap-ai-coding, Property 2: Session summary formatting includes all agent info after standard fields +func TestPropertyFormatSessionSummaryAgentInfo(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate random standard fields. + dataDir := rapid.StringMatching(`/[a-zA-Z0-9/_.-]+`).Draw(t, "dataDir") + projectDir := rapid.StringMatching(`/[a-zA-Z0-9/_.-]+`).Draw(t, "projectDir") + sshPort := rapid.IntRange(1024, 65535).Draw(t, "sshPort") + sshConnect := rapid.StringMatching(`ssh bac-[a-z0-9-]+`).Draw(t, "sshConnect") + agentCount := rapid.IntRange(1, 5).Draw(t, "agentCount") + agents := make([]string, agentCount) + for i := range agents { + agents[i] = rapid.StringMatching(`[a-z][a-z0-9-]*`).Draw(t, "agent") + } + + // Generate random AgentInfo (0–5 entries). + infoCount := rapid.IntRange(0, 5).Draw(t, "infoCount") + var agentInfo []agent.KeyValue + for i := 0; i < infoCount; i++ { + key := rapid.StringMatching(`[A-Za-z][A-Za-z0-9 ]*`).Draw(t, fmt.Sprintf("key%d", i)) + value := rapid.StringMatching(`[a-zA-Z0-9:/._-]+`).Draw(t, fmt.Sprintf("value%d", i)) + agentInfo = append(agentInfo, agent.KeyValue{Key: key, Value: value}) + } + + summary := cmd.SessionSummary{ + DataDir: dataDir, + ProjectDir: projectDir, + SSHPort: sshPort, + SSHConnect: sshConnect, + EnabledAgents: agents, + AgentInfo: agentInfo, + } + + output := cmd.FormatSessionSummary(summary) + lines := strings.Split(output, "\n") + + // (a) Every KeyValue.Key and KeyValue.Value appears in the output. + for _, kv := range agentInfo { + require.Contains(t, output, kv.Key+":", + "output must contain key %q with colon", kv.Key) + require.Contains(t, output, kv.Value, + "output must contain value %q", kv.Value) + } + + // (b) All agent info lines appear after the "Enabled agents" line. + enabledAgentsLineIdx := -1 + for i, line := range lines { + if strings.HasPrefix(line, "Enabled agents:") { + enabledAgentsLineIdx = i + break + } + } + require.NotEqual(t, -1, enabledAgentsLineIdx, + "output must contain 'Enabled agents:' line") + + for _, kv := range agentInfo { + for i, line := range lines { + if strings.Contains(line, kv.Key+":") && strings.Contains(line, kv.Value) { + require.Greater(t, i, enabledAgentsLineIdx, + "agent info line for key %q must appear after 'Enabled agents:' line", kv.Key) + } + } + } + + // (c) When AgentInfo is nil or empty, no extra lines beyond the standard five fields. + if len(agentInfo) == 0 { + // Count non-empty lines — should be exactly 5 (the standard fields). + nonEmptyLines := 0 + for _, line := range lines { + if line != "" { + nonEmptyLines++ + } + } + require.Equal(t, 5, nonEmptyLines, + "when AgentInfo is empty, output must have exactly 5 non-empty lines") + } + }) +} + +// **Validates: Requirements SI-2.3, SI-2.4, SI-7.2, SI-7.3, SI-7.4** + +// Feature: bootstrap-ai-coding, Property 1: Collection preserves order and excludes errors +func TestPropertyCollectionPreservesOrderAndExcludesErrors(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate a random number of agent results (1–10). + n := rapid.IntRange(1, 10).Draw(t, "numAgents") + + results := make([]cmd.AgentInfoResult, n) + var expected []agent.KeyValue + + for i := 0; i < n; i++ { + hasError := rapid.Bool().Draw(t, fmt.Sprintf("hasError[%d]", i)) + if hasError { + results[i] = cmd.AgentInfoResult{ + KeyValues: nil, + Err: fmt.Errorf("agent %d failed", i), + } + } else { + // Generate 0–3 KeyValue pairs for this agent. + kvCount := rapid.IntRange(0, 3).Draw(t, fmt.Sprintf("kvCount[%d]", i)) + kvs := make([]agent.KeyValue, kvCount) + for j := 0; j < kvCount; j++ { + kvs[j] = agent.KeyValue{ + Key: rapid.StringMatching(`[A-Za-z][A-Za-z0-9 ]*`).Draw(t, fmt.Sprintf("key[%d][%d]", i, j)), + Value: rapid.String().Draw(t, fmt.Sprintf("value[%d][%d]", i, j)), + } + } + results[i] = cmd.AgentInfoResult{ + KeyValues: kvs, + Err: nil, + } + expected = append(expected, kvs...) + } + } + + collected := cmd.CollectAgentInfo(results) + + // The collected output must match the expected filtered/ordered result. + require.Equal(t, len(expected), len(collected), + "collected length must match expected (non-erroring agents' KVs)") + for i := range expected { + require.Equal(t, expected[i].Key, collected[i].Key, + "Key at position %d must match", i) + require.Equal(t, expected[i].Value, collected[i].Value, + "Value at position %d must match", i) + } + }) +} + +// Feature: bootstrap-ai-coding, Property 4: Session summary includes Vibe Kanban URL for any valid port +func TestPropertySessionSummaryIncludesVibeKanbanURL(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + port := rapid.IntRange(1, 65535).Draw(t, "port") + url := fmt.Sprintf("http://localhost:%d", port) + + summary := cmd.SessionSummary{ + DataDir: "/home/user/.config/bootstrap-ai-coding/bac-test", + ProjectDir: "/home/user/project", + SSHPort: 2222, + SSHConnect: "ssh bac-test", + EnabledAgents: []string{"vibe-kanban"}, + AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: url}}, + } + + output := cmd.FormatSessionSummary(summary) + + // When AgentInfo contains Vibe Kanban, output must contain "Vibe Kanban:" and the URL. + require.Contains(t, output, "Vibe Kanban:", + "output must contain 'Vibe Kanban:' label when AgentInfo is set") + require.Contains(t, output, url, + "output must contain the Vibe Kanban URL %q", url) + + // When AgentInfo is empty, output must NOT contain "Vibe Kanban:". + summaryEmpty := cmd.SessionSummary{ + DataDir: "/home/user/.config/bootstrap-ai-coding/bac-test", + ProjectDir: "/home/user/project", + SSHPort: 2222, + SSHConnect: "ssh bac-test", + EnabledAgents: []string{"claude-code"}, + AgentInfo: nil, + } + + outputEmpty := cmd.FormatSessionSummary(summaryEmpty) + require.NotContains(t, outputEmpty, "Vibe Kanban:", + "output must NOT contain 'Vibe Kanban:' when AgentInfo is empty") + }) +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index a731b10..d1aa816 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -63,10 +63,15 @@ const ( // Corresponds to the Agent_ID glossary term for Build Resources (BR-1). BuildResourcesAgentName = "build-resources" + // VibeKanbanAgentName is the stable Agent_ID for the Vibe Kanban agent + // module that provides a web-based project management tool. + // Corresponds to the Agent_ID glossary term for Vibe Kanban (VK-1). + VibeKanbanAgentName = "vibe-kanban" + // DefaultAgents is the comma-separated list of agent IDs enabled when the - // --agents flag is omitted. Claude Code, Augment Code, and Build Resources - // are enabled by default. - DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName + // --agents flag is omitted. Claude Code, Augment Code, Build Resources, + // and Vibe Kanban are enabled by default. + DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName + "," + VibeKanbanAgentName // SSHHostKeyType is the algorithm used for the container's SSH host key pair. // Determines the key file names on disk (ssh_host__key) and the path diff --git a/internal/docker/builder.go b/internal/docker/builder.go index 7b2d705..4a6bc9a 100644 --- a/internal/docker/builder.go +++ b/internal/docker/builder.go @@ -231,6 +231,15 @@ func (b *DockerfileBuilder) Cmd(cmd string) { b.lines = append(b.lines, fmt.Sprintf(`CMD ["/bin/sh", "-c", %q]`, cmd)) } +// Entrypoint appends an ENTRYPOINT instruction in exec form. +func (b *DockerfileBuilder) Entrypoint(args ...string) { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = fmt.Sprintf("%q", a) + } + b.lines = append(b.lines, fmt.Sprintf("ENTRYPOINT [%s]", strings.Join(quoted, ", "))) +} + // RunAsUser emits a USER switch, runs the command as the container user, // then switches back to root for subsequent instructions. This is used by // agent modules that need to install user-local tools (e.g. uv). diff --git a/internal/docker/builder_test.go b/internal/docker/builder_test.go index a63df2e..0520771 100644 --- a/internal/docker/builder_test.go +++ b/internal/docker/builder_test.go @@ -1498,3 +1498,22 @@ func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { } }) } + +// --------------------------------------------------------------------------- +// Unit tests for Entrypoint builder method +// Validates: VK-3.1 +// --------------------------------------------------------------------------- + +func TestBuilderEntrypointSingleArg(t *testing.T) { + b := newCreateBuilder(1000, 1000) + b.Entrypoint("/usr/local/bin/bac-entrypoint.sh") + content := b.Build() + require.Contains(t, content, `ENTRYPOINT ["/usr/local/bin/bac-entrypoint.sh"]`) +} + +func TestBuilderEntrypointMultiArg(t *testing.T) { + b := newCreateBuilder(1000, 1000) + b.Entrypoint("/bin/sh", "-c", "start.sh") + content := b.Build() + require.Contains(t, content, `ENTRYPOINT ["/bin/sh", "-c", "start.sh"]`) +} diff --git a/internal/docker/export_test.go b/internal/docker/export_test.go index 36a1a99..1cdc3ff 100644 --- a/internal/docker/export_test.go +++ b/internal/docker/export_test.go @@ -1,10 +1 @@ package docker - -import dockerclient "github.com/docker/docker/client" - -// NewClientForTest creates a Client wrapping the given Docker SDK client. -// This is exported only for testing (via the _test.go convention) so that -// external test packages can inject a fake Docker client. -func NewClientForTest(inner *dockerclient.Client) *Client { - return &Client{inner: inner} -} diff --git a/internal/docker/runner.go b/internal/docker/runner.go index f6d8236..62803bf 100644 --- a/internal/docker/runner.go +++ b/internal/docker/runner.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" "github.com/koudis/bootstrap-ai-coding/internal/constants" @@ -376,3 +377,35 @@ func ExecInContainer(ctx context.Context, c *Client, containerID string, cmd []s } } } + +// ExecInContainerWithOutput runs a command inside a running container and returns +// the exit code and captured stdout. Stderr is discarded. This is useful when the +// caller needs to parse command output (e.g. port discovery via `ss -tlnp`). +func ExecInContainerWithOutput(ctx context.Context, c *Client, containerID string, cmd []string) (exitCode int, stdout string, err error) { + execID, err := c.ContainerExecCreate(ctx, containerID, container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return -1, "", fmt.Errorf("creating exec in container %s: %w", containerID, err) + } + + resp, err := c.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{}) + if err != nil { + return -1, "", fmt.Errorf("attaching to exec in container %s: %w", containerID, err) + } + defer resp.Close() + + var stdoutBuf, stderrBuf bytes.Buffer + if _, err := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, resp.Reader); err != nil { + return -1, "", fmt.Errorf("reading exec output in container %s: %w", containerID, err) + } + + inspect, err := c.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return -1, "", fmt.Errorf("inspecting exec in container %s: %w", containerID, err) + } + + return inspect.ExitCode, stdoutBuf.String(), nil +} diff --git a/internal/docker/testing_helper.go b/internal/docker/testing_helper.go new file mode 100644 index 0000000..2925410 --- /dev/null +++ b/internal/docker/testing_helper.go @@ -0,0 +1,10 @@ +package docker + +import dockerclient "github.com/docker/docker/client" + +// NewClientForTest creates a Client wrapping the given Docker SDK client. +// Intended for use in test code across packages that need to inject a fake +// Docker client without connecting to a real daemon. +func NewClientForTest(inner *dockerclient.Client) *Client { + return &Client{inner: inner} +} diff --git a/main.go b/main.go index 78abbda..a51abc8 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( _ "github.com/koudis/bootstrap-ai-coding/internal/agents/augment" _ "github.com/koudis/bootstrap-ai-coding/internal/agents/buildresources" _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" ) func main() { From 3316b78e2e76a7de341300662545099f655299a0 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 13 May 2026 23:02:08 +0200 Subject: [PATCH 07/13] Fix inconsistencies --- .../bootstrap-ai-coding/design-architecture.md | 2 +- .../specs/bootstrap-ai-coding/requirements-core.md | 2 +- .kiro/specs/bootstrap-ai-coding/requirements.md | 9 +++++---- .kiro/steering/agent-module.md | 11 ++++++----- .kiro/steering/cli-flags.md | 2 +- .kiro/steering/constants.md | 13 ++++++++++--- .kiro/steering/product.md | 4 ++-- .kiro/steering/structure.md | 4 ++-- internal/cmd/root.go | 9 ++++++++- internal/cmd/stop.go | 13 +++++++++++-- 10 files changed, 47 insertions(+), 22 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-architecture.md b/.kiro/specs/bootstrap-ai-coding/design-architecture.md index 8549974..8dd17c0 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-architecture.md +++ b/.kiro/specs/bootstrap-ai-coding/design-architecture.md @@ -99,7 +99,7 @@ sequenceDiagram note over CLI: *hostinfo.Info (Username, HomeDir, UID, GID) now available for all subsequent operations CLI->>CLI: Validate project path exists CLI->>Docker: Ping daemon, check version >= 20.10 - CLI->>AgentRegistry: Resolve enabled agents from --agents flag (default: "claude-code,augment-code,build-resources") + CLI->>AgentRegistry: Resolve enabled agents from --agents flag (default: "claude-code,augment-code,build-resources,vibe-kanban") note over AgentRegistry: Unknown agent ID → error, exit 1 CLI->>SSH: Discover public key (~/.ssh/id_ed25519.pub → id_rsa.pub → --ssh-key) CLI->>DataDir: Init Tool_Data_Dir (~/.config/bootstrap-ai-coding//) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 6e34791..2b76a1a 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -142,7 +142,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana 2. THE Agent_Registry SHALL allow Agent modules to register themselves without requiring changes to core system code. Adding a new Agent SHALL require only a new module that implements the Agent_Interface and registers itself. 3. IF an Agent identifier supplied by the user is not found in the Agent_Registry, THEN THE CLI SHALL print a descriptive error message to stderr listing the unknown identifier and exit with a non-zero exit code. 4. THE CLI SHALL accept an `--agents` flag whose value is a comma-separated list of Agent identifiers specifying the Enabled_Agents for the Container. -5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable both `claude-code` and `augment-code` as the default Enabled_Agents (i.e. the default value of `--agents` is `"claude-code,augment-code"`). +5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable `claude-code`, `augment-code`, `build-resources`, and `vibe-kanban` as the default Enabled_Agents (i.e. the default value of `--agents` is `"claude-code,augment-code,build-resources,vibe-kanban"`). --- diff --git a/.kiro/specs/bootstrap-ai-coding/requirements.md b/.kiro/specs/bootstrap-ai-coding/requirements.md index c240fa5..5c571d6 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -1,8 +1,9 @@ # Requirements -The requirements for this project are split across three documents: +The requirements for this project are split across these documents: -- **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–21. -- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code and Augment Code, plus the template for future agents. Requirements CC-1–CC-6 and AC-1–AC-6. -- **[requirements-cli-combinations.md](./requirements-cli-combinations.md)** — Formal rules for valid and invalid CLI flag combinations. Requirements CLI-1–CLI-6. +- **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–26. +- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code, Augment Code, Build Resources, and Vibe Kanban. Requirements CC-1–CC-8, AC-1–AC-6, BR-1–BR-6, VK-1–VK-9. +- **[requirements-cli-combinations.md](./requirements-cli-combinations.md)** — Formal rules for valid and invalid CLI flag combinations. Requirements CLI-1–CLI-7. +- **[requirements-two-layer-image.md](./requirements-two-layer-image.md)** — Two-layer Docker image architecture. Requirements TL-1–TL-11. - **[requirements-agent-summary-info.md](./requirements-agent-summary-info.md)** — Agent Summary Info: generic key:value pairs in session summary via Agent interface extension. Requirements SI-1–SI-7. diff --git a/.kiro/steering/agent-module.md b/.kiro/steering/agent-module.md index 7935081..f201e88 100644 --- a/.kiro/steering/agent-module.md +++ b/.kiro/steering/agent-module.md @@ -55,9 +55,9 @@ func (a *aiderAgent) CredentialStorePath() string { } // ContainerMountPath returns where credentials are mounted inside the container. -// Always use constants.ContainerUserHome as the base — never hardcode "/home/dev". -func (a *aiderAgent) ContainerMountPath() string { - return filepath.Join(constants.ContainerUserHome, ".aider") +// The homeDir parameter is the Container_User's home directory (resolved at runtime). +func (a *aiderAgent) ContainerMountPath(homeDir string) string { + return filepath.Join(homeDir, ".aider") } // HasCredentials reports whether the credential store contains valid auth tokens. @@ -149,16 +149,17 @@ Add a new section to `.kiro/specs/bootstrap-ai-coding/requirements-agents.md` fo | `ID()` | Unique, stable, kebab-case string (e.g. `"claude-code"`, `"aider"`) | | `Install(b)` | Appends `RUN` steps to `b`; must be idempotent and self-contained | | `CredentialStorePath()` | Default host path for auth tokens; may use `~/` prefix | -| `ContainerMountPath()` | Absolute path inside container; use `constants.ContainerUserHome` as base | +| `ContainerMountPath(homeDir)` | Absolute path inside container; use `homeDir` parameter as base | | `HasCredentials(path)` | `(true, nil)` if tokens exist; `(false, nil)` if empty; `(false, err)` on error | | `HealthCheck(ctx, c, id)` | `nil` if agent is ready; non-nil error if not. `c` is the existing `*docker.Client` — do not create a new one. | +| `SummaryInfo(ctx, c, id)` | `([]KeyValue, nil)` with key:value pairs for session summary; `(nil, nil)` if nothing to report; `(nil, err)` on failure | ## Import Rules for Agent Modules Agent modules may import: - `github.com/koudis/bootstrap-ai-coding/internal/agent` — to call `agent.Register()` - `github.com/koudis/bootstrap-ai-coding/internal/docker` — for `*docker.DockerfileBuilder` and `*docker.Client` -- `github.com/koudis/bootstrap-ai-coding/internal/constants` — for `ContainerUserHome` and other glossary values +- `github.com/koudis/bootstrap-ai-coding/internal/constants` — for glossary values (agent name constants, etc.) - `github.com/koudis/bootstrap-ai-coding/internal/pathutil` — for `ExpandHome` if needed - Standard library packages diff --git a/.kiro/steering/cli-flags.md b/.kiro/steering/cli-flags.md index 897d53c..f95062a 100644 --- a/.kiro/steering/cli-flags.md +++ b/.kiro/steering/cli-flags.md @@ -27,7 +27,7 @@ The path to the project directory on the host. Mounted into the container at `co Comma-separated list of agent IDs to install in the container. -- Default: `constants.DefaultAgents` (`"claude-code,augment-code"`) +- Default: `constants.DefaultAgents` (`"claude-code,augment-code,build-resources,vibe-kanban"`) - Example: `--agents claude-code` - Example: `--agents augment-code` - Example: `--agents claude-code,augment-code` diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index d33faf3..1f57ddf 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -18,8 +18,8 @@ This means: | Constant | Value | Glossary Term | |---|---|---| | `BaseContainerImage` | `"ubuntu:26.04"` | `Base_Container_Image` | -| `ContainerUser` | `"dev"` | `Container_User` (username) | -| `ContainerUserHome` | `"/home/" + ContainerUser` | `Container_User_Home` | +| `ContainerUser` | _(removed — dynamic per Req 22)_ | `Container_User` (username matches Host_User) | +| `ContainerUserHome` | _(removed — dynamic per Req 22)_ | `Container_User_Home` (matches Host_User home) | | `WorkspaceMountPath` | `"/workspace"` | `Mounted_Volume` (container path) | | `SSHPortStart` | `2222` | `SSH_Port` (starting value) | | `ContainerSSHPort` | `22` | SSH port inside the container | @@ -30,7 +30,9 @@ This means: | `ManifestFilePath` | `"/bac-manifest.json"` | manifest file inside image | | `ClaudeCodeAgentName` | `"claude-code"` | Agent_ID for Claude Code (CC-1) | | `AugmentCodeAgentName` | `"augment-code"` | Agent_ID for Augment Code (AC-1) | -| `DefaultAgents` | `"claude-code,augment-code"` | default `Enabled_Agents` (Req 7.5) | +| `BuildResourcesAgentName` | `"build-resources"` | Agent_ID for Build Resources (BR-1) | +| `VibeKanbanAgentName` | `"vibe-kanban"` | Agent_ID for Vibe Kanban (VK-1) | +| `DefaultAgents` | `"claude-code,augment-code,build-resources,vibe-kanban"` | default `Enabled_Agents` (Req 7.5) | | `SSHHostKeyType` | `"ed25519"` | SSH host key algorithm | | `MinDockerVersion` | `"20.10"` | minimum Docker version (Req 6.3) | | `ToolDataDirPerm` | `0o700` | Tool_Data_Dir permissions (Req 15.2) | @@ -39,6 +41,11 @@ This means: | `SSHConfigFile` | `"~/.ssh/config"` | SSH_Config_File (Req 19) | | `SSHDirPerm` | `0o700` | ~/.ssh directory permissions (Req 18.5) | | `HostBindIP` | `"127.0.0.1"` | IP address containers bind SSH port to on the host (Req R7) | +| `DefaultRestartPolicy` | `"unless-stopped"` | Docker restart policy default (Req 25.2) | +| `BaseImageName` | `"bac-base"` | Base image name for two-layer architecture (TL-11) | +| `BaseImageTag` | `"bac-base:latest"` | Full base image reference (TL-11) | +| `GitConfigPerm` | `0o444` | Injected .gitconfig permissions (Req 24) | +| `KeyringProfileScript` | `"/etc/profile.d/dbus-keyring.sh"` | Keyring startup script path (CC-7) | | `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.7) | ### Variables (not const — Go does not support slice/map constants) diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index 736fea9..7e76fd0 100644 --- a/.kiro/steering/product.md +++ b/.kiro/steering/product.md @@ -21,7 +21,7 @@ Data directory: ~/.config/bootstrap-ai-coding// Project directory: /path/to/project SSH port: 2222 SSH connect: ssh bac- -Enabled agents: claude-code, augment-code +Enabled agents: claude-code, augment-code, build-resources, vibe-kanban ``` ## Key design goals @@ -44,7 +44,7 @@ Developers who want to run AI coding agents (Claude Code, Augment Code, etc.) in | Flag | Description | |---|---| | `` | (positional) Path to the project directory to mount | -| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code`) | +| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources,vibe-kanban`) | | `--port ` | Override the SSH port (default: auto-selected from 2222 upward) | | `--ssh-key ` | Override the SSH public key path | | `--rebuild` | Force a full container image rebuild | diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md index 7435bb4..45d9007 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -80,8 +80,8 @@ import ( - Container names: `bac-` derived from the project directory name (sanitized to `[a-z0-9_.-]`); falls back to `bac-_` on conflict, then `bac-_-2`, `-3`, … — checked only against existing `bac-`-prefixed containers - Tool data directory: `~/.config/bootstrap-ai-coding//` — stores SSH port, SSH host key, agent manifest - Base image: always `ubuntu:26.04` (constants.BaseContainerImage) — no other base image or Ubuntu version -- Container user: `dev` (constants.ContainerUser), UID/GID matching the host user who invoked the CLI -- Container user home: `/home/dev` (constants.ContainerUserHome) +- Container user: matches Host_User's username (resolved at runtime via `hostinfo.Current()`), UID/GID matching the host user who invoked the CLI +- Container user home: matches Host_User's home directory path (resolved at runtime via `hostinfo.Current()`) - Workspace mount: `/workspace` (constants.WorkspaceMountPath) - SSH port: starts at `2222` (constants.SSHPortStart), increments until free, persisted per project - SSH host key type: `ed25519` (constants.SSHHostKeyType) — generated once per project, reused across rebuilds diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1050a8c..dc74d60 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -747,6 +747,13 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age return fmt.Errorf("container started but SSH did not become ready: %w", err) } + // Run health checks for all enabled agents (CC-5, AC-5, BR-4, VK-5). + for _, a := range enabledAgents { + if err := a.HealthCheck(ctx, c, containerName); err != nil { + fmt.Fprintf(os.Stderr, "warning: %s health check: %v\n", a.ID(), err) + } + } + // Sync known_hosts with the container's SSH host key (Req 18.1–18.9). if err := sshpkg.SyncKnownHosts(sshPort, hostKeyPub, flagNoUpdateKnownHosts); err != nil { fmt.Fprintf(os.Stderr, "warning: syncing known_hosts: %v\n", err) @@ -758,7 +765,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age for _, s := range agentStatuses { if !s.hasCredentials { - fmt.Printf("Authenticate %s inside the container: run 'claude' and complete the login flow.\n", s.a.ID()) + fmt.Printf("Authenticate %s inside the container.\n", s.a.ID()) } } diff --git a/internal/cmd/stop.go b/internal/cmd/stop.go index 70f5049..c92aafa 100644 --- a/internal/cmd/stop.go +++ b/internal/cmd/stop.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/koudis/bootstrap-ai-coding/internal/datadir" @@ -29,9 +30,10 @@ type StopDockerAPI interface { func RunStopWith(api StopDockerAPI, projectPath string) error { ctx := context.Background() - // List existing bac-managed container names. + // List existing bac-managed container names (filter by bac.managed label). containers, err := api.ContainerList(ctx, container.ListOptions{ - All: true, + All: true, + Filters: bacManagedFilter(), }) if err != nil { return fmt.Errorf("listing existing containers: %w", err) @@ -85,3 +87,10 @@ func RunStopWith(api StopDockerAPI, projectPath string) error { return nil } + +// bacManagedFilter returns a Docker filter for bac-managed resources. +func bacManagedFilter() filters.Args { + f := filters.NewArgs() + f.Add("label", "bac.managed=true") + return f +} From 43b78bfeabe370b0b326ea4798e935f125d0be66 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 13 May 2026 23:08:08 +0200 Subject: [PATCH 08/13] VIbe kanban is optional --- .kiro/specs/bootstrap-ai-coding/design-architecture.md | 2 +- .kiro/specs/bootstrap-ai-coding/requirements-agents.md | 8 ++++---- .kiro/specs/bootstrap-ai-coding/requirements-core.md | 2 +- .kiro/steering/cli-flags.md | 2 +- .kiro/steering/constants.md | 2 +- .kiro/steering/product.md | 4 ++-- internal/constants/constants.go | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-architecture.md b/.kiro/specs/bootstrap-ai-coding/design-architecture.md index 8dd17c0..8549974 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-architecture.md +++ b/.kiro/specs/bootstrap-ai-coding/design-architecture.md @@ -99,7 +99,7 @@ sequenceDiagram note over CLI: *hostinfo.Info (Username, HomeDir, UID, GID) now available for all subsequent operations CLI->>CLI: Validate project path exists CLI->>Docker: Ping daemon, check version >= 20.10 - CLI->>AgentRegistry: Resolve enabled agents from --agents flag (default: "claude-code,augment-code,build-resources,vibe-kanban") + CLI->>AgentRegistry: Resolve enabled agents from --agents flag (default: "claude-code,augment-code,build-resources") note over AgentRegistry: Unknown agent ID → error, exit 1 CLI->>SSH: Discover public key (~/.ssh/id_ed25519.pub → id_rsa.pub → --ssh-key) CLI->>DataDir: Init Tool_Data_Dir (~/.config/bootstrap-ai-coding//) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md index 04e20cd..de70957 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md @@ -410,14 +410,14 @@ The container uses host network mode (Req 26) by default, so Vibe Kanban's auto- --- -### Requirement VK-7: Default Inclusion +### Requirement VK-7: Optional Inclusion -**User Story:** As a developer, I want Vibe Kanban included by default so that the project management board is available without needing to explicitly request it. +**User Story:** As a developer, I want Vibe Kanban available as an opt-in agent so that I can enable the project management board when I need it. #### Acceptance Criteria -1. THE `constants.DefaultAgents` value SHALL include `"vibe-kanban"` in the comma-separated list so that the agent is present in the parsed agent set when the `--agents` flag is omitted. -2. WHEN the user invokes the CLI without the `--agents` flag, THE system SHALL include `"vibe-kanban"` in the enabled agents list displayed in the session summary. +1. THE `constants.DefaultAgents` value SHALL NOT include `"vibe-kanban"` — the agent is opt-in only. +2. WHEN the user invokes the CLI with `--agents` including `"vibe-kanban"` (e.g. `--agents claude-code,augment-code,build-resources,vibe-kanban`), THE system SHALL include `"vibe-kanban"` in the enabled agents list and install it in the container. 3. IF the user specifies `--agents` with a list that does not contain `"vibe-kanban"`, THEN THE system SHALL not install or enable the Vibe Kanban module in the container, and `"vibe-kanban"` SHALL not appear in the session summary's enabled agents list. 4. THE `"vibe-kanban"` agent ID SHALL be registered in the agent registry so that `agent.Lookup("vibe-kanban")` resolves without error. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 2b76a1a..cdd32a8 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -142,7 +142,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana 2. THE Agent_Registry SHALL allow Agent modules to register themselves without requiring changes to core system code. Adding a new Agent SHALL require only a new module that implements the Agent_Interface and registers itself. 3. IF an Agent identifier supplied by the user is not found in the Agent_Registry, THEN THE CLI SHALL print a descriptive error message to stderr listing the unknown identifier and exit with a non-zero exit code. 4. THE CLI SHALL accept an `--agents` flag whose value is a comma-separated list of Agent identifiers specifying the Enabled_Agents for the Container. -5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable `claude-code`, `augment-code`, `build-resources`, and `vibe-kanban` as the default Enabled_Agents (i.e. the default value of `--agents` is `"claude-code,augment-code,build-resources,vibe-kanban"`). +5. WHEN the `--agents` flag is omitted, THE CLI SHALL enable `claude-code`, `augment-code`, and `build-resources` as the default Enabled_Agents (i.e. the default value of `--agents` is `"claude-code,augment-code,build-resources"`). --- diff --git a/.kiro/steering/cli-flags.md b/.kiro/steering/cli-flags.md index f95062a..1cd0666 100644 --- a/.kiro/steering/cli-flags.md +++ b/.kiro/steering/cli-flags.md @@ -27,7 +27,7 @@ The path to the project directory on the host. Mounted into the container at `co Comma-separated list of agent IDs to install in the container. -- Default: `constants.DefaultAgents` (`"claude-code,augment-code,build-resources,vibe-kanban"`) +- Default: `constants.DefaultAgents` (`"claude-code,augment-code,build-resources"`) - Example: `--agents claude-code` - Example: `--agents augment-code` - Example: `--agents claude-code,augment-code` diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index 1f57ddf..96494d1 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -32,7 +32,7 @@ This means: | `AugmentCodeAgentName` | `"augment-code"` | Agent_ID for Augment Code (AC-1) | | `BuildResourcesAgentName` | `"build-resources"` | Agent_ID for Build Resources (BR-1) | | `VibeKanbanAgentName` | `"vibe-kanban"` | Agent_ID for Vibe Kanban (VK-1) | -| `DefaultAgents` | `"claude-code,augment-code,build-resources,vibe-kanban"` | default `Enabled_Agents` (Req 7.5) | +| `DefaultAgents` | `"claude-code,augment-code,build-resources"` | default `Enabled_Agents` (Req 7.5) | | `SSHHostKeyType` | `"ed25519"` | SSH host key algorithm | | `MinDockerVersion` | `"20.10"` | minimum Docker version (Req 6.3) | | `ToolDataDirPerm` | `0o700` | Tool_Data_Dir permissions (Req 15.2) | diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index 7e76fd0..f91728e 100644 --- a/.kiro/steering/product.md +++ b/.kiro/steering/product.md @@ -21,7 +21,7 @@ Data directory: ~/.config/bootstrap-ai-coding// Project directory: /path/to/project SSH port: 2222 SSH connect: ssh bac- -Enabled agents: claude-code, augment-code, build-resources, vibe-kanban +Enabled agents: claude-code, augment-code, build-resources ``` ## Key design goals @@ -44,7 +44,7 @@ Developers who want to run AI coding agents (Claude Code, Augment Code, etc.) in | Flag | Description | |---|---| | `` | (positional) Path to the project directory to mount | -| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources,vibe-kanban`) | +| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources`) | | `--port ` | Override the SSH port (default: auto-selected from 2222 upward) | | `--ssh-key ` | Override the SSH public key path | | `--rebuild` | Force a full container image rebuild | diff --git a/internal/constants/constants.go b/internal/constants/constants.go index d1aa816..2ed5fb0 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -69,9 +69,9 @@ const ( VibeKanbanAgentName = "vibe-kanban" // DefaultAgents is the comma-separated list of agent IDs enabled when the - // --agents flag is omitted. Claude Code, Augment Code, Build Resources, - // and Vibe Kanban are enabled by default. - DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName + "," + VibeKanbanAgentName + // --agents flag is omitted. Claude Code, Augment Code, and Build Resources + // are enabled by default. + DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildResourcesAgentName // SSHHostKeyType is the algorithm used for the container's SSH host key pair. // Determines the key file names on disk (ssh_host__key) and the path From c1427c43c90625ec85fbf45bab0be63d7f45dfbe Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 18 May 2026 20:23:04 +0200 Subject: [PATCH 09/13] Cerectness of requirements --- .../bootstrap-ai-coding/design-vibekanban.md | 118 +++++++----------- 1 file changed, 42 insertions(+), 76 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md index 135ed36..71625fe 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md +++ b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md @@ -132,9 +132,9 @@ DefaultAgents = ClaudeCodeAgentName + "," + AugmentCodeAgentName + "," + BuildRe | `HasCredentials(storePath)` | `(true, nil)` always | | `HealthCheck(ctx, c, containerID)` | Binary check + process running check with retries | -### Session Summary Extension +### Session Summary: Agent-Owned SummaryInfo Pattern -The `SessionSummary` struct in `cmd/root.go` needs a new field: +The session summary is **core-agnostic**. There is no Vibe Kanban–specific field on `SessionSummary`. Instead, every agent implements `SummaryInfo(ctx, c, containerID) ([]agent.KeyValue, error)` and the core renderer (`FormatSessionSummary`) displays whatever key-value pairs agents provide: ```go type SessionSummary struct { @@ -143,11 +143,11 @@ type SessionSummary struct { SSHPort int SSHConnect string EnabledAgents []string - VibeKanbanURL string // empty if not discovered or not enabled + AgentInfo []agent.KeyValue // populated by calling SummaryInfo on each enabled agent } ``` -`FormatSessionSummary` conditionally includes the Vibe Kanban line: +`FormatSessionSummary` iterates `AgentInfo` without knowledge of which agent produced which entry: ```go func FormatSessionSummary(s SessionSummary) string { @@ -157,26 +157,18 @@ func FormatSessionSummary(s SessionSummary) string { fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) - if s.VibeKanbanURL != "" { - fmt.Fprintf(&sb, "Vibe Kanban: %s\n", s.VibeKanbanURL) + for _, kv := range s.AgentInfo { + fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) } return sb.String() } ``` -### Port Discovery Function +### Port Discovery: Agent-Side Responsibility -A new exported function in `internal/docker/` for discovering a process's listening port: +Port discovery is **not** performed by core code. The Vibe Kanban agent's `SummaryInfo()` implementation is responsible for discovering its own port. It reads the port file (`/tmp/vibe-kanban.port`) written by the supervisor script, retrying for up to 30 seconds. The generic `docker.ExecInContainerWithOutput` helper is used to read the file inside the container. -```go -// DiscoverListeningPort executes `ss -tlnp` inside the container and returns -// the first port where the given process name is listening. Returns 0 if not found. -func DiscoverListeningPort(ctx context.Context, c *Client, containerID string, processName string) (int, error) { - // Uses ExecInContainerWithOutput (new helper) to capture stdout - // Parses ss output for lines containing processName - // Extracts port from the Local Address:Port column -} -``` +See [design-agent-summary-info.md](design-agent-summary-info.md) for the full `SummaryInfo()` contract and implementation details. --- @@ -449,7 +441,7 @@ func ExecInContainerWithOutput(ctx context.Context, c *Client, containerID strin ### 4. `internal/cmd/root.go` -#### SessionSummary struct extension: +#### SessionSummary struct (core-agnostic): ```go type SessionSummary struct { @@ -458,11 +450,11 @@ type SessionSummary struct { SSHPort int SSHConnect string EnabledAgents []string - VibeKanbanURL string // empty if not discovered or not enabled + AgentInfo []agent.KeyValue // populated by calling SummaryInfo on each enabled agent } ``` -#### FormatSessionSummary update: +#### FormatSessionSummary (core-agnostic renderer): ```go func FormatSessionSummary(s SessionSummary) string { @@ -472,62 +464,31 @@ func FormatSessionSummary(s SessionSummary) string { fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) - if s.VibeKanbanURL != "" { - fmt.Fprintf(&sb, "Vibe Kanban: %s\n", s.VibeKanbanURL) + for _, kv := range s.AgentInfo { + fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) } return sb.String() } ``` -#### Port discovery in `runStart()`: +#### Agent summary collection in `runStart()`: -After the health check passes and before printing the session summary, `runStart()` checks if `vibe-kanban` is among the enabled agents. If so, it attempts port discovery: +After health checks pass and before printing the session summary, `runStart()` calls `SummaryInfo()` on every enabled agent generically — no agent-specific branching: ```go -// Discover Vibe Kanban port if the agent is enabled -var vibeKanbanURL string +// Collect agent summary info. +var agentInfo []agent.KeyValue for _, a := range enabledAgents { - if a.ID() == constants.VibeKanbanAgentName { - port, err := discoverVibeKanbanPort(ctx, c, containerName) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: could not discover Vibe Kanban port: %v\n", err) - } else if port > 0 { - vibeKanbanURL = fmt.Sprintf("http://localhost:%d", port) - } - break + kvs, err := a.SummaryInfo(ctx, c, containerName) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s summary info: %v\n", a.ID(), err) + continue } + agentInfo = append(agentInfo, kvs...) } ``` -The `discoverVibeKanbanPort` function: - -```go -// discoverVibeKanbanPort attempts to find the port Vibe Kanban is listening on -// by executing ss inside the container. Retries for up to 30 seconds. -func discoverVibeKanbanPort(ctx context.Context, c *dockerpkg.Client, containerID string) (int, error) { - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { - exitCode, output, err := dockerpkg.ExecInContainerWithOutput(ctx, c, containerID, - []string{"bash", "-c", "ss -tlnp 2>/dev/null | grep vibe-kanban | awk '{print $4}' | grep -oP ':\\K[0-9]+' | head -1"}) - if err != nil { - return 0, err - } - if exitCode == 0 && output != "" { - port := 0 - fmt.Sscanf(strings.TrimSpace(output), "%d", &port) - if port > 0 { - return port, nil - } - } - select { - case <-ctx.Done(): - return 0, ctx.Err() - case <-time.After(2 * time.Second): - } - } - return 0, fmt.Errorf("timed out after 30s waiting for vibe-kanban to bind a port") -} -``` +The core has **no** `discoverVibeKanbanPort` function and does **not** check `constants.VibeKanbanAgentName`. Port discovery is entirely the agent's responsibility inside its `SummaryInfo()` implementation. ### 5. `main.go` @@ -579,21 +540,26 @@ import ( ### 5. 30-second timeout for port discovery -**Why:** Vibe Kanban needs time to start up (Node.js initialization, port binding). 30 seconds is generous but bounded. If it fails, the CLI prints a warning but does NOT fail the overall startup - the container is still usable for SSH and other agents. +**Why:** Vibe Kanban needs time to start up (Node.js initialization, port binding). 30 seconds is generous but bounded. If it fails, `SummaryInfo()` returns an error, the core prints a warning, and the URL is omitted from the summary. The container is still usable for SSH and other agents. ### 6. Graceful degradation for port discovery failure -**Why (VK-8.4):** If port discovery times out, the session summary simply omits the Vibe Kanban URL line. The user can still SSH into the container and discover the port manually. This prevents a flaky network or slow startup from blocking the entire workflow. +**Why (VK-8.4):** If `SummaryInfo()` times out, the core's generic error handling prints a warning and omits that agent's key-value pairs from the session summary. The user can still SSH into the container and discover the port manually. This prevents a flaky network or slow startup from blocking the entire workflow. ### 7. `HOST=0.0.0.0` environment variable for vibe-kanban **Why:** The vibe-kanban Rust binary reads the `HOST` environment variable to determine its listen address (defaults to `127.0.0.1`). Setting `HOST=0.0.0.0` ensures the server accepts connections on all interfaces, which is required for host network mode accessibility. The `BROWSER=none` variable is also set to suppress the automatic browser-open attempt in the headless container environment. -### 8. Core changes are minimal and generic +### 8. Zero core coupling — agent-owned SummaryInfo pattern + +The core (`cmd/root.go`) has **no** Vibe Kanban–specific code. It does not reference `constants.VibeKanbanAgentName` for port discovery or session summary rendering. Instead: -The core changes (SessionSummary field, FormatSessionSummary conditional line, port discovery) reference `constants.VibeKanbanAgentName` - they don't import the agent package. The `discoverVibeKanbanPort` function lives in `cmd/root.go` and uses only `docker.ExecInContainerWithOutput`. This maintains the "no core coupling" principle at the package level while allowing the session summary to show the URL. +- Every agent implements `SummaryInfo(ctx, c, containerID) ([]agent.KeyValue, error)`. +- The core iterates all enabled agents, calls `SummaryInfo()`, and appends the returned key-value pairs to the session summary. +- Port discovery, URL formatting, and timeout logic live entirely inside the Vibe Kanban agent's `SummaryInfo()` method. +- The generic `docker.ExecInContainerWithOutput` helper is used by the agent to read the port file inside the container. -**Note on VK-6 (No Core Coupling):** VK-6.1 states the module SHALL NOT be referenced by name in core code. However, VK-8.3 requires the session summary to include the Vibe Kanban URL. These requirements are in tension. The resolution: `cmd/root.go` references `constants.VibeKanbanAgentName` (a constant, not a string literal or import path) to check if the agent is enabled. This is the same pattern used for `DefaultAgents`. The constant lives in `internal/constants/` which is shared infrastructure, not agent-specific code. +This satisfies VK-6.1 (no core coupling) without conflicting with VK-8.3 (session summary includes the URL), because the agent itself provides the URL through the generic interface. --- @@ -608,9 +574,9 @@ The core changes (SessionSummary field, FormatSessionSummary conditional line, p | Supervisor gives up after max restarts | Logs error to stderr, exits; container continues running (sshd is PID 1) | | Health check: binary not found | Returns error identifying "binary" check | | Health check: process not running after 5 retries | Returns error identifying "process" check with retry count | -| Port discovery times out (30s) | Warning printed, URL omitted from summary, startup succeeds | -| Port discovery exec fails | Warning printed, URL omitted from summary, startup succeeds | -| `--host-network-off` (bridge mode) | Port not accessible from host; URL still shown but with a note that it requires port forwarding | +| `SummaryInfo()` port discovery times out (30s) | Core prints warning, URL omitted from summary, startup succeeds | +| `SummaryInfo()` exec fails | Core prints warning, URL omitted from summary, startup succeeds | +| `--host-network-off` (bridge mode) | URL still shown; accessibility depends on Docker port mapping (outside agent scope) | --- @@ -633,8 +599,8 @@ The core changes (SessionSummary field, FormatSessionSummary conditional line, p | `TestHasCredentials` | Returns (true, nil) | | `TestHealthCheckBinaryFailure` | Error message identifies binary check | | `TestHealthCheckProcessFailure` | Error message identifies process check | -| `TestFormatSessionSummaryWithVibeKanban` | URL line present when VibeKanbanURL is set | -| `TestFormatSessionSummaryWithoutVibeKanban` | URL line absent when VibeKanbanURL is empty | +| `TestFormatSessionSummaryWithAgentInfo` | AgentInfo key-value pairs rendered when present | +| `TestFormatSessionSummaryWithoutAgentInfo` | No extra lines when AgentInfo is empty | ### Property-Based Tests @@ -676,11 +642,11 @@ See Correctness Properties section below. **Validates: Requirements VK-4.2, VK-4.3** -### Property 4: Session summary includes Vibe Kanban URL for any valid port +### Property 4: Session summary includes agent-provided key-value pairs for any valid content -*For any* valid TCP port number (1-65535) set as `VibeKanbanURL` in the format `http://localhost:`, `FormatSessionSummary()` SHALL include a line containing that URL. When `VibeKanbanURL` is empty, the output SHALL NOT contain "Vibe Kanban:". +*For any* non-empty `AgentInfo` slice containing `KeyValue{Key: "Vibe Kanban", Value: "http://localhost:"}` where port is a valid TCP port (1-65535), `FormatSessionSummary()` SHALL include a line containing that URL. When `AgentInfo` is empty or nil, the output SHALL NOT contain any agent-specific lines beyond the standard fields. -**Validates: Requirements VK-8.3** +**Validates: Requirements VK-8.3 (via the generic SummaryInfo pattern)** ### Property 5: Supervisor script contains correct backoff parameters From e827d0c0910100aebed5c0679526f6f62856703c Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 18 May 2026 20:25:25 +0200 Subject: [PATCH 10/13] Update --- internal/agents/vibekanban/integration_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/agents/vibekanban/integration_test.go b/internal/agents/vibekanban/integration_test.go index e728377..6c1a576 100644 --- a/internal/agents/vibekanban/integration_test.go +++ b/internal/agents/vibekanban/integration_test.go @@ -33,6 +33,7 @@ var ( sharedSSHPort int sharedClient *docker.Client sharedImageTag string + sharedProjectDir string ) // TestMain gates the integration suite behind an explicit consent prompt, @@ -68,6 +69,7 @@ func setupSharedContainer() error { if err != nil { return fmt.Errorf("creating temp dir: %w", err) } + sharedProjectDir = projectDir dirName := filepath.Base(projectDir) hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() @@ -206,6 +208,11 @@ func teardownSharedContainer() { } } } + if sharedProjectDir != "" { + if err := os.RemoveAll(sharedProjectDir); err != nil { + fmt.Fprintf(os.Stderr, "warning: removing temp project dir: %v\n", err) + } + } } // ---------------------------------------------------------------------------- From fa1619d39bc31610f7a538dbe4ce5cf81d9dd6bf Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 18 May 2026 20:39:03 +0200 Subject: [PATCH 11/13] Update --- internal/agents/vibekanban/vibekanban.go | 4 ++-- internal/cmd/purge.go | 1 + internal/cmd/root.go | 1 + internal/cmd/root_test.go | 17 +++++++++++------ internal/docker/integration_test.go | 15 +++++++++------ internal/docker/runner_network_test.go | 3 ++- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/agents/vibekanban/vibekanban.go b/internal/agents/vibekanban/vibekanban.go index 9885655..e1bbbb4 100644 --- a/internal/agents/vibekanban/vibekanban.go +++ b/internal/agents/vibekanban/vibekanban.go @@ -84,7 +84,7 @@ PORT_FILE="/tmp/vibe-kanban.port" LOG_FILE="/tmp/vibe-kanban.log" RESTART_TIMES=() while true; do - NOW=$(date +%%%%s) + NOW=$(date +%%s) PRUNED=() for ts in "${RESTART_TIMES[@]}"; do if (( NOW - ts < WINDOW_SECONDS )); then @@ -96,7 +96,7 @@ while true; do echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 exit 1 fi - RESTART_TIMES+=("$(date +%%%%s)") + RESTART_TIMES+=("$(date +%%s)") rm -f "$PORT_FILE" su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "%s" > "$LOG_FILE" 2>&1 & VK_PID=$! diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go index 0010721..66352f5 100644 --- a/internal/cmd/purge.go +++ b/internal/cmd/purge.go @@ -82,6 +82,7 @@ func RunPurgeWith(api PurgeDockerAPI) error { // still reference bac-base as their parent). danglingFilter := filters.NewArgs() danglingFilter.Add("dangling", "true") + danglingFilter.Add("label", "bac.managed=true") if _, err := api.ImagesPrune(ctx, danglingFilter); err != nil { fmt.Printf("warning: pruning dangling images: %v\n", err) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index dc74d60..df7f0e8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -395,6 +395,7 @@ func runPurge(c *dockerpkg.Client) error { // reference bac-base as their parent). danglingFilter := filters.NewArgs() danglingFilter.Add("dangling", "true") + danglingFilter.Add("label", "bac.managed=true") if _, err := c.ImagesPrune(ctx, danglingFilter); err != nil { fmt.Fprintf(os.Stderr, "warning: pruning dangling images: %v\n", err) } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 8f2e0f6..e16346b 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -500,12 +500,11 @@ func TestPropertyFormatSessionSummaryAgentInfo(t *testing.T) { output := cmd.FormatSessionSummary(summary) lines := strings.Split(output, "\n") - // (a) Every KeyValue.Key and KeyValue.Value appears in the output. + // (a) Every KeyValue produces an exact formatted line in the output. for _, kv := range agentInfo { - require.Contains(t, output, kv.Key+":", - "output must contain key %q with colon", kv.Key) - require.Contains(t, output, kv.Value, - "output must contain value %q", kv.Value) + expectedLine := fmt.Sprintf("%-17s%s", kv.Key+":", kv.Value) + require.Contains(t, output, expectedLine, + "output must contain exact agent info line for key %q", kv.Key) } // (b) All agent info lines appear after the "Enabled agents" line. @@ -520,12 +519,18 @@ func TestPropertyFormatSessionSummaryAgentInfo(t *testing.T) { "output must contain 'Enabled agents:' line") for _, kv := range agentInfo { + expectedLine := fmt.Sprintf("%-17s%s", kv.Key+":", kv.Value) + found := false for i, line := range lines { - if strings.Contains(line, kv.Key+":") && strings.Contains(line, kv.Value) { + if line == expectedLine { require.Greater(t, i, enabledAgentsLineIdx, "agent info line for key %q must appear after 'Enabled agents:' line", kv.Key) + found = true + break } } + require.True(t, found, + "expected exact line %q in output", expectedLine) } // (c) When AgentInfo is nil or empty, no extra lines beyond the standard five fields. diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index 41c4102..43c2ae3 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -649,8 +649,9 @@ func TestHostNetworkModeSSHReachable(t *testing.T) { containerName := constants.ContainerNamePrefix + "hostnet-" + sanitize(dirName) instanceImageTag := containerName + ":latest" - // Use a high port to avoid conflicts - sshPort := 22222 + // Use a dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") // Determine user strategy strategy := docker.UserStrategyCreate @@ -761,8 +762,9 @@ func TestHostNetworkCanReachHostService(t *testing.T) { containerName := constants.ContainerNamePrefix + "hostreach-" + sanitize(dirName) instanceImageTag := containerName + ":latest" - // Use a high port for SSH to avoid conflicts - sshPort := 22224 + // Use a dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") // Determine user strategy strategy := docker.UserStrategyCreate @@ -873,8 +875,9 @@ func TestBridgeModeSSHReachable(t *testing.T) { containerName := constants.ContainerNamePrefix + "bridge-" + sanitize(dirName) instanceImageTag := containerName + ":latest" - // Use a different high port to avoid conflicts with host network test - sshPort := 22223 + // Use a dynamically allocated port to avoid conflicts + sshPort, err := findFreePort() + require.NoError(t, err, "finding free port") // Determine user strategy strategy := docker.UserStrategyCreate diff --git a/internal/docker/runner_network_test.go b/internal/docker/runner_network_test.go index 3826ae7..39b088c 100644 --- a/internal/docker/runner_network_test.go +++ b/internal/docker/runner_network_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/docker/docker/api/types/container" @@ -36,7 +37,7 @@ func newFakeDockerClient(t *testing.T) (*docker.Client, chan createRequest) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Match the containers/create endpoint (path includes version prefix) - if r.Method == http.MethodPost && r.URL.Path[len(r.URL.Path)-18:] == "/containers/create" { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/containers/create") { var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("failed to decode create request: %v", err) From 5022865911f6a8ffd7da8b8c30cd88ce8fbfd24f Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Mon, 18 May 2026 20:39:17 +0200 Subject: [PATCH 12/13] delete tesk list --- .kiro/specs/bootstrap-ai-coding/tasks.md | 124 ----------------------- 1 file changed, 124 deletions(-) delete mode 100644 .kiro/specs/bootstrap-ai-coding/tasks.md diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md deleted file mode 100644 index 96d2dca..0000000 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ /dev/null @@ -1,124 +0,0 @@ -# Implementation Plan: Agent Summary Info - -## Overview - -Refactor the session summary mechanism so that agent modules contribute their own key:value pairs via a generic `SummaryInfo` method on the `Agent` interface. This removes all Vibe Kanban–specific logic from the core (`internal/cmd/root.go`), restoring the architectural rule that "core has zero knowledge of agents." - -## Tasks - -- [x] 1. Extend the Agent interface and add KeyValue type - - [x] 1.1 Add `KeyValue` struct and `SummaryInfo` method to the Agent interface - - Add `KeyValue` struct with `Key string` and `Value string` fields to `internal/agent/agent.go` - - Add `SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]KeyValue, error)` to the `Agent` interface - - _Requirements: SI-1.1, SI-1.2, SI-1.3, SI-1.4_ - -- [x] 2. Implement no-op SummaryInfo in existing agents - - [x] 2.1 Implement `SummaryInfo` returning `(nil, nil)` in the Claude Code agent - - Add `SummaryInfo` method to `claudeAgent` in `internal/agents/claude/claude.go` - - Add necessary imports (`context`, `agent` package reference for `KeyValue`) - - _Requirements: SI-6.1_ - - - [x] 2.2 Implement `SummaryInfo` returning `(nil, nil)` in the Augment Code agent - - Add `SummaryInfo` method to `augmentAgent` in `internal/agents/augment/augment.go` - - _Requirements: SI-6.2_ - - - [x] 2.3 Implement `SummaryInfo` returning `(nil, nil)` in the Build Resources agent - - Add `SummaryInfo` method to `buildResourcesAgent` in `internal/agents/buildresources/buildresources.go` - - _Requirements: SI-6.3_ - -- [x] 3. Implement SummaryInfo in the Vibe Kanban agent - - [x] 3.1 Move port discovery logic into `vibekanban.SummaryInfo()` - - Add `portRegexp` variable to `internal/agents/vibekanban/vibekanban.go` - - Implement `SummaryInfo` method with the same retry logic (30s deadline, 2s intervals, `ss -tlnp` parsing) currently in `discoverVibeKanbanPort()` - - Return `[]agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:"}}` on success - - Return error on timeout - - Add `"regexp"` and `"strconv"` imports to `vibekanban.go` - - _Requirements: SI-5.1, SI-5.2, SI-5.3, SI-5.4_ - -- [x] 4. Checkpoint - Verify compilation - - Ensure all tests pass (`go build ./...`), ask the user if questions arise. - -- [x] 5. Update SessionSummary and FormatSessionSummary - - [x] 5.1 Replace `VibeKanbanURL` with `AgentInfo` in `SessionSummary` and update `FormatSessionSummary` - - Remove `VibeKanbanURL string` field from `SessionSummary` struct in `internal/cmd/root.go` - - Add `AgentInfo []agent.KeyValue` field to `SessionSummary` struct - - Replace the Vibe Kanban conditional in `FormatSessionSummary` with a generic loop: `fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value)` for each entry in `AgentInfo` - - _Requirements: SI-4.1, SI-4.4, SI-7.1, SI-7.2, SI-7.3, SI-7.4_ - -- [x] 6. Update core collection logic and remove VK-specific code from root.go - - [x] 6.1 Add generic agent info collection loop and update `printSessionSummary` - - Add a collection loop in `runStart()` (both reconnect and fresh-start paths) that iterates over `enabledAgents`, calls `SummaryInfo()`, collects `[]agent.KeyValue`, and prints warnings on error - - Update `printSessionSummary` signature: replace `vibeKanbanURL string` parameter with `agentInfo []agent.KeyValue` - - Pass collected `agentInfo` to `printSessionSummary` and store in `SessionSummary.AgentInfo` - - _Requirements: SI-2.1, SI-2.2, SI-2.3, SI-2.4, SI-3.1, SI-3.2, SI-3.3, SI-3.4_ - - - [x] 6.2 Remove all Vibe Kanban–specific code from root.go - - Delete `discoverVibeKanbanPort()` function - - Delete `portRegexp` package-level variable - - Remove `constants.VibeKanbanAgentName` reference and the two VK discovery blocks (reconnect path + fresh start path) - - Remove unused imports (`"regexp"`, `"strconv"`) from `root.go` - - _Requirements: SI-4.2, SI-4.3_ - -- [x] 7. Checkpoint - Verify compilation and existing tests - - Ensure all tests pass (`go test ./...`), ask the user if questions arise. - -- [x] 8. Update existing tests - - [x] 8.1 Update `root_test.go` to use `AgentInfo` instead of `VibeKanbanURL` - - Update `TestFormatSessionSummaryWithVibeKanban` to use `AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:3000"}}` - - Update `TestFormatSessionSummaryWithoutVibeKanban` to use `AgentInfo: nil` - - Update `TestPropertySessionSummaryIncludesVibeKanbanURL` to use `AgentInfo` field and assert generic formatting behaviour - - Import `agent` package in `root_test.go` - - _Requirements: SI-7.2, SI-7.3, SI-7.4_ - - - [x] 8.2 Add unit tests for no-op `SummaryInfo` in agent test files - - Add `TestSummaryInfoReturnsNil` to `internal/agents/claude/claude_test.go` - - Add `TestSummaryInfoReturnsNil` to `internal/agents/augment/augment_test.go` - - Add `TestSummaryInfoReturnsNil` to `internal/agents/buildresources/buildresources_test.go` - - _Requirements: SI-6.1, SI-6.2, SI-6.3_ - -- [x] 9. Write property tests for collection logic and formatting - - [x] 9.1 Write property test: Collection preserves order and excludes errors - - **Property 1: Collection preserves order and excludes errors** - - **Validates: Requirements SI-2.2, SI-3.2, SI-3.3** - - Create a helper function `CollectAgentInfo` (exported for testability) that takes a slice of `([]KeyValue, error)` results and returns the collected `[]KeyValue` - - Write `TestPropertyCollectionPreservesOrderAndExcludesErrors` in `internal/cmd/root_test.go` using `rapid` - - Generate random slices of `([]KeyValue, error)` tuples; assert collected output matches expected filtered/ordered result - - - [x] 9.2 Write property test: Session summary formatting includes all agent info after standard fields - - **Property 2: Session summary formatting includes all agent info after standard fields** - - **Validates: Requirements SI-2.3, SI-2.4, SI-7.2, SI-7.3, SI-7.4** - - Write `TestPropertyFormatSessionSummaryAgentInfo` in `internal/cmd/root_test.go` using `rapid` - - Generate random `SessionSummary` with random `AgentInfo`; assert all keys/values present, after "Enabled agents" line, no extras when empty - - - [x] 9.3 Write property test: Vibe Kanban URL format - - **Property 3: Vibe Kanban URL format** - - **Validates: Requirements SI-5.2** - - Write `TestPropertyVibeKanbanURLFormat` in `internal/agents/vibekanban/vibekanban_test.go` using `rapid` - - Generate random port in 1–65535; assert URL matches `"http://localhost:"` exactly - -- [x] 10. Final checkpoint - Ensure all tests pass - - Ensure all tests pass (`go test ./...`), ask the user if questions arise. - -## Notes - -- Tasks marked with `*` are optional and can be skipped for faster MVP -- Each task references specific requirements for traceability -- Checkpoints ensure incremental validation -- Property tests validate universal correctness properties from the design document -- Unit tests validate specific examples and edge cases -- The Go compiler enforces interface compliance — once `SummaryInfo` is added to the interface, all implementations must exist for the project to compile (tasks 1–3 must be done together or in quick succession) - -## Task Dependency Graph - -```json -{ - "waves": [ - { "id": 0, "tasks": ["1.1"] }, - { "id": 1, "tasks": ["2.1", "2.2", "2.3", "3.1"] }, - { "id": 2, "tasks": ["5.1"] }, - { "id": 3, "tasks": ["6.1", "6.2"] }, - { "id": 4, "tasks": ["8.1", "8.2"] }, - { "id": 5, "tasks": ["9.1", "9.2", "9.3"] } - ] -} -``` From 9f1f7c5c12438de33685c093b7bfe21fd26ad7fd Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 20 May 2026 17:35:52 +0200 Subject: [PATCH 13/13] typos and strability --- .../bootstrap-ai-coding/design-vibekanban.md | 54 +++++++++++++++---- internal/docker/runner_network_test.go | 15 +++++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md index 71625fe..ba9c5fe 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md +++ b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md @@ -69,17 +69,18 @@ The supervisor script (`/usr/local/bin/vibe-kanban-supervisor.sh`) implements cr ### Port Discovery -Vibe Kanban auto-assigns its port at startup (VK-9.1). The supervisor script discovers the port and writes it to a well-known file: +Vibe Kanban auto-assigns its port at startup (VK-9.1). The supervisor script discovers the port by parsing the server's log output and writes it to a well-known file: -1. The supervisor starts vibe-kanban in the background and captures its PID -2. It polls `ss -tlnp` filtered by the exact PID (`grep "pid=$VK_PID,"`) to find the bound port +1. The supervisor starts vibe-kanban in the background, redirecting stdout/stderr to a log file, and captures its PID +2. It polls the log file for the "Main server on :" message (up to 30 seconds) 3. Once found, it writes the port number to `/tmp/vibe-kanban.port` 4. `SummaryInfo()` reads this file (retrying up to 30 seconds) This approach is robust because: -- It uses PID-based filtering (unambiguous, no process name dependency) +- It uses the server's own log output (unambiguous, no dependency on `ss` output format) - It works regardless of how many other services bind ports in the container - It avoids conflicts when multiple containers share the host network namespace (each gets a unique auto-assigned port) +- The port file is cleared (`rm -f`) before each restart so stale values are never read See [design-agent-summary-info.md](design-agent-summary-info.md) for the `SummaryInfo()` implementation details. @@ -197,6 +198,8 @@ exec "$@" MAX_RESTARTS=5 WINDOW_SECONDS=60 DELAY_SECONDS=5 +PORT_FILE="/tmp/vibe-kanban.port" +LOG_FILE="/tmp/vibe-kanban.log" RESTART_TIMES=() USERNAME="__USERNAME__" @@ -220,8 +223,25 @@ while true; do # Record this restart attempt RESTART_TIMES+=("$(date +%s)") - # Start vibe-kanban as the container user - su -c "vibe-kanban" "$USERNAME" || true + # Clear stale port file and start vibe-kanban in background + rm -f "$PORT_FILE" + su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "$USERNAME" > "$LOG_FILE" 2>&1 & + VK_PID=$! + + # Wait up to 30s for the port to appear in the log output + for i in $(seq 1 30); do + sleep 1 + if [ -f "$LOG_FILE" ]; then + PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) + if [ -n "$PORT" ]; then + echo "$PORT" > "$PORT_FILE" + break + fi + fi + done + + # Wait for vibe-kanban to exit (crash or shutdown) + wait $VK_PID 2>/dev/null || true # Wait before restarting sleep "$DELAY_SECONDS" @@ -279,6 +299,8 @@ func (a *vibeKanbanAgent) Install(b *docker.DockerfileBuilder) { MAX_RESTARTS=5 WINDOW_SECONDS=60 DELAY_SECONDS=5 +PORT_FILE="/tmp/vibe-kanban.port" +LOG_FILE="/tmp/vibe-kanban.log" RESTART_TIMES=() while true; do NOW=$(date +%%s) @@ -294,7 +316,21 @@ while true; do exit 1 fi RESTART_TIMES+=("$(date +%%s)") - su -c "vibe-kanban --host 0.0.0.0" "%s" || true + rm -f "$PORT_FILE" + su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "%s" > "$LOG_FILE" 2>&1 & + VK_PID=$! + # Wait up to 30s for the port to appear in the log output + for i in $(seq 1 30); do + sleep 1 + if [ -f "$LOG_FILE" ]; then + PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) + if [ -n "$PORT" ]; then + echo "$PORT" > "$PORT_FILE" + break + fi + fi + done + wait $VK_PID 2>/dev/null || true sleep "$DELAY_SECONDS" done`, username) @@ -534,9 +570,9 @@ import ( **Why:** The entrypoint runs as root (Docker default). The supervisor uses `su -c "vibe-kanban" "$USERNAME"` to drop privileges. This is simpler than sudo (no sudoers parsing) and works reliably in the container environment. -### 4. Port discovery via port file (not `ss` process name matching) +### 4. Port discovery via log parsing (not `ss` process name matching) -**Why:** Vibe Kanban auto-assigns its port at startup. The supervisor discovers the port using PID-based `ss` filtering and writes it to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method reads this file. This is more reliable than parsing `ss` output in `SummaryInfo()` because: (a) the Rust binary name in `ss` output varies by platform/version, (b) other services may bind ports in the container, and (c) PID-based filtering in the supervisor is unambiguous since it knows the exact child PID. +**Why:** Vibe Kanban auto-assigns its port at startup. The supervisor discovers the port by parsing the server's stdout for the "Main server on :" message and writes it to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method reads this file. This is more reliable than parsing `ss` output in `SummaryInfo()` because: (a) the Rust binary name in `ss` output varies by platform/version, (b) other services may bind ports in the container, and (c) log-based parsing in the supervisor is unambiguous since it captures the server's own output directly. ### 5. 30-second timeout for port discovery diff --git a/internal/docker/runner_network_test.go b/internal/docker/runner_network_test.go index 39b088c..69ca602 100644 --- a/internal/docker/runner_network_test.go +++ b/internal/docker/runner_network_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/docker/docker/api/types/container" dockerclient "github.com/docker/docker/client" @@ -95,7 +96,12 @@ func TestCreateContainerHostNetworkMode(t *testing.T) { require.NoError(t, err) require.Equal(t, "fake-container-id", id) - req := <-ch + var req createRequest + select { + case req = <-ch: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for container create request") + } require.Equal(t, container.NetworkMode("host"), req.HostConfig.NetworkMode, "HostNetworkOff=false must set NetworkMode to 'host'") require.Empty(t, req.HostConfig.PortBindings, @@ -121,7 +127,12 @@ func TestCreateContainerBridgeMode(t *testing.T) { require.NoError(t, err) require.Equal(t, "fake-container-id", id) - req := <-ch + var req createRequest + select { + case req = <-ch: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for container create request") + } // NetworkMode should NOT be "host" require.NotEqual(t, container.NetworkMode("host"), req.HostConfig.NetworkMode,