From 28c6c6a5ffcb2875a6346b5956e6732b436c481a Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Sun, 10 May 2026 20:07:47 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 e47237fe512a0d287dbc9cae4cd6067cdedb5cd4 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 13 May 2026 22:41:31 +0200 Subject: [PATCH 6/7] Inconsistencies in reqs and Des fixed --- .../bootstrap-ai-coding/requirements-core.md | 4 +- .../specs/bootstrap-ai-coding/requirements.md | 9 ++-- .kiro/steering/agent-module.md | 14 +++--- .kiro/steering/cli-flags.md | 5 +- .kiro/steering/constants.md | 28 ++++++++--- .kiro/steering/product.md | 4 +- .kiro/steering/structure.md | 28 +++++++---- .../agents/buildresources/buildresources.go | 2 +- internal/cmd/purge.go | 9 ++-- internal/cmd/root.go | 50 +------------------ internal/cmd/stop.go | 8 ++- 11 files changed, 73 insertions(+), 88 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index ed0522f..7d343e4 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -91,7 +91,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana #### Acceptance Criteria -1. THE CLI SHALL read the user's Public_Key from `~/.ssh/id_ed25519.pub`, `~/.ssh/id_rsa.pub`, or a path supplied via a `--ssh-key` option, in that order of precedence. +1. THE CLI SHALL read the user's Public_Key from a path supplied via the `--ssh-key` option (highest precedence), or from `~/.ssh/id_ed25519.pub`, or from `~/.ssh/id_rsa.pub`, in that order of precedence (first found wins). 2. WHEN a Container is started, THE CLI SHALL install the Public_Key into the Container's `~/.ssh/authorized_keys` for the Container_User. 3. WHEN a user connects to the SSH_Server using the corresponding private key, THE SSH_Server SHALL authenticate the connection without prompting for a password. 4. IF no Public_Key can be located, THEN THE CLI SHALL print a descriptive error message to stderr and exit with a non-zero exit code. @@ -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`, and `build-resources` as the default Enabled_Agents (i.e. the default value of `--agents` is `constants.DefaultAgents`). See also BR-6 in `requirements-agents.md`. --- diff --git a/.kiro/specs/bootstrap-ai-coding/requirements.md b/.kiro/specs/bootstrap-ai-coding/requirements.md index a05f522..04d7f0c 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -1,7 +1,8 @@ # Requirements -The requirements for this project are split across three documents: +The requirements for this project are split across multiple 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, host network mode, restart policy, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–26. +- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code (CC-1–CC-8), Augment Code (AC-1–AC-6), Build Resources (BR-1–BR-6), and Vibe Kanban (VK-1–VK-8). +- **[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 (Base_Image + Instance_Image). Requirements TL-1–TL-11. diff --git a/.kiro/steering/agent-module.md b/.kiro/steering/agent-module.md index 7935081..f998065 100644 --- a/.kiro/steering/agent-module.md +++ b/.kiro/steering/agent-module.md @@ -21,11 +21,11 @@ package aider import ( "context" + "fmt" "os" "path/filepath" "github.com/koudis/bootstrap-ai-coding/internal/agent" - "github.com/koudis/bootstrap-ai-coding/internal/constants" "github.com/koudis/bootstrap-ai-coding/internal/docker" ) @@ -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 (from hostinfo.Info.HomeDir). +func (a *aiderAgent) ContainerMountPath(homeDir string) string { + return filepath.Join(homeDir, ".aider") } // HasCredentials reports whether the credential store contains valid auth tokens. @@ -149,7 +149,7 @@ 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; `homeDir` is the container user's home (from `hostinfo.Info.HomeDir`) | | `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. | @@ -158,12 +158,12 @@ Add a new section to `.kiro/specs/bootstrap-ai-coding/requirements-agents.md` fo 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 - `github.com/koudis/bootstrap-ai-coding/internal/pathutil` — for `ExpandHome` if needed - Standard library packages Agent modules must **NOT** import: -- `cmd`, `naming`, `ssh`, `datadir`, `docker/runner` +- `cmd`, `naming`, `ssh`, `datadir`, `docker/runner`, `hostinfo` - Any other agent module ## Naming Convention diff --git a/.kiro/steering/cli-flags.md b/.kiro/steering/cli-flags.md index 897d53c..b0a3cd9 100644 --- a/.kiro/steering/cli-flags.md +++ b/.kiro/steering/cli-flags.md @@ -27,12 +27,13 @@ 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"`) - Example: `--agents claude-code` - Example: `--agents augment-code` - Example: `--agents claude-code,augment-code` +- Example: `--agents claude-code,augment-code,build-resources` - Unknown IDs produce an error listing available agents -- **Validates:** Req 7.4, 7.5 +- **Validates:** Req 7.4, 7.5, BR-6 --- diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index d33faf3..5597986 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -8,18 +8,19 @@ All project-wide constants are defined in `constants/constants.go`. Every value This means: - No `"ubuntu:26.04"` string literals outside `constants/` -- No `"dev"` username literals outside `constants/` - No `2222` port literals outside `constants/` - No `0o700` / `0o600` permission literals outside `constants/` - etc. +> **Note:** `Container_User` and `Container_User_Home` are NOT constants. They are resolved at runtime via `hostinfo.Current()` (Req 22) and passed through the `*hostinfo.Info` struct. The container user's username and home directory match the host user who invoked the CLI. + ## Constants Reference | Constant | Value | Glossary Term | |---|---|---| | `BaseContainerImage` | `"ubuntu:26.04"` | `Base_Container_Image` | -| `ContainerUser` | `"dev"` | `Container_User` (username) | -| `ContainerUserHome` | `"/home/" + ContainerUser` | `Container_User_Home` | +| `BaseImageName` | `"bac-base"` | Base image name for two-layer architecture (TL-11) | +| `BaseImageTag` | `BaseImageName + ":latest"` | Full base image reference (TL-11) | | `WorkspaceMountPath` | `"/workspace"` | `Mounted_Volume` (container path) | | `SSHPortStart` | `2222` | `SSH_Port` (starting value) | | `ContainerSSHPort` | `22` | SSH port inside the container | @@ -30,16 +31,20 @@ 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) | +| `DefaultAgents` | `"claude-code,augment-code,build-resources"` | default `Enabled_Agents` (Req 7.5, BR-6) | | `SSHHostKeyType` | `"ed25519"` | SSH host key algorithm | | `MinDockerVersion` | `"20.10"` | minimum Docker version (Req 6.3) | | `ToolDataDirPerm` | `0o700` | Tool_Data_Dir permissions (Req 15.2) | | `ToolDataFilePerm` | `0o600` | Tool_Data_Dir file permissions (Req 15.3) | +| `GitConfigPerm` | `0o444` | Injected .gitconfig permissions (Req 24) | | `KnownHostsFile` | `"~/.ssh/known_hosts"` | Known_Hosts_File (Req 18) | | `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) | -| `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.7) | +| `KeyringProfileScript` | `"/etc/profile.d/dbus-keyring.sh"` | D-Bus/gnome-keyring startup script (CC-7) | +| `HostBindIP` | `"127.0.0.1"` | IP address containers bind SSH port to on the host (Req 26) | +| `DefaultRestartPolicy` | `"unless-stopped"` | Docker restart policy (Req 25.2) | +| `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.8) | ### Variables (not const — Go does not support slice/map constants) @@ -49,6 +54,17 @@ This means: | `KnownHostsPatterns` | `["[localhost]", "127.0.0.1"]` | Known_Hosts_Entry host patterns (Req 18) | | `Version` | `"dev"` (overridden via ldflags) | build version | +### Runtime-Resolved Values (NOT constants) + +| Value | Source | Glossary Term | +|---|---|---| +| Container_User username | `hostinfo.Current().Username` | `Container_User` (Req 22) | +| Container_User home | `hostinfo.Current().HomeDir` | `Container_User_Home` (Req 22) | +| Container_User UID | `hostinfo.Current().UID` | Host_User UID (Req 10) | +| Container_User GID | `hostinfo.Current().GID` | Host_User GID (Req 10) | + +These are resolved once at CLI startup via `hostinfo.Current()` and threaded through all operations via `*hostinfo.Info`. + ## Import Pattern ```go diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md index 736fea9..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 +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`) | +| `--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/.kiro/steering/structure.md b/.kiro/steering/structure.md index 7435bb4..e6c4815 100644 --- a/.kiro/steering/structure.md +++ b/.kiro/steering/structure.md @@ -13,15 +13,21 @@ bootstrap-ai-coding/ ├── pathutil/ │ └── pathutil.go # Shared path helpers (ExpandHome) — zero internal dependencies │ + ├── hostinfo/ + │ └── hostinfo.go # Runtime host user identity (Username, HomeDir, UID, GID) — replaces former ContainerUser/ContainerUserHome constants + │ ├── cmd/ - │ └── root.go # Cobra root command, flag definitions, top-level orchestration logic + │ ├── root.go # Cobra root command, flag definitions, top-level orchestration logic + │ ├── builds.go # Two-layer image cache detection (determineBuilds) + │ ├── stop.go # --stop-and-remove flow (testable via StopDockerAPI interface) + │ └── purge.go # --purge flow (testable via PurgeDockerAPI interface) │ ├── naming/ │ └── naming.go # Container name resolution from project path ("bac-" prefix, human-readable, collision-resistant) │ ├── docker/ │ ├── client.go # Docker SDK client wrapper; prerequisite checks (daemon reachable, version >= constants.MinDockerVersion) - │ ├── builder.go # DockerfileBuilder — incremental Dockerfile assembly + │ ├── builder.go # DockerfileBuilder — two-layer Dockerfile assembly (base + instance) │ └── runner.go # Container create/start/stop/inspect helpers │ ├── ssh/ @@ -42,15 +48,17 @@ bootstrap-ai-coding/ └── agents/ ├── claude/ │ └── claude.go # Claude Code agent module (reference implementation) - └── augment/ - └── augment.go # Augment Code agent module + ├── augment/ + │ └── augment.go # Augment Code agent module + └── buildresources/ + └── buildresources.go # Build Resources pseudo-agent (dev toolchains) # future agents: internal/agents//.go — no core files change ``` ## Architectural Rules - **All packages live under `internal/`.** The Go compiler enforces that nothing outside this module can import them. -- **Core has zero knowledge of agents.** Packages under `internal/cmd/`, `internal/naming/`, `internal/docker/`, `internal/ssh/`, `internal/datadir/`, `internal/pathutil/`, and `internal/agent/` must never import anything under `internal/agents/`. +- **Core has zero knowledge of agents.** Packages under `internal/cmd/`, `internal/naming/`, `internal/docker/`, `internal/ssh/`, `internal/datadir/`, `internal/pathutil/`, `internal/hostinfo/`, and `internal/agent/` must never import anything under `internal/agents/`. - **Agent modules are wired in via blank imports in `main.go` only.** Each agent's `init()` calls `agent.Register()`. - **Agent modules may import `internal/agent`, `internal/docker`, `internal/constants`, and `internal/pathutil` from the core.** They must not import `internal/cmd`, `internal/naming`, `internal/ssh`, `internal/datadir`, or `internal/docker/runner`. - **No package may hardcode values that exist in `internal/constants/`.** Always import and reference `constants.*`. @@ -63,13 +71,15 @@ bootstrap-ai-coding/ // In main.go: import ( "github.com/koudis/bootstrap-ai-coding/internal/cmd" - _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" _ "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" ) // In internal packages: import ( "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" "github.com/koudis/bootstrap-ai-coding/internal/pathutil" "github.com/koudis/bootstrap-ai-coding/internal/naming" ) @@ -80,12 +90,12 @@ 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 the Host_User's username (resolved at runtime via `hostinfo.Current()`), UID/GID matching the host user who invoked the CLI +- Container user home: matches the 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 - Manifest file inside image: `/bac-manifest.json` (constants.ManifestFilePath) — lists enabled agent IDs for rebuild detection -- Default agents: `claude-code,augment-code` (constants.DefaultAgents) +- Default agents: `claude-code,augment-code,build-resources` (constants.DefaultAgents) - File permissions: Tool_Data_Dir `0700` (constants.ToolDataDirPerm), all files within `0600` (constants.ToolDataFilePerm) - Headless keyring: D-Bus session bus + gnome-keyring-daemon started via `/etc/profile.d/dbus-keyring.sh` on SSH login — enables libsecret-based credential storage (CC-7) diff --git a/internal/agents/buildresources/buildresources.go b/internal/agents/buildresources/buildresources.go index 84b1bf4..5407cfe 100644 --- a/internal/agents/buildresources/buildresources.go +++ b/internal/agents/buildresources/buildresources.go @@ -104,7 +104,7 @@ func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, {[]string{"uv", "--version"}, "uv"}, {[]string{"cmake", "--version"}, "cmake"}, {[]string{"javac", "-version"}, "javac"}, - {[]string{"bash", "-lc", "go version"}, "go"}, + {[]string{"/usr/local/go/bin/go", "version"}, "go"}, {[]string{"rg", "--version"}, "ripgrep"}, {[]string{"fdfind", "--version"}, "fd-find"}, {[]string{"jq", "--version"}, "jq"}, diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go index 0010721..c6630bd 100644 --- a/internal/cmd/purge.go +++ b/internal/cmd/purge.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -50,7 +51,7 @@ func RunPurgeWith(api PurgeDockerAPI) error { for _, ctr := range containers { _ = api.ContainerStop(ctx, ctr.ID, container.StopOptions{}) if err := api.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{Force: true}); err != nil { - fmt.Printf("warning: removing container %s: %v\n", ctr.ID, err) + fmt.Fprintf(os.Stderr, "warning: removing container %s: %v\n", ctr.ID, err) } } @@ -74,7 +75,7 @@ func RunPurgeWith(api PurgeDockerAPI) error { if len(img.RepoTags) > 0 { tag = img.RepoTags[0] } - fmt.Printf("warning: removing image %s: %v\n", tag, err) + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) } } @@ -83,7 +84,7 @@ func RunPurgeWith(api PurgeDockerAPI) error { danglingFilter := filters.NewArgs() danglingFilter.Add("dangling", "true") if _, err := api.ImagesPrune(ctx, danglingFilter); err != nil { - fmt.Printf("warning: pruning dangling images: %v\n", err) + fmt.Fprintf(os.Stderr, "warning: pruning dangling images: %v\n", err) } // 3. Remove base image(s) now that children are gone. @@ -93,7 +94,7 @@ func RunPurgeWith(api PurgeDockerAPI) error { if len(img.RepoTags) > 0 { tag = img.RepoTags[0] } - fmt.Printf("warning: removing image %s: %v\n", tag, err) + fmt.Fprintf(os.Stderr, "warning: removing image %s: %v\n", tag, err) } } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6d4856c..2e1c701 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -261,56 +261,8 @@ func run(cmd *cobra.Command, args []string) error { } } -func modeFlag(m Mode) string { - if m == ModeStop { - return "--stop-and-remove" - } - return "--purge" -} - func runStop(c *dockerpkg.Client, projectPath string) error { - existingNames, err := dockerpkg.ListBACContainerNames(context.Background(), c) - if err != nil { - return fmt.Errorf("listing existing containers: %w", err) - } - containerName, err := naming.ContainerName(projectPath, existingNames) - if err != nil { - return fmt.Errorf("deriving container name: %w", err) - } - info, err := dockerpkg.InspectContainer(context.Background(), c, containerName) - if err != nil { - return err - } - if info == nil { - fmt.Printf("No container found for project %s\n", projectPath) - return nil - } - if err := dockerpkg.StopContainer(context.Background(), c, containerName); err != nil { - if !strings.Contains(err.Error(), "not running") { - return err - } - } - if err := dockerpkg.RemoveContainer(context.Background(), c, containerName); err != nil { - return err - } - fmt.Printf("Container %s stopped and removed.\n", containerName) - - // Remove known_hosts entries for this project's SSH port (Req 18.7). - dd, err := datadir.New(containerName) - if err == nil { - if port, err := dd.ReadPort(); err == nil && port != 0 { - if khErr := sshpkg.RemoveKnownHostsEntries(port); khErr != nil { - fmt.Fprintf(os.Stderr, "warning: removing known_hosts entries: %v\n", khErr) - } - } - } - - // Remove SSH config entry for this container (Req 19.7). - if cfgErr := sshpkg.RemoveSSHConfigEntry(containerName); cfgErr != nil { - fmt.Fprintf(os.Stderr, "warning: removing SSH config entry: %v\n", cfgErr) - } - - return nil + return RunStopWith(c, projectPath) } func runPurge(c *dockerpkg.Client) error { diff --git a/internal/cmd/stop.go b/internal/cmd/stop.go index 70f5049..2d90cbe 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,12 @@ 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 (filtered by bac.managed label). + f := filters.NewArgs() + f.Add("label", "bac.managed=true") containers, err := api.ContainerList(ctx, container.ListOptions{ - All: true, + All: true, + Filters: f, }) if err != nil { return fmt.Errorf("listing existing containers: %w", err) From f8d1be5087cbf15bc27093a449ceb506179a9bd5 Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 13 May 2026 22:50:42 +0200 Subject: [PATCH 7/7] remove vibe kanban --- .../design-architecture.md | 6 +- .../bootstrap-ai-coding/design-vibekanban.md | 726 ------------------ .kiro/specs/bootstrap-ai-coding/design.md | 7 +- .../requirements-agents.md | 126 +-- .../bootstrap-ai-coding/requirements-core.md | 2 +- .../specs/bootstrap-ai-coding/requirements.md | 2 +- .kiro/specs/bootstrap-ai-coding/tasks.md | 175 +---- internal/constants/constants.go | 5 +- 8 files changed, 14 insertions(+), 1035 deletions(-) delete mode 100644 .kiro/specs/bootstrap-ai-coding/design-vibekanban.md diff --git a/.kiro/specs/bootstrap-ai-coding/design-architecture.md b/.kiro/specs/bootstrap-ai-coding/design-architecture.md index 8549974..3e128a7 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-architecture.md +++ b/.kiro/specs/bootstrap-ai-coding/design-architecture.md @@ -18,7 +18,7 @@ graph TD BuildResAgent["internal/agents/buildresources\n(pseudo-agent module)"] FutureAgent["internal/agents/other\n(future agent module)"] DockerDaemon["Docker Daemon"] - Container["Container\n(sshd + dev user + enabled agents)"] + Container["Container\n(sshd + container user + enabled agents)"] User -->|"bac [--agents ...]"| CLI CLI --> Naming @@ -36,7 +36,7 @@ graph TD The core packages (`internal/cmd`, `internal/naming`, `internal/docker`, `internal/ssh`, `internal/datadir`, `internal/agent`) have **no import dependency** on any package under `internal/agents/`. Agent modules are wired in exclusively via `main.go` blank imports. -> **Note (Req 28 — Module Consolidation):** The former `internal/credentials` and `internal/portfinder` packages have been merged into `internal/datadir`. Both dealt with per-project persistent state (credential paths, port selection/persistence) and had only `cmd/root.go` as their consumer. Consolidating them reduces package count without introducing import cycles or mixing unrelated concerns. +> **Note (Module Consolidation):** The former `internal/credentials` and `internal/portfinder` packages have been merged into `internal/datadir`. Both dealt with per-project persistent state (credential paths, port selection/persistence) and had only `cmd/root.go` as their consumer. Consolidating them reduces package count without introducing import cycles or mixing unrelated concerns. ### Package Layout @@ -117,7 +117,7 @@ sequenceDiagram else No image or --rebuild CLI->>Docker: Inspect Base_Container_Image for UID/GID conflict (Req 10a) alt Conflicting_Image_User found (existing user has Host_User UID or GID) - CLI->>User: "User '' (UID/GID) already exists in base image. Rename to 'dev'? [y/N]" + CLI->>User: "User '' (UID/GID) already exists in base image. Rename to ''? [y/N]" alt User confirms rename CLI->>CLI: Set user_strategy = rename (use usermod -l in Dockerfile) else User declines diff --git a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md b/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md deleted file mode 100644 index d3022ec..0000000 --- a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md +++ /dev/null @@ -1,726 +0,0 @@ -# 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 7574790..4422ee3 100644 --- a/.kiro/specs/bootstrap-ai-coding/design.md +++ b/.kiro/specs/bootstrap-ai-coding/design.md @@ -29,12 +29,11 @@ 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-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-core.md` — core application requirements (Req 1–26, including Req 22: Dynamic Container User Identity, Req 25: Restart Policy, Req 26: Host Network Mode) +- `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) +- `requirements-cli-combinations.md` — valid and invalid CLI flag combinations (CLI-1–CLI-7) - `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 961f8a5..59c7988 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, **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. +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. > **Related documents:** > - `requirements-core.md` — core application requirements including the Agent_Interface contract @@ -285,7 +285,7 @@ Build Resources is a pseudo-agent that does not provide an AI coding tool. Inste - `uv --version` - `cmake --version` - `javac -version` - - `go version` (executed via `bash -lc` to pick up `/etc/profile.d/golang.sh`) + - `/usr/local/go/bin/go version` 2. THE Health_Check SHALL be invoked by the core after the Container starts. 3. IF any Health_Check command fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Build Resources agent and the specific tool that failed. @@ -312,125 +312,3 @@ 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. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index 7d343e4..236d556 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -443,7 +443,7 @@ The core application is responsible for all orchestration: Docker lifecycle mana ### 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. +**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 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 diff --git a/.kiro/specs/bootstrap-ai-coding/requirements.md b/.kiro/specs/bootstrap-ai-coding/requirements.md index 04d7f0c..cf7f4c6 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -3,6 +3,6 @@ The requirements for this project are split across multiple documents: - **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, host network mode, restart policy, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–26. -- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code (CC-1–CC-8), Augment Code (AC-1–AC-6), Build Resources (BR-1–BR-6), and Vibe Kanban (VK-1–VK-8). +- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code (CC-1–CC-8), Augment Code (AC-1–AC-6), and Build Resources (BR-1–BR-6). - **[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 (Base_Image + Instance_Image). Requirements TL-1–TL-11. diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md index eeb2d10..bbdeac0 100644 --- a/.kiro/specs/bootstrap-ai-coding/tasks.md +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -1,174 +1,3 @@ -# Implementation Plan: Vibe Kanban Agent Module +# Implementation Plan -## 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 - -```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"] } - ] -} -``` +No pending implementation tasks. diff --git a/internal/constants/constants.go b/internal/constants/constants.go index a731b10..110d0c1 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -135,9 +135,8 @@ var Version = "dev" var ( // PublicKeyDefaultPaths lists the candidate Public_Key file paths on the Host, - // in order of precedence (highest first). The CLI tries each in turn before - // falling back to the --ssh-key flag value. - // Defined by Req 4.1: ~/.ssh/id_ed25519.pub → ~/.ssh/id_rsa.pub → --ssh-key. + // tried in order after the --ssh-key flag (which has highest precedence). + // Defined by Req 4.1: --ssh-key → ~/.ssh/id_ed25519.pub → ~/.ssh/id_rsa.pub. // Declared as a var (not const) because Go does not support slice constants. PublicKeyDefaultPaths = []string{ "~/.ssh/id_ed25519.pub",