diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-augment-code.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-augment-code.md new file mode 100644 index 0000000..bc1b444 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-augment-code.md @@ -0,0 +1,89 @@ +# Augment Code Agent Requirements + +## Overview + +Augment Code is an AI coding agent by Augment (augmentcode.com). Its CLI tool is called **Auggie** and is distributed as the `@augmentcode/auggie` npm package, providing the `auggie` binary. Auggie requires Node.js 22 or later. Authentication tokens and settings are stored in `~/.augment` on the Host. The agent is invoked inside the Container via the `auggie` command. + +### Glossary + +- **Auggie**: The Augment Code CLI tool, installed as the `auggie` binary via the `@augmentcode/auggie` npm package. Requires Node.js 22 or later. + +--- + +### Requirement AC-1: Agent Identity + +**User Story:** As the core system, I need the Augment Code module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL declare the Agent_ID `"augment-code"`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. + +--- + +### Requirement AC-2: Installation + +**User Story:** As a developer, I want Auggie to be pre-installed in the container image so I can run it immediately after connecting via SSH. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL contribute Dockerfile steps that install Node.js 22 or later as a runtime dependency, compatible with the Base_Container_Image. Note: when both Claude Code and Augment Code are enabled (the default), the agents share a single Node.js installation. The Node.js version installed must be >= 22 to satisfy this requirement; since Node.js 22 is the current LTS, it also satisfies Claude Code's LTS requirement (see CC-2). +2. THE Augment Code module SHALL contribute Dockerfile steps that install the `@augmentcode/auggie` npm package globally. +3. WHEN the container image is built with the Augment Code agent enabled, the `auggie` command SHALL be available on the default `PATH` inside the Container for the Container_User. +4. THE installation steps SHALL NOT require any manual intervention after the container starts. + +--- + +### Requirement AC-3: Credential Store + +**User Story:** As a developer, I want my Augment Code authentication to persist across sessions so I only need to log in once. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL declare `~/.augment` as its default Credential_Store path on the Host. +2. THE Augment Code module SHALL declare `/.augment` as its Credential_Volume mount path inside the Container. +3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. +4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. + +--- + +### Requirement AC-4: Credential Presence Check + +**User Story:** As the core system, I need to know whether the user has already authenticated Augment Code so I can inform them if they haven't. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL implement a credential presence check that inspects the Credential_Store directory for existing authentication tokens. +2. THE credential presence check SHALL return `false` when the Credential_Store is empty or contains no recognisable Augment Code authentication tokens. +3. THE credential presence check SHALL return `true` when valid authentication tokens are present in the Credential_Store. +4. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `auggie login` inside the Container and complete the login flow. + +--- + +### Requirement AC-5: Readiness Health Check + +**User Story:** As the core system, I need to verify that Auggie is correctly installed inside a running container before reporting it as ready. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL implement a Health_Check that verifies the `auggie` binary is present and executable inside the Container. +2. THE Health_Check SHALL be invoked by the core after the Container starts. +3. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Augment Code agent. + +--- + +### Requirement AC-6: No Core Coupling + +**User Story:** As a platform maintainer, I want the Augment Code module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE Augment Code module SHALL NOT be referenced by name or identifier anywhere in the core application code. +2. THE Augment Code module SHALL register itself with the Agent_Registry without requiring any modification to core source files. +3. THE core application SHALL function correctly (with no enabled agents) if the Augment Code module is not compiled in. + +--- + +## Cross-Cutting Concerns + +This agent implements the general agent contract (Req 7 & 8) and the **Agent Summary Info** mechanism (SI-1–SI-7) defined in the [parent index](../requirements-agents.md). diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-build-resources.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-build-resources.md new file mode 100644 index 0000000..363e806 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-build-resources.md @@ -0,0 +1,102 @@ +# Build Resources Agent Requirements + +## Overview + +Build Resources is a pseudo-agent that does not provide an AI coding tool. Instead, it installs common build toolchains and language runtimes into the container so that the development environment is ready for compilation, packaging, and general-purpose development tasks out of the box. It follows the standard agent module pattern (self-registers via `init()`, contributes Dockerfile steps, included in `DefaultAgents`) for architectural simplicity. + +### Glossary + +- **Build_Resources**: The set of system packages and language runtimes installed by this module: Python 3 (complete with setuptools/wheel), Python uv (system-wide via `UV_INSTALL_DIR`), CMake, build-essential, OpenJDK, Go, graphify (knowledge graph skill for AI coding assistants, installed via `uv tool install`), tree (directory listing utility), btop (terminal-based resource monitor), and common build dependencies (pkg-config, libssl-dev, libffi-dev, unzip, wget). + +--- + +### Requirement BR-1: Agent Identity + +**User Story:** As the core system, I need the Build Resources module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE Build Resources module SHALL declare the Agent_ID `"build-resources"`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. + +--- + +### Requirement BR-2: Installation + +**User Story:** As a developer, I want common build toolchains and language runtimes pre-installed in the container so I can compile and build projects immediately after connecting via SSH. + +#### Acceptance Criteria + +1. THE Build Resources module SHALL contribute Dockerfile steps that install **Python 3** (complete): `python3`, `python3-pip`, `python3-venv`, `python3-dev`, `python3-setuptools`, `python3-wheel`. +2. THE Build Resources module SHALL contribute Dockerfile steps that install **Python uv** via the official installer (`curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh`), installed system-wide to `/usr/local/bin`. +3. THE Build Resources module SHALL ensure `uv` is available on the default system `PATH` (via `/usr/local/bin`) without any additional shell profile configuration. +4. THE Build Resources module SHALL contribute Dockerfile steps that install **CMake**: `cmake`. +5. THE Build Resources module SHALL contribute Dockerfile steps that install **build-essential**: `build-essential` (provides gcc, g++, make, libc-dev). +6. THE Build Resources module SHALL contribute Dockerfile steps that install **OpenJDK**: `default-jdk` (provides both JDK and JRE). +7. THE Build Resources module SHALL contribute Dockerfile steps that install **Go** (latest stable) via the official tarball from `https://go.dev/dl/`, extracted to `/usr/local/go`, with `/usr/local/go/bin` added to the system-wide `PATH`. +8. THE Build Resources module SHALL contribute Dockerfile steps that install **common build dependencies**: `pkg-config`, `libssl-dev`, `libffi-dev`, `unzip`, `wget`. These are transitive dependencies commonly required when building Python, Go, and C/C++ packages from source. +9. THE Build Resources module SHALL contribute Dockerfile steps that install **terminal and directory utilities**: `tree` (directory listing), `btop` (terminal-based resource monitor). +10. THE Build Resources module SHALL contribute Dockerfile steps that install **graphify** via `UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy`. Graphify is an open-source knowledge graph skill for AI coding assistants (GitHub: safishamsi/graphify). After installation, `graphify install` SHALL be run to set it up as a Claude Code skill. Requires Python 3.10+ (satisfied by the python3 installation in AC-1). Note: `uv tool install` is used instead of `pip install` because Ubuntu 26.04 enforces PEP 668 (externally-managed-environment), and uv is already installed by this module (AC-2). The `UV_TOOL_BIN_DIR=/usr/local/bin` environment variable ensures the `graphify` executable is placed in a system-wide location accessible by all users (by default, `uv tool install` places executables in `$HOME/.local/bin` which would not be on the Container_User's PATH when the build runs as root). +11. ALL packages and runtimes SHALL be installed globally (system-wide), including uv which uses `UV_INSTALL_DIR=/usr/local/bin`. +12. ALL installed tools SHALL be available to the Container_User without manual intervention after the container starts. + +--- + +### Requirement BR-3: No Credential Store + +**User Story:** As the core system, I need the Build Resources module to conform to the Agent_Interface even though it has no credentials to manage. + +#### Acceptance Criteria + +1. THE Build Resources module SHALL return an empty string from `CredentialStorePath()` indicating no host-side credential directory. +2. THE Build Resources module SHALL return an empty string from `ContainerMountPath()` indicating no bind-mount is needed. +3. THE Build Resources module SHALL always return `(true, nil)` from `HasCredentials()` — there are no credentials to check. + +--- + +### Requirement BR-4: Readiness Health Check + +**User Story:** As the core system, I need to verify that all build toolchains are correctly installed inside a running container before reporting the agent as ready. + +#### Acceptance Criteria + +1. THE Build Resources module SHALL implement a Health_Check that verifies the following commands exit with code 0 inside the Container: + - `python3 --version` + - `uv --version` + - `cmake --version` + - `javac -version` + - `go version` (executed via `bash -lc` to pick up `/etc/profile.d/golang.sh`) + - `graphify --version` + - `tree --version` + - `btop --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. + +--- + +### Requirement BR-5: No Core Coupling + +**User Story:** As a platform maintainer, I want the Build Resources module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE Build Resources module SHALL NOT be referenced by name or identifier anywhere in the core application code. +2. THE Build Resources module SHALL register itself with the Agent_Registry without requiring any modification to core source files. +3. THE core application SHALL function correctly (with no enabled agents) if the Build Resources module is not compiled in. + +--- + +### Requirement BR-6: Default Inclusion + +**User Story:** As a developer, I want build toolchains installed by default so that the container is ready for development without needing to explicitly request them. + +#### Acceptance Criteria + +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. + +--- + +## Cross-Cutting Concerns + +This agent implements the general agent contract (Req 7 & 8) and the **Agent Summary Info** mechanism (SI-1–SI-7) defined in the [parent index](../requirements-agents.md). diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md new file mode 100644 index 0000000..d4ed5e5 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md @@ -0,0 +1,115 @@ +# Claude Code Agent Requirements + +## Overview + +Claude Code is Anthropic's AI coding agent. It is the first and default agent module for `bootstrap-ai-coding`. It is installed via npm, stores its authentication tokens in `~/.claude` on the Host, and is invoked inside the Container via the `claude` command. + +--- + +### Requirement CC-1: Agent Identity + +**User Story:** As the core system, I need the Claude Code module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL declare the Agent_ID `"claude-code"`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. + +--- + +### Requirement CC-2: Installation + +**User Story:** As a developer, I want Claude Code to be pre-installed in the container image so I can run it immediately after connecting via SSH. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL contribute Dockerfile steps that install Node.js (LTS) as a runtime dependency, compatible with the Base_Container_Image. Note: when both Claude Code and Augment Code are enabled (the default), the agents share a single Node.js installation. Since Augment Code requires Node.js 22+ (see AC-2), the installed version must satisfy both agents' requirements. In practice, Node.js 22 is the current LTS and satisfies both constraints. +2. THE Claude Code module SHALL contribute Dockerfile steps that install the `@anthropic-ai/claude-code` npm package globally. +3. WHEN the container image is built with Claude Code enabled, the `claude` command SHALL be available on the default `PATH` inside the Container for the Container_User. +4. THE installation steps SHALL NOT require any manual intervention after the container starts. + +--- + +### Requirement CC-3: Credential Store + +**User Story:** As a developer, I want my Claude Code authentication to persist across sessions so I only need to log in once. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL declare `~/.claude` as its default Credential_Store path on the Host. +2. THE Claude Code module SHALL declare `/.claude` as its Credential_Volume mount path inside the Container. +3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. +4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. +5. NOTE: Claude Code also stores onboarding state in `~/.claude.json` (outside the credential directory). See Requirement CC-8 for how this is handled via symlink and host-side synchronisation. + +--- + +### Requirement CC-4: Credential Presence Check + +**User Story:** As the core system, I need to know whether the user has already authenticated Claude Code so I can inform them if they haven't. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL implement a credential presence check that inspects the Credential_Store directory for existing authentication tokens. +2. THE credential presence check SHALL return `false` when the Credential_Store is empty or contains no recognisable Claude Code authentication tokens. +3. THE credential presence check SHALL return `true` when valid authentication tokens are present in the Credential_Store. +4. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `claude` inside the Container and complete the login flow. + +--- + +### Requirement CC-5: Readiness Health Check + +**User Story:** As the core system, I need to verify that Claude Code is correctly installed inside a running container before reporting it as ready. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL implement a Health_Check that verifies the `claude` binary is present and executable inside the Container. +2. THE Health_Check SHALL be invoked by the core after the Container starts. +3. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Claude Code agent. + +--- + +### Requirement CC-7: Headless Keyring for Credential Persistence + +**User Story:** As a developer, I want Claude Code to be able to read and refresh its OAuth tokens inside the container without a graphical desktop, so I don't have to re-authenticate every time I connect. + +#### Acceptance Criteria + +1. THE container image SHALL include a D-Bus session bus and a Secret Service–compatible keyring daemon (gnome-keyring) capable of running without a graphical display. +2. THE keyring daemon SHALL be started automatically when the Container_User's SSH session begins, using an empty password to unlock the default keyring. +3. Claude Code (and any other tool using `libsecret` / D-Bus Secret Service API) SHALL be able to store and retrieve credentials via the running keyring daemon without user interaction. +4. THE `DBUS_SESSION_BUS_ADDRESS` environment variable SHALL be set correctly for the Container_User's session so that client applications can locate the session bus. +5. THE keyring setup SHALL NOT interfere with the existing SSH-based authentication or the bind-mounted `~/.claude` credential store. +6. THE keyring packages and startup configuration SHALL be installed as part of the base container image (in `DockerfileBuilder`), not in individual agent modules, since multiple agents and IDE extensions may benefit from it. + +--- + +### Requirement CC-6: No Core Coupling + +**User Story:** As a platform maintainer, I want the Claude Code module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE Claude Code module SHALL NOT be referenced by name or identifier anywhere in the core application code. +2. THE Claude Code module SHALL register itself with the Agent_Registry without requiring any modification to core source files. +3. THE core application SHALL function correctly (with no enabled agents) if the Claude Code module is not compiled in. + +--- + +### Requirement CC-8: Onboarding State Synchronisation + +**User Story:** As a developer, I want my Claude Code onboarding state to persist across container recreations, so I am not prompted to complete the onboarding flow every time the container is rebuilt. + +#### Acceptance Criteria + +1. Claude Code stores its onboarding state (including `hasCompletedOnboarding`) in `~/.claude.json` on the Host — a file in the home directory root, separate from the `~/.claude/` credential directory. +2. THE Claude Code module SHALL create a symlink inside the Container at `/.claude.json` pointing to `/.claude/claude.json`, so that Claude Code reads and writes its onboarding state through the bind-mounted Credential_Volume. +3. THE Claude Code module SHALL implement the `CredentialPreparer` interface. Its `PrepareCredentials` method SHALL copy `~/.claude.json` from the Host home directory into the Credential_Store as `claude.json`, but only when the source file exists and is newer than the destination (or the destination is absent). +4. THE combination of the symlink (inside the container) and the host-side copy (before mount) SHALL ensure that a single bind-mount on `~/.claude/` persists both OAuth tokens and onboarding state across container rebuilds and restarts. +5. IF `~/.claude.json` does not exist on the Host (first-time user), THE `PrepareCredentials` method SHALL silently skip the copy without error. + +--- + +## Cross-Cutting Concerns + +This agent implements the general agent contract (Req 7 & 8) and the **Agent Summary Info** mechanism (SI-1–SI-7) defined in the [parent index](../requirements-agents.md). diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-codex.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-codex.md new file mode 100644 index 0000000..b05b1d6 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-codex.md @@ -0,0 +1,91 @@ +# Codex Agent Requirements + +## Overview + +Codex is OpenAI's AI coding agent, distributed as the `@openai/codex` npm package and invoked via the `codex` command. It requires Node.js 22 or later. Authentication tokens are stored in `~/.codex/auth.json` on the Host. Unlike the default agents, Codex is **opt-in only** — users must explicitly include it via `--agents codex`. + +### Glossary + +- **Codex_CLI**: The OpenAI Codex CLI tool, distributed as the `@openai/codex` npm package, providing an AI coding assistant powered by OpenAI models. + +--- + +### Requirement CX-1: Agent Identity + +**User Story:** As the core system, I need the Codex module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE Codex module SHALL declare the Agent_ID `"codex"`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. + +--- + +### Requirement CX-2: Installation + +**User Story:** As a developer, I want the Codex CLI to be pre-installed in the container image so I can run it immediately after connecting via SSH. + +#### Acceptance Criteria + +1. THE Codex module SHALL contribute Dockerfile steps that install Node.js 22 as a runtime dependency, using the shared `IsNodeInstalled()`/`MarkNodeInstalled()` deduplication pattern. +2. THE Codex module SHALL contribute Dockerfile steps that install the `@openai/codex` npm package globally via `npm install -g --no-fund --no-audit @openai/codex`. +3. WHEN the container image is built with the Codex agent enabled, the `codex` command SHALL be available on the default `PATH` inside the Container for the Container_User. +4. THE installation steps SHALL NOT require any manual intervention after the container starts. + +--- + +### Requirement CX-3: Credential Store + +**User Story:** As a developer, I want my Codex authentication to persist across sessions so I only need to log in once. + +#### Acceptance Criteria + +1. THE Codex module SHALL declare `~/.codex` as its default Credential_Store path on the Host. +2. THE Codex module SHALL declare `/.codex` as its Credential_Volume mount path inside the Container. +3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. +4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. + +--- + +### Requirement CX-4: Credential Presence Check + +**User Story:** As the core system, I need to know whether the user has already authenticated Codex so I can inform them if they haven't. + +#### Acceptance Criteria + +1. THE Codex module SHALL implement a credential presence check that inspects the Credential_Store directory for the `auth.json` file. +2. THE credential presence check SHALL return `true` when `auth.json` exists in the Credential_Store. +3. THE credential presence check SHALL return `false` when `auth.json` is absent or the Credential_Store directory does not exist. +4. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `codex` inside the Container and complete the login flow. + +--- + +### Requirement CX-5: Readiness Health Check + +**User Story:** As the core system, I need to verify that the Codex CLI is correctly installed inside a running container before reporting it as ready. + +#### Acceptance Criteria + +1. THE Codex module SHALL implement a Health_Check that executes `codex --version` inside the Container and verifies exit code 0. +2. THE Health_Check SHALL be invoked by the core after the Container starts. +3. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Codex agent. + +--- + +### Requirement CX-6: No Core Coupling + +**User Story:** As a platform maintainer, I want the Codex module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE Codex module SHALL NOT be referenced by name or identifier anywhere in the core application code. +2. THE Codex module SHALL register itself with the Agent_Registry without requiring any modification to core source files. +3. THE core application SHALL function correctly (with no enabled agents) if the Codex module is not compiled in. + +> **Note:** The Codex agent is NOT included in `constants.DefaultAgents`. It is opt-in only — users must explicitly pass `--agents codex` or `--agents claude-code,codex` to include it. + +--- + +## Cross-Cutting Concerns + +This agent implements the general agent contract (Req 7 & 8) and the **Agent Summary Info** mechanism (SI-1–SI-7) defined in the [parent index](../requirements-agents.md). diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-opencode.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-opencode.md new file mode 100644 index 0000000..045934a --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-opencode.md @@ -0,0 +1,100 @@ +# OpenCode Agent Requirements + +## Overview + +OpenCode is an open-source AI coding agent for the terminal, developed by Anomaly (anomalyco). It is distributed as the `opencode-ai` npm package and invoked via the `opencode` command. It requires Node.js 18 or later (the project installs Node.js 22, which satisfies this). Authentication credentials are stored in `~/.local/share/opencode/auth.json` on the Host, and provider configuration lives in `~/.config/opencode/` on the Host. Unlike the default agents, OpenCode is **opt-in only** — users must explicitly include it via `--agents open-code`. + +### Glossary + +- **OpenCode_CLI**: The OpenCode terminal AI coding agent, distributed as the `opencode-ai` npm package, providing the `opencode` binary. Requires Node.js 18 or later. +- **OpenCode_Auth_Store**: The host-side directory `~/.local/share/opencode/` where OpenCode persists authentication credentials (`auth.json`). +- **OpenCode_Config_Store**: The host-side directory `~/.config/opencode/` where OpenCode persists provider configuration and session data. + +--- + +### Requirement OC-1: Agent Identity + +**User Story:** As the core system, I need the OpenCode module to declare a stable, unique identifier so it can be selected via the `--agents` flag. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL declare the Agent_ID `"open-code"`. +2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. + +--- + +### Requirement OC-2: Installation + +**User Story:** As a developer, I want OpenCode to be pre-installed in the container image so I can run it immediately after connecting via SSH. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL contribute Dockerfile steps that install Node.js 22 as a runtime dependency, using the shared `IsNodeInstalled()`/`MarkNodeInstalled()` deduplication pattern. +2. THE OpenCode module SHALL contribute Dockerfile steps that install the `opencode-ai` npm package globally via `npm install -g --no-fund --no-audit opencode-ai`. +3. WHEN the container image is built with the OpenCode agent enabled, the `opencode` command SHALL be available on the default `PATH` inside the Container for the Container_User. +4. THE installation steps SHALL NOT require any manual intervention or environment variable configuration after the container starts. + +--- + +### Requirement OC-3: Credential Store + +**User Story:** As a developer, I want my OpenCode authentication and configuration to persist across sessions so I only need to log in once. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL declare `~/.local/share/opencode` as its primary Credential_Store path on the Host (OpenCode_Auth_Store). +2. THE OpenCode module SHALL declare `/.local/share/opencode` as the primary Credential_Volume mount path inside the Container. +3. THE OpenCode module SHALL declare `~/.config/opencode` as its secondary Credential_Store path on the Host (OpenCode_Config_Store) via the `AdditionalMounter` interface. +4. THE OpenCode module SHALL declare `/.config/opencode` as the secondary Credential_Volume mount path inside the Container. +5. EACH Credential_Volume SHALL be a bind-mount so that authentication tokens and configuration written inside the Container are immediately persisted to the respective Host directory. +6. Authentication tokens persisted in the Host OpenCode_Auth_Store SHALL be available in future Sessions without re-authentication. +7. Provider configuration persisted in the Host OpenCode_Config_Store SHALL be available in future Sessions without reconfiguration. +8. IF the Host OpenCode_Auth_Store or OpenCode_Config_Store directory does not exist at container start time, THEN THE core SHALL create it with permissions `0700` before mounting. + +--- + +### Requirement OC-4: Credential Presence Check + +**User Story:** As the core system, I need to know whether the user has already authenticated OpenCode so I can inform them if they haven't. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL implement a credential presence check that inspects the OpenCode_Auth_Store directory for the `auth.json` file. +2. THE credential presence check SHALL return `true` when `auth.json` exists in the OpenCode_Auth_Store and has a file size greater than 0 bytes. +3. THE credential presence check SHALL return `false` with a nil error when `auth.json` is absent, has a file size of 0 bytes, or the OpenCode_Auth_Store directory does not exist. +4. IF a filesystem error other than file-not-found or directory-not-found occurs, THE credential presence check SHALL return `false` and a non-nil error describing the failure. +5. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `opencode` inside the Container and complete the authentication flow. + +--- + +### Requirement OC-5: Readiness Health Check + +**User Story:** As the core system, I need to verify that the OpenCode CLI is correctly installed inside a running container before reporting it as ready. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL implement a Health_Check that executes `opencode --version` inside the Container and verifies exit code 0. +2. THE Health_Check SHALL be invoked by the core after the Container starts. +3. IF `opencode --version` returns a non-zero exit code, THE OpenCode module SHALL return an error indicating that the health check failed, including the exit code. +4. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the OpenCode agent. + +--- + +### Requirement OC-6: No Core Coupling + +**User Story:** As a platform maintainer, I want the OpenCode module to be fully self-contained so that removing or replacing it requires no changes to core code. + +#### Acceptance Criteria + +1. THE OpenCode module SHALL NOT be referenced by name or identifier anywhere in the core application code. +2. THE OpenCode module SHALL register itself with the Agent_Registry without requiring any modification to core source files. +3. THE core application SHALL function correctly (with no enabled agents) if the OpenCode module is not compiled in. +4. THE string literal `"open-code"` SHALL NOT appear in any source file under `internal/cmd/`, `internal/naming/`, `internal/docker/`, `internal/ssh/`, `internal/datadir/`, `internal/pathutil/`, `internal/hostinfo/`, or `internal/agent/`. + +> **Note:** The OpenCode agent is NOT included in `constants.DefaultAgents`. It is opt-in only — users must explicitly pass `--agents open-code` or `--agents claude-code,open-code` to include it. + +--- + +## Cross-Cutting Concerns + +This agent implements the general agent contract (Req 7 & 8) and the **Agent Summary Info** mechanism (SI-1–SI-7) defined in the [parent index](../requirements-agents.md). diff --git a/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md b/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md index 05d0653..cb10b23 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md +++ b/.kiro/specs/bootstrap-ai-coding/design-agent-summary-info.md @@ -4,19 +4,17 @@ > - [design.md](design.md) — Overview and document index > - [requirements-agent-summary-info.md](requirements-agent-summary-info.md) — Requirements (SI-1 through SI-7) > - [design-components.md](design-components.md) — Core component designs (Agent Interface, SessionSummary) -> - [design-vibekanban.md](design-vibekanban.md) — Vibe Kanban agent module design --- ## Overview -This design describes the Agent Summary Info mechanism — an extension to the `Agent` interface that allows agent modules to contribute key:value pairs to the session summary printed after a successful container start. The primary motivation is to remove all Vibe Kanban–specific logic from the core (`internal/cmd/root.go`), restoring the architectural rule that "core has zero knowledge of agents." +This design describes the Agent Summary Info mechanism — an extension to the `Agent` interface that allows agent modules to contribute key:value pairs to the session summary printed after a successful container start. The mechanism enforces the architectural rule that "core has zero knowledge of agents." -The refactoring: +The design: 1. Adds a `KeyValue` struct and `SummaryInfo` method to the `Agent` interface -2. Moves port discovery logic from `root.go` into the `vibekanban` package -3. Makes the core iterate generically over agents to collect summary info -4. Removes all agent-specific references from `root.go` +2. Makes the core iterate generically over agents to collect summary info +3. Removes all agent-specific references from `root.go` --- @@ -26,21 +24,21 @@ The refactoring: sequenceDiagram participant Core as cmd/root.go participant Agent1 as Agent (claude) - participant Agent2 as Agent (vibekanban) + participant Agent2 as Agent (example) participant Docker as Docker Client Note over Core: Container started successfully Core->>Agent1: SummaryInfo(ctx, client, containerID) Agent1-->>Core: (nil, nil) Core->>Agent2: SummaryInfo(ctx, client, containerID) - Agent2->>Docker: ExecInContainerWithOutput(cat /tmp/vibe-kanban.port) - Docker-->>Agent2: "39497" - Agent2-->>Core: ([]KeyValue{{Key:"Vibe Kanban", Value:"http://localhost:39497"}}, nil) + Agent2->>Docker: ExecInContainerWithOutput(...) + Docker-->>Agent2: "8080" + Agent2-->>Core: ([]KeyValue{{Key:"My Agent", Value:"http://localhost:8080"}}, nil) Note over Core: Collect all KeyValue pairs Note over Core: FormatSessionSummary with AgentInfo ``` -The core treats all agents uniformly — it never inspects the returned keys or values, never branches on agent IDs, and never references `constants.VibeKanbanAgentName`. +The core treats all agents uniformly — it never inspects the returned keys or values, never branches on agent IDs, and never references any agent-specific constant. --- @@ -90,12 +88,10 @@ type SessionSummary struct { SSHPort int SSHConnect string EnabledAgents []string - AgentInfo []agent.KeyValue // replaces VibeKanbanURL + AgentInfo []agent.KeyValue // generic agent info pairs } ``` -The `VibeKanbanURL string` field is removed entirely. - ### Updated FormatSessionSummary ```go @@ -116,7 +112,7 @@ func FormatSessionSummary(s SessionSummary) string { **Design decisions:** - The format string `"%-17s%s\n"` left-pads the key (with colon) to 17 characters, aligning values with the existing fields (`"Data directory: "` is 17 chars including the trailing spaces). - No conditional logic — the loop handles zero, one, or many entries uniformly. -- When `AgentInfo` is nil or empty, the loop body never executes, producing output identical to the current format (minus the removed Vibe Kanban line). +- When `AgentInfo` is nil or empty, the loop body never executes, producing output identical to the standard five-field format. --- @@ -159,56 +155,7 @@ func printSessionSummary(dd *datadir.DataDir, projectDir string, containerName s } ``` -The `vibeKanbanURL string` parameter is removed and replaced by the generic `agentInfo []agent.KeyValue`. - ---- - -## Vibe Kanban SummaryInfo Implementation - -The port discovery uses a **port file** approach for robustness. The supervisor script starts vibe-kanban in the background, discovers its auto-assigned port by polling `ss -tlnp` filtered by the exact PID, and writes the port to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method simply reads this file. - -This design is robust because: -- It doesn't depend on process names in `ss` output (which vary by platform/version) -- It doesn't break when other services bind ports in the container -- It uses PID-based filtering in the supervisor, which is unambiguous - -```go -// vibeKanbanPortFile is the well-known path where the supervisor writes -// the auto-assigned port after vibe-kanban starts. -const vibeKanbanPortFile = "/tmp/vibe-kanban.port" - -// SummaryInfo reads the port file written by the supervisor script. -// Retries for up to 30 seconds with 2-second intervals. -func (a *vibeKanbanAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { - deadline := time.Now().Add(30 * time.Second) - for time.Now().Before(deadline) { - exitCode, output, err := docker.ExecInContainerWithOutput(ctx, c, containerID, - []string{"cat", vibeKanbanPortFile}) - if err != nil { - return nil, err - } - if exitCode == 0 { - portStr := strings.TrimSpace(output) - port, err := strconv.Atoi(portStr) - if err == nil && port > 0 && port <= 65535 { - return []agent.KeyValue{ - {Key: "Vibe Kanban", Value: fmt.Sprintf("http://localhost:%d", port)}, - }, nil - } - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(2 * time.Second): - } - } - return nil, fmt.Errorf("timed out after 30s waiting for vibe-kanban port file") -} -``` - -**New imports in `vibekanban.go`:** `"strconv"`. - -**Removed from `vibekanban.go`:** `"regexp"` (no longer needed — port file contains a plain integer). +The previous agent-specific URL parameter is removed and replaced by the generic `agentInfo []agent.KeyValue`. --- @@ -237,31 +184,14 @@ func (a *buildResourcesAgent) SummaryInfo(ctx context.Context, c *docker.Client, --- -## What Gets Removed from root.go - -The following items are deleted from `internal/cmd/root.go`: - -| Item | Type | Reason | -|---|---|---| -| `VibeKanbanURL string` | Field on `SessionSummary` | Replaced by `AgentInfo []agent.KeyValue` | -| `discoverVibeKanbanPort()` | Function | Moved to `vibekanban.SummaryInfo()` | -| `portRegexp` | Package-level `var` | Moved to `vibekanban` package | -| `constants.VibeKanbanAgentName` reference | Import usage | Core no longer references any agent by name | -| Vibe Kanban URL conditional in `FormatSessionSummary` | `if` block | Replaced by generic `AgentInfo` loop | -| Vibe Kanban discovery blocks in `runStart` | Two code blocks (reconnect path + fresh start path) | Replaced by generic collection loop | - -After this refactoring, `root.go` no longer imports or references any agent-specific constant. The `"regexp"` and `"strconv"` imports can also be removed from `root.go` (they were only used by `discoverVibeKanbanPort`). - ---- - ## Data Models ### KeyValue (new) | Field | Type | Description | |---|---|---| -| `Key` | `string` | Label for the summary line (e.g. `"Vibe Kanban"`) | -| `Value` | `string` | Content for the summary line (e.g. `"http://localhost:3000"`) | +| `Key` | `string` | Label for the summary line (e.g. `"My Agent"`) | +| `Value` | `string` | Content for the summary line (e.g. `"http://localhost:8080"`) | ### SessionSummary (updated) @@ -272,7 +202,6 @@ After this refactoring, `root.go` no longer imports or references any agent-spec | `SSHPort` | `int` | unchanged | | `SSHConnect` | `string` | unchanged | | `EnabledAgents` | `[]string` | unchanged | -| `VibeKanbanURL` | `string` | **removed** | | `AgentInfo` | `[]agent.KeyValue` | **added** | --- @@ -293,12 +222,6 @@ After this refactoring, `root.go` no longer imports or references any agent-spec **Validates: Requirements SI-2.3, SI-2.4, SI-7.2, SI-7.3, SI-7.4** -### Property 3: Vibe Kanban URL format - -*For any* valid TCP port number (1–65535), the Vibe Kanban `SummaryInfo` URL value SHALL be exactly `"http://localhost:"` where `` is the decimal string representation of the port number. - -**Validates: Requirements SI-5.2** - --- ## Error Handling @@ -309,7 +232,7 @@ After this refactoring, `root.go` no longer imports or references any agent-spec | Agent's `SummaryInfo()` returns `(nil, nil)` | No lines added. No warning. | | Agent's `SummaryInfo()` returns `([]KeyValue{}, nil)` | Same as nil — no lines added. | | Context cancelled during `SummaryInfo()` | Agent returns `ctx.Err()`. Core prints warning, continues with remaining agents. | -| Vibe Kanban port discovery times out (30s) | Returns error. Core prints warning. Session summary omits Vibe Kanban URL. Startup succeeds. | +| Agent port/resource discovery times out | Returns error. Core prints warning. Session summary omits that agent's info. Startup succeeds. | --- @@ -321,7 +244,6 @@ After this refactoring, `root.go` no longer imports or references any agent-spec |---|---|---| | Property 1: Collection order | Random slices of `([]KeyValue, error)` tuples | Collected output matches expected filtered/ordered result | | Property 2: Formatting | Random `SessionSummary` with random `AgentInfo` | All keys/values present, after "Enabled agents", no extras when empty | -| Property 3: URL format | Random port in 1–65535 | URL matches `"http://localhost:"` exactly | Each property test runs minimum 100 iterations. Tag format: ```go @@ -343,8 +265,8 @@ Each property test runs minimum 100 iterations. Tag format: | Test | What it verifies | |---|---| -| `TestVibeKanbanSummaryInfoDiscoversPort` | With a running container, `SummaryInfo()` returns the correct URL | -| `TestSessionSummaryContainsVibeKanbanURL` | Full start flow prints Vibe Kanban URL via the generic mechanism | +| `TestAgentSummaryInfoDiscoversInfo` | With a running container, `SummaryInfo()` returns the correct info | +| `TestSessionSummaryContainsAgentInfo` | Full start flow prints agent info via the generic mechanism | ### What is NOT tested with PBT diff --git a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md index 49f04d5..3b4a33e 100644 --- a/.kiro/specs/bootstrap-ai-coding/design-build-resources.md +++ b/.kiro/specs/bootstrap-ai-coding/design-build-resources.md @@ -19,7 +19,7 @@ Build Resources is a pseudo-agent that installs common build toolchains and lang **Package:** `internal/agents/buildresources/buildresources.go` -**Validates: BR-1 through BR-6** +**Validates: BR-1 through BR-6** (including BR-2.9, BR-2.10 for tree/btop/graphify and BR-4 updated health checks) --- @@ -51,7 +51,7 @@ func (a *buildResourcesAgent) ID() string { } // Install appends Dockerfile RUN steps that install Python 3, uv, CMake, -// build-essential, OpenJDK, and Go. +// build-essential, OpenJDK, Go, graphify, tree, and btop. // Satisfies: BR-2 func (a *buildResourcesAgent) Install(b *docker.DockerfileBuilder) { // All apt packages installed by this agent, listed explicitly for easy @@ -66,6 +66,8 @@ func (a *buildResourcesAgent) Install(b *docker.DockerfileBuilder) { "default-jdk", // Common build dependencies "libssl-dev", "libffi-dev", + // Terminal and directory utilities + "tree", "btop", // Utilities "curl", "ca-certificates", "unzip", "wget", } @@ -82,6 +84,14 @@ func (a *buildResourcesAgent) Install(b *docker.DockerfileBuilder) { // Python uv — installed system-wide to /usr/local/bin via official installer. // Using UV_INSTALL_DIR avoids user-local PATH issues with docker exec (runs as root). b.Run("curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh") + + // graphify — knowledge graph skill for AI coding assistants. + // Installed via uv tool install into an isolated venv; UV_TOOL_BIN_DIR places + // the executable in /usr/local/bin so it's on all users' PATH. + b.Run("UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy") + + // Set up graphify as a Claude Code skill (creates files in ~/.claude/skills/graphify/). + b.Run("graphify install") } // CredentialStorePath returns empty — no credentials to persist. @@ -114,6 +124,9 @@ func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, {[]string{"cmake", "--version"}, "cmake"}, {[]string{"javac", "-version"}, "javac"}, {[]string{"bash", "-lc", "go version"}, "go"}, + {[]string{"graphify", "--version"}, "graphify"}, + {[]string{"tree", "--version"}, "tree"}, + {[]string{"btop", "--version"}, "btop"}, } for _, chk := range checks { exitCode, err := docker.ExecInContainer(ctx, c, containerID, chk.cmd) @@ -140,13 +153,19 @@ func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, 4. **Go via official tarball:** The Go binary is installed from `go.dev/dl/` to `/usr/local/go` with PATH set via `/etc/profile.d/golang.sh`. This ensures the latest stable version regardless of what Ubuntu's package manager offers. -5. **Health check uses `bash -lc` only for Go:** Go is available via a PATH entry in `/etc/profile.d/golang.sh`. Running it through `bash -lc` ensures the login profile is sourced. All other tools (python3, uv, cmake, javac) are on the default PATH and don't need login shell invocation. +5. **Health check uses `bash -lc` only for Go:** Go is available via a PATH entry in `/etc/profile.d/golang.sh`. Running it through `bash -lc` ensures the login profile is sourced. All other tools (python3, uv, cmake, javac, graphify, tree, btop) are on the default PATH and don't need login shell invocation. 6. **`RunAsUser` builder method:** The `DockerfileBuilder` has a `RunAsUser(cmd string)` helper that emits `USER ` before the `RUN` and `USER root` after. While the Build Resources agent no longer uses it (all installs are system-wide), it remains available for future agents that need user-local installations. 7. **`goVersion` private constant:** The Go version is declared as a private `const goVersion` in the agent package, making it easy to bump without searching through string literals. -7. **Default inclusion:** Added to `constants.DefaultAgents` so it's always present unless the user explicitly overrides `--agents`. This means `go run . /path` installs Claude Code + Augment Code + Build Resources by default. +8. **Default inclusion:** Added to `constants.DefaultAgents` so it's always present unless the user explicitly overrides `--agents`. This means `go run . /path` installs Claude Code + Augment Code + Build Resources by default. + +9. **`UV_TOOL_BIN_DIR=/usr/local/bin` for graphify:** By default, `uv tool install` places executables in `$HOME/.local/bin` (XDG convention). During Docker build (running as root), that resolves to `/root/.local/bin`, which is not on the Container_User's PATH. Setting `UV_TOOL_BIN_DIR=/usr/local/bin` ensures the `graphify` executable is placed in a system-wide location accessible to all users without additional PATH configuration. + +10. **`uv tool install` over `pip install`:** Ubuntu 26.04 enforces PEP 668 (externally-managed-environment), which prevents `pip install` from installing packages into the system Python. `uv tool install` is preferred because: (a) it creates an isolated venv for the tool (cleaner than `--break-system-packages`), and (b) `uv` is already installed by this module (AC-2), so no additional dependency is needed. + +11. **`graphify install` post-installation step:** After `uv tool install graphifyy` makes the executable available, `graphify install` must be run to set up the Claude Code skill. This creates skill files in `~/.claude/skills/graphify/` that Claude Code discovers at runtime. --- @@ -185,8 +204,11 @@ 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 tree btop ← Build Resources (terminal utilities) RUN go tarball + /etc/profile.d/golang.sh ← Build Resources (Go) RUN uv install (UV_INSTALL_DIR=/usr/local/bin) ← Build Resources (uv) +RUN UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy ← Build Resources (graphify) +RUN graphify install ← Build Resources (graphify Claude Code skill) RUN echo manifest > /bac-manifest.json ← manifest # NO CMD — that belongs in Instance_Image ``` 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 ba9c5fe..0000000 --- a/.kiro/specs/bootstrap-ai-coding/design-vibekanban.md +++ /dev/null @@ -1,733 +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 - -Vibe Kanban auto-assigns its port at startup (VK-9.1). The supervisor script discovers the port by parsing the server's log output and writes it to a well-known file: - -1. The supervisor starts vibe-kanban in the background, redirecting stdout/stderr to a log file, and captures its PID -2. It polls the log file for the "Main server on :" message (up to 30 seconds) -3. Once found, it writes the port number to `/tmp/vibe-kanban.port` -4. `SummaryInfo()` reads this file (retrying up to 30 seconds) - -This approach is robust because: -- It uses the server's own log output (unambiguous, no dependency on `ss` output format) -- It works regardless of how many other services bind ports in the container -- It avoids conflicts when multiple containers share the host network namespace (each gets a unique auto-assigned port) -- The port file is cleared (`rm -f`) before each restart so stale values are never read - -See [design-agent-summary-info.md](design-agent-summary-info.md) for the `SummaryInfo()` implementation details. - -### 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: Agent-Owned SummaryInfo Pattern - -The session summary is **core-agnostic**. There is no Vibe Kanban–specific field on `SessionSummary`. Instead, every agent implements `SummaryInfo(ctx, c, containerID) ([]agent.KeyValue, error)` and the core renderer (`FormatSessionSummary`) displays whatever key-value pairs agents provide: - -```go -type SessionSummary struct { - DataDir string - ProjectDir string - SSHPort int - SSHConnect string - EnabledAgents []string - AgentInfo []agent.KeyValue // populated by calling SummaryInfo on each enabled agent -} -``` - -`FormatSessionSummary` iterates `AgentInfo` without knowledge of which agent produced which entry: - -```go -func FormatSessionSummary(s SessionSummary) string { - var sb strings.Builder - fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) - fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) - fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) - fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) - fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) - for _, kv := range s.AgentInfo { - fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) - } - return sb.String() -} -``` - -### Port Discovery: Agent-Side Responsibility - -Port discovery is **not** performed by core code. The Vibe Kanban agent's `SummaryInfo()` implementation is responsible for discovering its own port. It reads the port file (`/tmp/vibe-kanban.port`) written by the supervisor script, retrying for up to 30 seconds. The generic `docker.ExecInContainerWithOutput` helper is used to read the file inside the container. - -See [design-agent-summary-info.md](design-agent-summary-info.md) for the full `SummaryInfo()` contract and implementation details. - ---- - -## 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 -PORT_FILE="/tmp/vibe-kanban.port" -LOG_FILE="/tmp/vibe-kanban.log" -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)") - - # Clear stale port file and start vibe-kanban in background - rm -f "$PORT_FILE" - su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "$USERNAME" > "$LOG_FILE" 2>&1 & - VK_PID=$! - - # Wait up to 30s for the port to appear in the log output - for i in $(seq 1 30); do - sleep 1 - if [ -f "$LOG_FILE" ]; then - PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) - if [ -n "$PORT" ]; then - echo "$PORT" > "$PORT_FILE" - break - fi - fi - done - - # Wait for vibe-kanban to exit (crash or shutdown) - wait $VK_PID 2>/dev/null || true - - # Wait before restarting - sleep "$DELAY_SECONDS" -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 -PORT_FILE="/tmp/vibe-kanban.port" -LOG_FILE="/tmp/vibe-kanban.log" -RESTART_TIMES=() -while true; do - NOW=$(date +%%s) - PRUNED=() - for ts in "${RESTART_TIMES[@]}"; do - if (( NOW - ts < WINDOW_SECONDS )); then - PRUNED+=("$ts") - fi - done - RESTART_TIMES=("${PRUNED[@]}") - if (( ${#RESTART_TIMES[@]} >= MAX_RESTARTS )); then - echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 - exit 1 - fi - RESTART_TIMES+=("$(date +%%s)") - rm -f "$PORT_FILE" - su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "%s" > "$LOG_FILE" 2>&1 & - VK_PID=$! - # Wait up to 30s for the port to appear in the log output - for i in $(seq 1 30); do - sleep 1 - if [ -f "$LOG_FILE" ]; then - PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) - if [ -n "$PORT" ]; then - echo "$PORT" > "$PORT_FILE" - break - fi - fi - done - wait $VK_PID 2>/dev/null || true - sleep "$DELAY_SECONDS" -done`, username) - - 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 (core-agnostic): - -```go -type SessionSummary struct { - DataDir string - ProjectDir string - SSHPort int - SSHConnect string - EnabledAgents []string - AgentInfo []agent.KeyValue // populated by calling SummaryInfo on each enabled agent -} -``` - -#### FormatSessionSummary (core-agnostic renderer): - -```go -func FormatSessionSummary(s SessionSummary) string { - var sb strings.Builder - fmt.Fprintf(&sb, "Data directory: %s\n", s.DataDir) - fmt.Fprintf(&sb, "Project directory: %s\n", s.ProjectDir) - fmt.Fprintf(&sb, "SSH port: %d\n", s.SSHPort) - fmt.Fprintf(&sb, "SSH connect: %s\n", s.SSHConnect) - fmt.Fprintf(&sb, "Enabled agents: %s\n", strings.Join(s.EnabledAgents, ", ")) - for _, kv := range s.AgentInfo { - fmt.Fprintf(&sb, "%-17s%s\n", kv.Key+":", kv.Value) - } - return sb.String() -} -``` - -#### Agent summary collection in `runStart()`: - -After health checks pass and before printing the session summary, `runStart()` calls `SummaryInfo()` on every enabled agent generically — no agent-specific branching: - -```go -// Collect agent summary info. -var agentInfo []agent.KeyValue -for _, a := range enabledAgents { - kvs, err := a.SummaryInfo(ctx, c, containerName) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: %s summary info: %v\n", a.ID(), err) - continue - } - agentInfo = append(agentInfo, kvs...) -} -``` - -The core has **no** `discoverVibeKanbanPort` function and does **not** check `constants.VibeKanbanAgentName`. Port discovery is entirely the agent's responsibility inside its `SummaryInfo()` implementation. - -### 5. `main.go` - -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 log parsing (not `ss` process name matching) - -**Why:** Vibe Kanban auto-assigns its port at startup. The supervisor discovers the port by parsing the server's stdout for the "Main server on :" message and writes it to `/tmp/vibe-kanban.port`. The `SummaryInfo()` method reads this file. This is more reliable than parsing `ss` output in `SummaryInfo()` because: (a) the Rust binary name in `ss` output varies by platform/version, (b) other services may bind ports in the container, and (c) log-based parsing in the supervisor is unambiguous since it captures the server's own output directly. - -### 5. 30-second timeout for port discovery - -**Why:** Vibe Kanban needs time to start up (Node.js initialization, port binding). 30 seconds is generous but bounded. If it fails, `SummaryInfo()` returns an error, the core prints a warning, and the URL is omitted from the summary. The container is still usable for SSH and other agents. - -### 6. Graceful degradation for port discovery failure - -**Why (VK-8.4):** If `SummaryInfo()` times out, the core's generic error handling prints a warning and omits that agent's key-value pairs from the session summary. The user can still SSH into the container and discover the port manually. This prevents a flaky network or slow startup from blocking the entire workflow. - -### 7. `HOST=0.0.0.0` environment variable for vibe-kanban - -**Why:** The vibe-kanban Rust binary reads the `HOST` environment variable to determine its listen address (defaults to `127.0.0.1`). Setting `HOST=0.0.0.0` ensures the server accepts connections on all interfaces, which is required for host network mode accessibility. The `BROWSER=none` variable is also set to suppress the automatic browser-open attempt in the headless container environment. - -### 8. Zero core coupling — agent-owned SummaryInfo pattern - -The core (`cmd/root.go`) has **no** Vibe Kanban–specific code. It does not reference `constants.VibeKanbanAgentName` for port discovery or session summary rendering. Instead: - -- Every agent implements `SummaryInfo(ctx, c, containerID) ([]agent.KeyValue, error)`. -- The core iterates all enabled agents, calls `SummaryInfo()`, and appends the returned key-value pairs to the session summary. -- Port discovery, URL formatting, and timeout logic live entirely inside the Vibe Kanban agent's `SummaryInfo()` method. -- The generic `docker.ExecInContainerWithOutput` helper is used by the agent to read the port file inside the container. - -This satisfies VK-6.1 (no core coupling) without conflicting with VK-8.3 (session summary includes the URL), because the agent itself provides the URL through the generic interface. - ---- - -## 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 | -| `SummaryInfo()` port discovery times out (30s) | Core prints warning, URL omitted from summary, startup succeeds | -| `SummaryInfo()` exec fails | Core prints warning, URL omitted from summary, startup succeeds | -| `--host-network-off` (bridge mode) | URL still shown; accessibility depends on Docker port mapping (outside agent scope) | - ---- - -## 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 | -| `TestFormatSessionSummaryWithAgentInfo` | AgentInfo key-value pairs rendered when present | -| `TestFormatSessionSummaryWithoutAgentInfo` | No extra lines when AgentInfo 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 agent-provided key-value pairs for any valid content - -*For any* non-empty `AgentInfo` slice containing `KeyValue{Key: "Vibe Kanban", Value: "http://localhost:"}` where port is a valid TCP port (1-65535), `FormatSessionSummary()` SHALL include a line containing that URL. When `AgentInfo` is empty or nil, the output SHALL NOT contain any agent-specific lines beyond the standard fields. - -**Validates: Requirements VK-8.3 (via the generic SummaryInfo pattern)** - -### 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 ac4f8a8..c9a831f 100644 --- a/.kiro/specs/bootstrap-ai-coding/design.md +++ b/.kiro/specs/bootstrap-ai-coding/design.md @@ -29,14 +29,13 @@ 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 | -| [`design-agent-summary-info.md`](design-agent-summary-info.md) | Agent Summary Info: KeyValue type, SummaryInfo interface method, generic collection, Vibe Kanban port discovery migration | +| [`design-agent-summary-info.md`](design-agent-summary-info.md) | Agent Summary Info: KeyValue type, SummaryInfo interface method, generic collection | ## 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-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-6) - `requirements-two-layer-image.md` — two-layer Docker image requirements (TL-1–TL-11) - `requirements-agent-summary-info.md` — Agent Summary Info requirements (SI-1–SI-7) diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md b/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md index 98f68d6..de210eb 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agent-summary-info.md @@ -2,14 +2,14 @@ ## Overview -Agent Summary Info is a mechanism that allows agent modules to contribute their own key:value pairs to the session summary printed by the core after a successful container start. This eliminates the need for the core to contain agent-specific logic (such as port discovery for Vibe Kanban) and restores the architectural rule that "core has zero knowledge of agents." +Agent Summary Info is a mechanism that allows agent modules to contribute their own key:value pairs to the session summary printed by the core after a successful container start. This eliminates the need for the core to contain agent-specific logic and restores the architectural rule that "core has zero knowledge of agents." Each agent module implements a `SummaryInfo` method on the Agent_Interface. The core iterates over enabled agents, calls `SummaryInfo()` on each, and appends any returned key:value pairs to the session summary output. Agents that have nothing to report return nil. ## Glossary - **Summary_Info**: A slice of key:value pairs returned by an agent's `SummaryInfo()` method, representing additional labelled lines to include in the session summary. -- **KeyValue**: A struct with `Key string` and `Value string` fields, representing a single labelled line in the session summary (e.g. Key=`"Vibe Kanban"`, Value=`"http://localhost:3000"`). +- **KeyValue**: A struct with `Key string` and `Value string` fields, representing a single labelled line in the session summary (e.g. Key=`"My Agent"`, Value=`"http://localhost:8080"`). --- @@ -54,33 +54,20 @@ Each agent module implements a `SummaryInfo` method on the Agent_Interface. The --- -### Requirement SI-4: Remove Hardcoded Vibe Kanban Logic from Core +### Requirement SI-4: Remove Hardcoded Agent Logic from Core -**User Story:** As a platform maintainer, I want all Vibe Kanban–specific logic removed from the core so that the architectural rule "core has zero knowledge of agents" is restored. +**User Story:** As a platform maintainer, I want all agent-specific logic removed from the core so that the architectural rule "core has zero knowledge of agents" is restored. #### Acceptance Criteria -1. THE `VibeKanbanURL` field SHALL be removed from the `SessionSummary` struct in `internal/cmd/root.go`. -2. THE `discoverVibeKanbanPort()` function SHALL be removed from `internal/cmd/root.go`. -3. THE `constants.VibeKanbanAgentName` reference SHALL be removed from `internal/cmd/root.go` — the core SHALL NOT reference any agent by name or identifier. -4. THE `FormatSessionSummary` function SHALL NOT contain any conditional logic specific to Vibe Kanban or any other individual agent. +1. THE `SessionSummary` struct in `internal/cmd/root.go` SHALL NOT contain fields specific to any individual agent. +2. THE core SHALL NOT contain functions that discover or query individual agent state (e.g. port discovery for a specific agent). +3. THE core SHALL NOT reference any agent by name or identifier — all agent interaction goes through the Agent_Interface. +4. THE `FormatSessionSummary` function SHALL NOT contain any conditional logic specific to any individual agent. --- -### Requirement SI-5: Vibe Kanban SummaryInfo Implementation - -**User Story:** As a developer, I want the Vibe Kanban agent to report its URL via the `SummaryInfo` mechanism so that the session summary still shows the Vibe Kanban URL without the core containing Vibe Kanban–specific code. - -#### Acceptance Criteria - -1. THE Vibe Kanban module SHALL implement `SummaryInfo()` by reading the port file (`/tmp/vibe-kanban.port`) written by the supervisor script after vibe-kanban starts and binds its auto-assigned port. -2. WHEN the Vibe Kanban port is successfully discovered, THE `SummaryInfo()` method SHALL return a single `KeyValue` with Key `"Vibe Kanban"` and Value `"http://localhost:"`. -3. IF the Vibe Kanban port file cannot be read within 30 seconds (retrying every 2 seconds), THEN THE `SummaryInfo()` method SHALL return a non-nil error describing the timeout. -4. THE port discovery logic SHALL reside entirely in the Vibe Kanban agent package (`internal/agents/vibekanban/`) — the core SHALL NOT contain any port discovery code. - ---- - -### Requirement SI-6: Other Agents Return Nil +### Requirement SI-5: Other Agents Return Nil **User Story:** As a platform maintainer, I want agents that have no summary information to return nil from `SummaryInfo()` so that the core handles them uniformly without special cases. @@ -92,7 +79,7 @@ Each agent module implements a `SummaryInfo` method on the Agent_Interface. The --- -### Requirement SI-7: Session Summary Formatting with Agent Info +### Requirement SI-6: Session Summary Formatting with Agent Info **User Story:** As a developer, I want agent-contributed information to appear in the session summary with the same formatting as built-in fields so that the output is consistent and easy to read. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md index de70957..4577033 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-agents.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-agents.md @@ -4,8 +4,6 @@ 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. - > **Related documents:** > - `requirements-core.md` — core application requirements including the Agent_Interface contract > - `requirements-cli-combinations.md` — formal rules for valid and invalid CLI flag combinations @@ -21,446 +19,32 @@ This document currently covers **Claude Code** as the reference implementation, --- -## Claude Code Agent - -### Overview - -Claude Code is Anthropic's AI coding agent. It is the first and default agent module for `bootstrap-ai-coding`. It is installed via npm, stores its authentication tokens in `~/.claude` on the Host, and is invoked inside the Container via the `claude` command. - ---- - -### Requirement CC-1: Agent Identity - -**User Story:** As the core system, I need the Claude Code module to declare a stable, unique identifier so it can be selected via the `--agents` flag. - -#### Acceptance Criteria - -1. THE Claude Code module SHALL declare the Agent_ID `"claude-code"`. -2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. - ---- - -### Requirement CC-2: Installation - -**User Story:** As a developer, I want Claude Code to be pre-installed in the container image so I can run it immediately after connecting via SSH. - -#### Acceptance Criteria - -1. THE Claude Code module SHALL contribute Dockerfile steps that install Node.js (LTS) as a runtime dependency, compatible with the Base_Container_Image. Note: when both Claude Code and Augment Code are enabled (the default), the agents share a single Node.js installation. Since Augment Code requires Node.js 22+ (see AC-2), the installed version must satisfy both agents' requirements. In practice, Node.js 22 is the current LTS and satisfies both constraints. -2. THE Claude Code module SHALL contribute Dockerfile steps that install the `@anthropic-ai/claude-code` npm package globally. -3. WHEN the container image is built with Claude Code enabled, the `claude` command SHALL be available on the default `PATH` inside the Container for the Container_User. -4. THE installation steps SHALL NOT require any manual intervention after the container starts. - ---- - -### Requirement CC-3: Credential Store - -**User Story:** As a developer, I want my Claude Code authentication to persist across sessions so I only need to log in once. - -#### Acceptance Criteria - -1. THE Claude Code module SHALL declare `~/.claude` as its default Credential_Store path on the Host. -2. THE Claude Code module SHALL declare `/.claude` as its Credential_Volume mount path inside the Container. -3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. -4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. -5. NOTE: Claude Code also stores onboarding state in `~/.claude.json` (outside the credential directory). See Requirement CC-8 for how this is handled via symlink and host-side synchronisation. - ---- - -### Requirement CC-4: Credential Presence Check - -**User Story:** As the core system, I need to know whether the user has already authenticated Claude Code so I can inform them if they haven't. - -#### Acceptance Criteria - -1. THE Claude Code module SHALL implement a credential presence check that inspects the Credential_Store directory for existing authentication tokens. -2. THE credential presence check SHALL return `false` when the Credential_Store is empty or contains no recognisable Claude Code authentication tokens. -3. THE credential presence check SHALL return `true` when valid authentication tokens are present in the Credential_Store. -4. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `claude` inside the Container and complete the login flow. - ---- - -### Requirement CC-5: Readiness Health Check +## General Agent Contract -**User Story:** As the core system, I need to verify that Claude Code is correctly installed inside a running container before reporting it as ready. +Every agent module must conform to the contract defined in the core requirements: -#### Acceptance Criteria +- **[Requirement 7: Agent Module API](./requirements-core.md)** — defines the `Agent_Interface` (ID, Install, CredentialStorePath, ContainerMountPath, HasCredentials, HealthCheck, SummaryInfo), self-registration via `Agent_Registry`, and `--agents` flag behaviour. +- **[Requirement 8: Agent Installation & Credential Mount](./requirements-core.md)** — defines how the core calls agent installation steps, mounts credential stores, auto-creates missing directories, and invokes the optional `CredentialPreparer` interface. +- **[Agent Summary Info](./requirements-agent-summary-info.md)** (SI-1–SI-7) — defines the `SummaryInfo` method contract for contributing key:value pairs to the session summary. -1. THE Claude Code module SHALL implement a Health_Check that verifies the `claude` binary is present and executable inside the Container. -2. THE Health_Check SHALL be invoked by the core after the Container starts. -3. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Claude Code agent. +Individual agent files below specify how each module satisfies this contract. --- -### Requirement CC-7: Headless Keyring for Credential Persistence - -**User Story:** As a developer, I want Claude Code to be able to read and refresh its OAuth tokens inside the container without a graphical desktop, so I don't have to re-authenticate every time I connect. - -#### Acceptance Criteria - -1. THE container image SHALL include a D-Bus session bus and a Secret Service–compatible keyring daemon (gnome-keyring) capable of running without a graphical display. -2. THE keyring daemon SHALL be started automatically when the Container_User's SSH session begins, using an empty password to unlock the default keyring. -3. Claude Code (and any other tool using `libsecret` / D-Bus Secret Service API) SHALL be able to store and retrieve credentials via the running keyring daemon without user interaction. -4. THE `DBUS_SESSION_BUS_ADDRESS` environment variable SHALL be set correctly for the Container_User's session so that client applications can locate the session bus. -5. THE keyring setup SHALL NOT interfere with the existing SSH-based authentication or the bind-mounted `~/.claude` credential store. -6. THE keyring packages and startup configuration SHALL be installed as part of the base container image (in `DockerfileBuilder`), not in individual agent modules, since multiple agents and IDE extensions may benefit from it. - ---- - -### Requirement CC-6: No Core Coupling - -**User Story:** As a platform maintainer, I want the Claude Code module to be fully self-contained so that removing or replacing it requires no changes to core code. - -#### Acceptance Criteria +## Agent Requirements (per module) -1. THE Claude Code module SHALL NOT be referenced by name or identifier anywhere in the core application code. -2. THE Claude Code module SHALL register itself with the Agent_Registry without requiring any modification to core source files. -3. THE core application SHALL function correctly (with no enabled agents) if the Claude Code module is not compiled in. +| Agent | Requirements | Status | +|---|---|---| +| [Claude Code](./agents/requirements-claude-code.md) | CC-1 through CC-8 | Default agent | +| [Augment Code](./agents/requirements-augment-code.md) | AC-1 through AC-6 | Default agent | +| [Build Resources](./agents/requirements-build-resources.md) | BR-1 through BR-6 | Default pseudo-agent | +| [Codex](./agents/requirements-codex.md) | CX-1 through CX-6 | Opt-in only | +| [OpenCode](./agents/requirements-opencode.md) | OC-1 through OC-6 | Opt-in only | --- -### Requirement CC-8: Onboarding State Synchronisation - -**User Story:** As a developer, I want my Claude Code onboarding state to persist across container recreations, so I am not prompted to complete the onboarding flow every time the container is rebuilt. - -#### Acceptance Criteria - -1. Claude Code stores its onboarding state (including `hasCompletedOnboarding`) in `~/.claude.json` on the Host — a file in the home directory root, separate from the `~/.claude/` credential directory. -2. THE Claude Code module SHALL create a symlink inside the Container at `/.claude.json` pointing to `/.claude/claude.json`, so that Claude Code reads and writes its onboarding state through the bind-mounted Credential_Volume. -3. THE Claude Code module SHALL implement the `CredentialPreparer` interface. Its `PrepareCredentials` method SHALL copy `~/.claude.json` from the Host home directory into the Credential_Store as `claude.json`, but only when the source file exists and is newer than the destination (or the destination is absent). -4. THE combination of the symlink (inside the container) and the host-side copy (before mount) SHALL ensure that a single bind-mount on `~/.claude/` persists both OAuth tokens and onboarding state across container rebuilds and restarts. -5. IF `~/.claude.json` does not exist on the Host (first-time user), THE `PrepareCredentials` method SHALL silently skip the copy without error. - ---- - -## Augment Code Agent - -### Overview - -Augment Code is an AI coding agent by Augment (augmentcode.com). Its CLI tool is called **Auggie** and is distributed as the `@augmentcode/auggie` npm package, providing the `auggie` binary. Auggie requires Node.js 22 or later. Authentication tokens and settings are stored in `~/.augment` on the Host. The agent is invoked inside the Container via the `auggie` command. - -### Glossary - -- **Auggie**: The Augment Code CLI tool, installed as the `auggie` binary via the `@augmentcode/auggie` npm package. Requires Node.js 22 or later. - ---- - -### Requirement AC-1: Agent Identity - -**User Story:** As the core system, I need the Augment Code module to declare a stable, unique identifier so it can be selected via the `--agents` flag. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL declare the Agent_ID `"augment-code"`. -2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. - ---- - -### Requirement AC-2: Installation - -**User Story:** As a developer, I want Auggie to be pre-installed in the container image so I can run it immediately after connecting via SSH. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL contribute Dockerfile steps that install Node.js 22 or later as a runtime dependency, compatible with the Base_Container_Image. Note: when both Claude Code and Augment Code are enabled (the default), the agents share a single Node.js installation. The Node.js version installed must be >= 22 to satisfy this requirement; since Node.js 22 is the current LTS, it also satisfies Claude Code's LTS requirement (see CC-2). -2. THE Augment Code module SHALL contribute Dockerfile steps that install the `@augmentcode/auggie` npm package globally. -3. WHEN the container image is built with the Augment Code agent enabled, the `auggie` command SHALL be available on the default `PATH` inside the Container for the Container_User. -4. THE installation steps SHALL NOT require any manual intervention after the container starts. - ---- - -### Requirement AC-3: Credential Store - -**User Story:** As a developer, I want my Augment Code authentication to persist across sessions so I only need to log in once. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL declare `~/.augment` as its default Credential_Store path on the Host. -2. THE Augment Code module SHALL declare `/.augment` as its Credential_Volume mount path inside the Container. -3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. -4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. - ---- - -### Requirement AC-4: Credential Presence Check - -**User Story:** As the core system, I need to know whether the user has already authenticated Augment Code so I can inform them if they haven't. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL implement a credential presence check that inspects the Credential_Store directory for existing authentication tokens. -2. THE credential presence check SHALL return `false` when the Credential_Store is empty or contains no recognisable Augment Code authentication tokens. -3. THE credential presence check SHALL return `true` when valid authentication tokens are present in the Credential_Store. -4. WHEN the credential presence check returns `false`, THE core SHALL print a message to stdout instructing the user to run `auggie login` inside the Container and complete the login flow. - ---- - -### Requirement AC-5: Readiness Health Check - -**User Story:** As the core system, I need to verify that Auggie is correctly installed inside a running container before reporting it as ready. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL implement a Health_Check that verifies the `auggie` binary is present and executable inside the Container. -2. THE Health_Check SHALL be invoked by the core after the Container starts. -3. IF the Health_Check fails, THE core SHALL report the failure to the user with a descriptive error message identifying the Augment Code agent. - ---- - -### Requirement AC-6: No Core Coupling - -**User Story:** As a platform maintainer, I want the Augment Code module to be fully self-contained so that removing or replacing it requires no changes to core code. - -#### Acceptance Criteria - -1. THE Augment Code module SHALL NOT be referenced by name or identifier anywhere in the core application code. -2. THE Augment Code module SHALL register itself with the Agent_Registry without requiring any modification to core source files. -3. THE core application SHALL function correctly (with no enabled agents) if the Augment Code module is not compiled in. - - ---- - -## Build Resources Agent - -### Overview - -Build Resources is a pseudo-agent that does not provide an AI coding tool. Instead, it installs common build toolchains and language runtimes into the container so that the development environment is ready for compilation, packaging, and general-purpose development tasks out of the box. It follows the standard agent module pattern (self-registers via `init()`, contributes Dockerfile steps, included in `DefaultAgents`) for architectural simplicity. - -### Glossary - -- **Build_Resources**: The set of system packages and language runtimes installed by this module: Python 3 (complete with setuptools/wheel), Python uv (system-wide via `UV_INSTALL_DIR`), CMake, build-essential, OpenJDK, Go, and common build dependencies (pkg-config, libssl-dev, libffi-dev, unzip, wget). - ---- - -### Requirement BR-1: Agent Identity - -**User Story:** As the core system, I need the Build Resources module to declare a stable, unique identifier so it can be selected via the `--agents` flag. - -#### Acceptance Criteria - -1. THE Build Resources module SHALL declare the Agent_ID `"build-resources"`. -2. THE Agent_ID SHALL be stable across versions of the module and SHALL NOT change. - ---- - -### Requirement BR-2: Installation - -**User Story:** As a developer, I want common build toolchains and language runtimes pre-installed in the container so I can compile and build projects immediately after connecting via SSH. - -#### Acceptance Criteria - -1. THE Build Resources module SHALL contribute Dockerfile steps that install **Python 3** (complete): `python3`, `python3-pip`, `python3-venv`, `python3-dev`, `python3-setuptools`, `python3-wheel`. -2. THE Build Resources module SHALL contribute Dockerfile steps that install **Python uv** via the official installer (`curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh`), installed system-wide to `/usr/local/bin`. -3. THE Build Resources module SHALL ensure `uv` is available on the default system `PATH` (via `/usr/local/bin`) without any additional shell profile configuration. -4. THE Build Resources module SHALL contribute Dockerfile steps that install **CMake**: `cmake`. -5. THE Build Resources module SHALL contribute Dockerfile steps that install **build-essential**: `build-essential` (provides gcc, g++, make, libc-dev). -6. THE Build Resources module SHALL contribute Dockerfile steps that install **OpenJDK**: `default-jdk` (provides both JDK and JRE). -7. THE Build Resources module SHALL contribute Dockerfile steps that install **Go** (latest stable) via the official tarball from `https://go.dev/dl/`, extracted to `/usr/local/go`, with `/usr/local/go/bin` added to the system-wide `PATH`. -8. THE Build Resources module SHALL contribute Dockerfile steps that install **common build dependencies**: `pkg-config`, `libssl-dev`, `libffi-dev`, `unzip`, `wget`. These are transitive dependencies commonly required when building Python, Go, and C/C++ packages from source. -9. ALL packages and runtimes SHALL be installed globally (system-wide), including uv which uses `UV_INSTALL_DIR=/usr/local/bin`. -10. ALL installed tools SHALL be available to the Container_User without manual intervention after the container starts. - ---- - -### Requirement BR-3: No Credential Store - -**User Story:** As the core system, I need the Build Resources module to conform to the Agent_Interface even though it has no credentials to manage. - -#### Acceptance Criteria - -1. THE Build Resources module SHALL return an empty string from `CredentialStorePath()` indicating no host-side credential directory. -2. THE Build Resources module SHALL return an empty string from `ContainerMountPath()` indicating no bind-mount is needed. -3. THE Build Resources module SHALL always return `(true, nil)` from `HasCredentials()` — there are no credentials to check. - ---- - -### Requirement BR-4: Readiness Health Check - -**User Story:** As the core system, I need to verify that all build toolchains are correctly installed inside a running container before reporting the agent as ready. - -#### Acceptance Criteria - -1. THE Build Resources module SHALL implement a Health_Check that verifies the following commands exit with code 0 inside the Container: - - `python3 --version` - - `uv --version` - - `cmake --version` - - `javac -version` - - `go version` (executed via `bash -lc` to pick up `/etc/profile.d/golang.sh`) -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. - ---- - -### Requirement BR-5: No Core Coupling - -**User Story:** As a platform maintainer, I want the Build Resources module to be fully self-contained so that removing or replacing it requires no changes to core code. - -#### Acceptance Criteria - -1. THE Build Resources module SHALL NOT be referenced by name or identifier anywhere in the core application code. -2. THE Build Resources module SHALL register itself with the Agent_Registry without requiring any modification to core source files. -3. THE core application SHALL function correctly (with no enabled agents) if the Build Resources module is not compiled in. - ---- - -### Requirement BR-6: Default Inclusion - -**User Story:** As a developer, I want build toolchains installed by default so that the container is ready for development without needing to explicitly request them. - -#### Acceptance Criteria - -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: Optional Inclusion - -**User Story:** As a developer, I want Vibe Kanban available as an opt-in agent so that I can enable the project management board when I need it. - -#### Acceptance Criteria - -1. THE `constants.DefaultAgents` value SHALL NOT include `"vibe-kanban"` — the agent is opt-in only. -2. WHEN the user invokes the CLI with `--agents` including `"vibe-kanban"` (e.g. `--agents claude-code,augment-code,build-resources,vibe-kanban`), THE system SHALL include `"vibe-kanban"` in the enabled agents list and install it in the container. -3. IF the user specifies `--agents` with a list that does not contain `"vibe-kanban"`, THEN THE system SHALL not install or enable the Vibe Kanban module in the container, and `"vibe-kanban"` SHALL not appear in the session summary's enabled agents list. -4. THE `"vibe-kanban"` agent ID SHALL be registered in the agent registry so that `agent.Lookup("vibe-kanban")` resolves without error. - ---- - -### 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 Vibe Kanban module SHALL discover the Vibe_Kanban_Port via its `SummaryInfo()` method (see SI-5 in requirements-agent-summary-info.md) by inspecting the running Vibe Kanban process's listening port inside the Container, waiting up to 30 seconds for the port to become available. -3. THE session summary (Requirement 17 in requirements-core.md) SHALL include a labelled line "Vibe Kanban:" followed by the full URL `http://localhost:` so the user knows how to access it. This is delivered via the generic Agent Summary Info mechanism (SI-2, SI-7). -4. IF the Vibe Kanban module cannot discover the Vibe_Kanban_Port within the 30-second timeout (e.g. the process started but has not bound a port), THEN THE core SHALL print a warning message to stderr (per SI-3) and SHALL omit the Vibe Kanban URL from the session summary without failing the overall startup. -5. WHEN `--host-network-off` is set (bridge mode), THE Vibe Kanban server SHALL NOT be accessible from the host without additional port forwarding — this is a known limitation of bridge mode for non-SSH services. - - ---- - -### Requirement VK-9: Port Assignment and Discovery - -**User Story:** As a developer running multiple bac containers simultaneously in host network mode, I need each container's Vibe Kanban instance to use a unique port so they don't conflict with each other. - -#### Acceptance Criteria - -1. THE Vibe Kanban server SHALL use auto-assigned port selection (port 0 / OS-assigned) at startup, allowing the operating system to choose a free port. THE supervisor script SHALL NOT hardcode or fix the port number. -2. BECAUSE containers in host network mode (Req 26, default) share the host's network namespace, a fixed port would cause bind failures when multiple bac containers run simultaneously. Auto-assignment ensures each instance gets a unique port. -3. THE `SummaryInfo()` method SHALL discover the auto-assigned port by reading a well-known port file (`/tmp/vibe-kanban.port`) written by the supervisor script. The supervisor discovers the port by polling `ss -tlnp` filtered by the vibe-kanban process PID, then writes the port number to the file. This approach is deterministic regardless of how many services listen in the container. -4. THE port discovery logic SHALL NOT rely on the process name appearing in `ss -tlnp` output, because the Rust binary downloaded by the npm wrapper may report under a different name depending on the platform and version. The supervisor uses PID-based filtering (`grep "pid=$VK_PID,"`) which is unambiguous. -5. THE container image SHALL include `iproute2` (provides `ss`) and `procps` (provides `pgrep`, `ps`) as runtime dependencies installed by the Vibe Kanban module's `Install()` method, to support port discovery and health checks. -6. THE supervisor script SHALL set `BROWSER=none` in the environment when launching vibe-kanban, to suppress the automatic browser-open attempt in the headless container environment. -7. THE Vibe Kanban module's `Install()` method SHALL pre-download the platform-specific binary during image build by running `vibe-kanban` with a timeout, so that the binary is cached in the container and does not require internet access at runtime. - -#### Design Notes - -- The vibe-kanban npm package (`npx vibe-kanban`) is a CLI wrapper that downloads a platform-specific Rust binary on first run and caches it in `~/.vibe-kanban/bin/`. The pre-download step during image build ensures the binary is available without network access at container start. -- The `HOST=0.0.0.0` environment variable is set so the server binds to all interfaces, making it accessible from the host in host network mode. -- In bridge mode (`--host-network-off`), port conflicts are not an issue since each container has its own network namespace, but the port is still auto-assigned for consistency. - - ---- +## Cross-Cutting Concerns -## Agent Summary Info +### Agent Summary Info See **[requirements-agent-summary-info.md](./requirements-agent-summary-info.md)** — Agent Summary Info mechanism: generic key:value pairs in session summary via Agent interface extension. Requirements SI-1–SI-7. diff --git a/.kiro/specs/bootstrap-ai-coding/requirements-core.md b/.kiro/specs/bootstrap-ai-coding/requirements-core.md index cdd32a8..7d27177 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements-core.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements-core.md @@ -444,7 +444,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 5c571d6..44329e9 100644 --- a/.kiro/specs/bootstrap-ai-coding/requirements.md +++ b/.kiro/specs/bootstrap-ai-coding/requirements.md @@ -3,7 +3,7 @@ The requirements for this project are split across these documents: - **[requirements-core.md](./requirements-core.md)** — Core application: CLI, Docker lifecycle, SSH, volume mounts, and the Agent module API (Agent_Interface + Agent_Registry). Requirements 1–26. -- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code, Augment Code, Build Resources, and Vibe Kanban. Requirements CC-1–CC-8, AC-1–AC-6, BR-1–BR-6, VK-1–VK-9. +- **[requirements-agents.md](./requirements-agents.md)** — Agent module implementations: Claude Code, Augment Code, and Build Resources. Requirements CC-1–CC-8, AC-1–AC-6, 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. Requirements TL-1–TL-11. - **[requirements-agent-summary-info.md](./requirements-agent-summary-info.md)** — Agent Summary Info: generic key:value pairs in session summary via Agent interface extension. Requirements SI-1–SI-7. diff --git a/.kiro/specs/bootstrap-ai-coding/tasks.md b/.kiro/specs/bootstrap-ai-coding/tasks.md new file mode 100644 index 0000000..6478b36 --- /dev/null +++ b/.kiro/specs/bootstrap-ai-coding/tasks.md @@ -0,0 +1,78 @@ +# Implementation Plan: Build Resources — Add tree, btop, and graphify + +## Overview + +Update the Build Resources agent module to install three new tools: `tree` (directory listing), `btop` (terminal resource monitor), and `graphify` (knowledge graph skill for AI coding assistants). This involves modifying the apt packages list, adding new RUN steps for graphify, extending health checks, and updating both unit and integration tests. + +## Tasks + +- [x] 1. Add tree, btop, and graphify installation steps to buildresources.go + - [x] 1.1 Add "tree" and "btop" to the aptPackages slice + - Add `"tree"` and `"btop"` to the `aptPackages` variable in `internal/agents/buildresources/buildresources.go` + - Place them in a new comment group `// Terminal and directory utilities` or alongside existing terminal utilities + - _Requirements: BR-2 AC-9_ + + - [x] 1.2 Add graphify installation RUN steps + - After the uv installation `b.Run(...)` call, add: `b.Run("UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy")` + - After the graphify tool install, add: `b.Run("graphify install")` + - _Requirements: BR-2 AC-10_ + + - [x] 1.3 Add health checks for graphify, tree, and btop + - Add three new entries to the `checks` slice in `HealthCheck()`: + - `{[]string{"graphify", "--version"}, "graphify"}` + - `{[]string{"tree", "--version"}, "tree"}` + - `{[]string{"btop", "--version"}, "btop"}` + - _Requirements: BR-4 AC-1_ + +- [x] 2. Update unit tests to verify new packages and install steps + - [x] 2.1 Update TestInstallAppendsExpectedPackages to include new packages + - Add `"tree"` and `"btop"` to the `expectedPackages` slice in `TestInstallAppendsExpectedPackages` + - Add assertions verifying the graphify install commands appear in the generated Dockerfile: + - `require.Contains(t, content, "UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy")` + - `require.Contains(t, content, "graphify install")` + - _Requirements: BR-2 AC-9, BR-2 AC-10_ + +- [x] 3. Checkpoint - Ensure unit tests pass + - Ensure all tests pass with `go test ./internal/agents/buildresources/...`, ask the user if questions arise. + +- [x] 4. Update integration tests to verify new tools are available + - [x] 4.1 Add integration test for tree availability + - Add `TestTreeAvailable` in `integration_test.go` following existing patterns (exec `tree --version` in the shared container, assert exit code 0) + - Add comment `// Validates: BR-2.9` + - _Requirements: BR-2 AC-9_ + + - [x] 4.2 Add integration test for btop availability + - Add `TestBtopAvailable` in `integration_test.go` following existing patterns (exec `btop --version` in the shared container, assert exit code 0) + - Add comment `// Validates: BR-2.9` + - _Requirements: BR-2 AC-9_ + + - [x] 4.3 Add integration test for graphify availability + - Add `TestGraphifyAvailable` in `integration_test.go` following existing patterns (exec `graphify --version` in the shared container, assert exit code 0) + - Add comment `// Validates: BR-2.10` + - _Requirements: BR-2 AC-10_ + +- [x] 5. Final checkpoint - Ensure all tests pass + - Ensure all tests pass with `go test ./internal/agents/buildresources/...`, ask the user if questions arise. + - Integration tests can be run with `BAC_INTEGRATION_CONSENT=yes go test -tags integration -timeout 30m -p 1 ./internal/agents/buildresources/...` + +## Notes + +- The design specifies Go as the implementation language — all code is in Go. +- No property-based tests are needed for this change — the additions are deterministic install steps verified by example-based unit tests and integration tests. +- The existing `TestBuildResourcesHealthCheck` integration test will implicitly validate the new health check entries once the implementation is in place. +- The graphify package name on PyPI is `graphifyy` (note the double-y), as specified in the design document. +- `graphify install` must run after `uv tool install graphifyy` since it depends on the graphify executable being available. +- Each task references specific requirements for traceability. +- Checkpoints ensure incremental validation. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.2"] }, + { "id": 1, "tasks": ["1.3", "2.1"] }, + { "id": 2, "tasks": ["4.1", "4.2", "4.3"] } + ] +} +``` diff --git a/.kiro/steering/constants.md b/.kiro/steering/constants.md index 96494d1..c4b99f3 100644 --- a/.kiro/steering/constants.md +++ b/.kiro/steering/constants.md @@ -31,7 +31,6 @@ This means: | `ClaudeCodeAgentName` | `"claude-code"` | Agent_ID for Claude Code (CC-1) | | `AugmentCodeAgentName` | `"augment-code"` | Agent_ID for Augment Code (AC-1) | | `BuildResourcesAgentName` | `"build-resources"` | Agent_ID for Build Resources (BR-1) | -| `VibeKanbanAgentName` | `"vibe-kanban"` | Agent_ID for Vibe Kanban (VK-1) | | `DefaultAgents` | `"claude-code,augment-code,build-resources"` | default `Enabled_Agents` (Req 7.5) | | `SSHHostKeyType` | `"ed25519"` | SSH host key algorithm | | `MinDockerVersion` | `"20.10"` | minimum Docker version (Req 6.3) | diff --git a/README.md b/README.md index cb22a17..6002c38 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,6 @@ SSH connect: ssh bac-myproject Enabled agents: claude-code, augment-code, build-resources ``` -When optional agents like `vibe-kanban` are enabled, their info appears after the standard fields: - -``` -Enabled agents: claude-code, augment-code, build-resources, vibe-kanban -Vibe Kanban: http://localhost:3000 -``` - After that, `ssh bac-myproject` works — no port or username to remember. ## Prerequisites @@ -105,7 +98,7 @@ Removes all bac-managed containers, images, tool data (`~/.config/bootstrap-ai-c | Flag | Description | |---|---| | `` | Path to the project directory to mount (required for start/stop) | -| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources`; available: also `vibe-kanban`) | +| `--agents ` | Comma-separated agent IDs to enable (default: `claude-code,augment-code,build-resources`) | | `--port ` | Override the SSH port (1024–65535; default: auto-selected from 2222 upward) | | `--ssh-key ` | Override the SSH public key path | | `--rebuild` | Force a full container image rebuild | @@ -120,14 +113,13 @@ Removes all bac-managed containers, images, tool data (`~/.config/bootstrap-ai-c ## Supported Agents -The first three agents are enabled by default. Use `--agents` to enable a specific subset or add optional agents. +The first three agents are enabled by default. Use `--agents` to enable a specific subset. | Agent ID | Description | Credential store | Container mount | |---|---|---|---| | `claude-code` | [Claude Code](https://github.com/anthropics/claude-code) by Anthropic | `~/.claude/` | `/home//.claude/` | | `augment-code` | [Augment Code](https://www.augmentcode.com) (Auggie CLI) | `~/.augment/` | `/home//.augment/` | | `build-resources` | Common build toolchains and runtimes (Python, Go, Java, CMake, ripgrep, etc.) | — | — | -| `vibe-kanban` | Web-based kanban board for AI coding sessions (auto-starts as background service) | — | — | ### Examples @@ -143,9 +135,6 @@ bac --agents augment-code # Claude Code + build tools bac --agents claude-code,build-resources - -# All agents including Vibe Kanban -bac --agents claude-code,augment-code,build-resources,vibe-kanban ``` ## Agents @@ -170,23 +159,6 @@ A pseudo-agent that installs common build toolchains and language runtimes: No credentials to persist — this agent only adds build tools to the image. -### Vibe Kanban (`vibe-kanban`) - -A web-based kanban board for AI coding sessions, distributed as the `vibe-kanban` npm package. Unlike other agents, Vibe Kanban runs as a **background service** inside the container — it auto-starts when the container boots and is accessible from your host browser. - -- Auto-starts via an ENTRYPOINT wrapper script (no manual launch needed) -- Crash recovery with backoff (max 5 restarts in 60 seconds) -- Auto-assigns a port at startup; the URL is shown in the session summary -- No credentials to persist - -When enabled, the session summary includes the Vibe Kanban URL: - -``` -Vibe Kanban: http://localhost:3000 -``` - -Not enabled by default — add it with `--agents claude-code,augment-code,build-resources,vibe-kanban`. - ### Credential persistence Credentials are stored on the host and bind-mounted into every container — so if you are already logged in on your host machine, the container inherits your credentials automatically with no extra login step. The tool only prompts you when no credentials are found: diff --git a/internal/agent/mounter.go b/internal/agent/mounter.go new file mode 100644 index 0000000..896cb6c --- /dev/null +++ b/internal/agent/mounter.go @@ -0,0 +1,11 @@ +package agent + +import "github.com/koudis/bootstrap-ai-coding/internal/docker" + +// AdditionalMounter is an optional interface that agents can implement to +// declare additional bind-mounts beyond the single primary credential store. +// The core calls this after processing CredentialStorePath/ContainerMountPath +// and appends the returned mounts to the container spec. +type AdditionalMounter interface { + AdditionalMounts(homeDir string) []docker.Mount +} diff --git a/internal/agents/buildresources/buildresources.go b/internal/agents/buildresources/buildresources.go index b0c79b7..dd13d82 100644 --- a/internal/agents/buildresources/buildresources.go +++ b/internal/agents/buildresources/buildresources.go @@ -32,7 +32,7 @@ var aptPackages = []string{ // Version control extras "git-lfs", // Terminal and shell utilities - "tmux", "less", "file", "shellcheck", + "tmux", "less", "file", "shellcheck", "tree", "btop", // Database "sqlite3", // Archive handling @@ -73,6 +73,10 @@ func (a *buildResourcesAgent) Install(b *docker.DockerfileBuilder) { // Using UV_INSTALL_DIR to place the binary where all users can access it, // avoiding user-local PATH issues with docker exec (which runs as root). b.Run("curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh") + + // Graphify — knowledge graph skill for AI coding assistants + b.Run("UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy") + b.Run("graphify install") } // CredentialStorePath returns empty — no credentials to persist. @@ -116,6 +120,9 @@ func (a *buildResourcesAgent) HealthCheck(ctx context.Context, c *docker.Client, {[]string{"jq", "--version"}, "jq"}, {[]string{"git-lfs", "--version"}, "git-lfs"}, {[]string{"tmux", "-V"}, "tmux"}, + {[]string{"graphify", "--version"}, "graphify"}, + {[]string{"tree", "--version"}, "tree"}, + {[]string{"btop", "--version"}, "btop"}, } for _, chk := range checks { exitCode, err := docker.ExecInContainer(ctx, c, containerID, chk.cmd) diff --git a/internal/agents/buildresources/buildresources_test.go b/internal/agents/buildresources/buildresources_test.go index af5b7e6..9458359 100644 --- a/internal/agents/buildresources/buildresources_test.go +++ b/internal/agents/buildresources/buildresources_test.go @@ -74,6 +74,7 @@ func TestInstallAppendsExpectedPackages(t *testing.T) { "default-jdk", "libssl-dev", "libffi-dev", "curl", "ca-certificates", "unzip", "wget", + "tree", "btop", } for _, pkg := range expectedPackages { require.Contains(t, content, pkg, @@ -97,6 +98,12 @@ func TestInstallAppendsExpectedPackages(t *testing.T) { "Install() must install uv via official installer") require.Contains(t, content, "UV_INSTALL_DIR=/usr/local/bin", "Install() must install uv to /usr/local/bin") + + // Verify graphify installation + require.Contains(t, content, "UV_TOOL_BIN_DIR=/usr/local/bin uv tool install graphifyy", + "Install() must install graphify via uv tool install") + require.Contains(t, content, "graphify install", + "Install() must run graphify install") } // TestSummaryInfoReturnsNil verifies that the Build Resources agent's SummaryInfo diff --git a/internal/agents/buildresources/integration_test.go b/internal/agents/buildresources/integration_test.go index 0f837fa..336d513 100644 --- a/internal/agents/buildresources/integration_test.go +++ b/internal/agents/buildresources/integration_test.go @@ -302,6 +302,54 @@ func TestBuildResourcesHealthCheck(t *testing.T) { require.NoError(t, err, "build-resources HealthCheck should return no error") } +// ---------------------------------------------------------------------------- +// TestTreeAvailable +// Validates: BR-2.9 +// ---------------------------------------------------------------------------- + +func TestTreeAvailable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"tree", "--version"}) + require.NoError(t, err, "exec tree --version") + require.Equal(t, 0, exitCode, "expected 'tree --version' to exit 0") +} + +// ---------------------------------------------------------------------------- +// TestBtopAvailable +// Validates: BR-2.9 +// ---------------------------------------------------------------------------- + +func TestBtopAvailable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"btop", "--version"}) + require.NoError(t, err, "exec btop --version") + require.Equal(t, 0, exitCode, "expected 'btop --version' to exit 0") +} + +// ---------------------------------------------------------------------------- +// TestGraphifyAvailable +// Validates: BR-2.10 +// ---------------------------------------------------------------------------- + +func TestGraphifyAvailable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"graphify", "--version"}) + require.NoError(t, err, "exec graphify --version") + require.Equal(t, 0, exitCode, "expected 'graphify --version' to exit 0") +} + // ---------------------------------------------------------------------------- // Internal helpers // ---------------------------------------------------------------------------- diff --git a/internal/agents/codex/codex.go b/internal/agents/codex/codex.go new file mode 100644 index 0000000..27f6ead --- /dev/null +++ b/internal/agents/codex/codex.go @@ -0,0 +1,91 @@ +// Package codex implements the OpenAI Codex CLI agent module. +// It self-registers with the agent registry via init() and satisfies the +// agent.Agent interface. The core application has no direct dependency on +// this package — it is wired in exclusively via a blank import in main.go. +package codex + +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" +) + +type codexAgent struct{} + +func init() { + agent.Register(&codexAgent{}) +} + +// ID returns the stable Agent_ID for the Codex agent. +// Satisfies: CX-1 +func (a *codexAgent) ID() string { + return constants.CodexAgentName +} + +// Install appends Dockerfile RUN steps that install Node.js 22 and the +// @openai/codex npm package globally. +// Satisfies: CX-2 +func (a *codexAgent) Install(b *docker.DockerfileBuilder) { + b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates git && rm -rf /var/lib/apt/lists/*") + if !b.IsNodeInstalled() { + 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() + } + b.Run("npm install -g --no-fund --no-audit @openai/codex") +} + +// CredentialStorePath returns the default host-side credential directory for +// Codex authentication tokens. +// Satisfies: CX-3 +func (a *codexAgent) CredentialStorePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".codex") +} + +// ContainerMountPath returns the path inside the container where the +// Credential_Store is bind-mounted. +// Satisfies: CX-3 +func (a *codexAgent) ContainerMountPath(homeDir string) string { + return filepath.Join(homeDir, ".codex") +} + +// HasCredentials reports whether the credential store contains the auth.json +// file, indicating that the user has authenticated Codex. +// Returns (false, nil) when the directory or auth.json is absent — not an error. +// Satisfies: CX-4 +func (a *codexAgent) HasCredentials(storePath string) (bool, error) { + _, err := os.Stat(filepath.Join(storePath, "auth.json")) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("checking codex credentials: %w", err) +} + +// HealthCheck verifies that the codex binary is present and executable inside +// the running container by executing `codex --version`. +// Satisfies: CX-5 +func (a *codexAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { + exitCode, err := docker.ExecInContainer(ctx, c, containerID, []string{"codex", "--version"}) + if err != nil { + return fmt.Errorf("codex health check failed: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("codex health check failed: 'codex --version' exited with code %d", exitCode) + } + return nil +} + +// SummaryInfo returns no additional session summary information for the +// Codex agent. +// Satisfies: Req 9 (Session Summary) +func (a *codexAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} diff --git a/internal/agents/codex/codex_test.go b/internal/agents/codex/codex_test.go new file mode 100644 index 0000000..a0430a3 --- /dev/null +++ b/internal/agents/codex/codex_test.go @@ -0,0 +1,353 @@ +// Package codex_test contains property-based and unit tests for the OpenAI +// Codex CLI agent module. The blank import of the codex package triggers its +// init() function, which registers the codexAgent with the global agent +// registry. +package codex_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/codex" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" +) + +// newTestBuilder returns a DockerfileBuilder pre-seeded with the base layer, +// using fixed key material and UserStrategyCreate with uid=1000, gid=1000. +func newTestBuilder() *docker.DockerfileBuilder { + return docker.NewBaseImageBuilder( + &hostinfo.Info{Username: "testuser", HomeDir: "/home/testuser", UID: 1000, GID: 1000}, + docker.UserStrategyCreate, "", + "", + ) +} + +// --------------------------------------------------------------------------- +// Property 1: Codex agent ID is stable +// --------------------------------------------------------------------------- + +// Feature: codex-agent, Property 1: Codex agent ID is stable +func TestPropertyCodexAgentIDIsStable(t *testing.T) { + // Validates: Requirements 1.2, 6.2 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered under constants.CodexAgentName") + + id := a.ID() + require.Equal(rt, constants.CodexAgentName, id, + "ID() must always return constants.CodexAgentName (%q)", constants.CodexAgentName) + }) +} + +// --------------------------------------------------------------------------- +// Property 2: Codex credential presence check is consistent +// --------------------------------------------------------------------------- + +// Feature: codex-agent, Property 2: Codex credential presence check is consistent +func TestPropertyCodexCredentialPresenceConsistent(t *testing.T) { + // Validates: Requirements 4.1, 4.2, 4.3 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered") + + tmpDir := t.TempDir() + + hasFile := rapid.Bool().Draw(rt, "hasFile") + + if hasFile { + err := os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte(`{"token":"test"}`), 0o600) + require.NoError(rt, err, "failed to create test credential file") + } + + hasCreds, err := a.HasCredentials(tmpDir) + require.NoError(rt, err, "HasCredentials must not error for a valid directory") + + // Codex checks auth.json existence (not file size) + require.Equal(rt, hasFile, hasCreds, + "HasCredentials must return true iff auth.json exists in the store path") + }) +} + +// Feature: codex-agent, Property 2: Codex credential presence check is consistent +func TestPropertyCodexCredentialAbsentDirectory(t *testing.T) { + // Validates: Requirements 4.1, 4.2, 4.3 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered") + + // Use a path that does not exist. + tmpDir := t.TempDir() + nonExistent := filepath.Join(tmpDir, "does-not-exist") + + hasCreds, err := a.HasCredentials(nonExistent) + require.NoError(rt, err, "HasCredentials must return (false, nil) for absent directory") + require.False(rt, hasCreds, "HasCredentials must return false for absent directory") + }) +} + +// --------------------------------------------------------------------------- +// Property 3: Codex container mount path uses runtime-provided home directory +// --------------------------------------------------------------------------- + +// Feature: codex-agent, Property 3: Codex container mount path uses runtime-provided home directory +func TestPropertyCodexContainerMountPath(t *testing.T) { + // Validates: Requirements 3.2 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered") + + homeDir := rapid.StringMatching("/[a-zA-Z][a-zA-Z0-9_/.-]*").Draw(rt, "homeDir") + mountPath := a.ContainerMountPath(homeDir) + wantPath := filepath.Join(homeDir, ".codex") + + require.Equal(rt, wantPath, mountPath, + "ContainerMountPath(%q) must return %q", homeDir, wantPath) + }) +} + +// --------------------------------------------------------------------------- +// Property 4: Codex Dockerfile steps include Node.js 22 and @openai/codex package +// --------------------------------------------------------------------------- + +// Feature: codex-agent, Property 4: Codex Dockerfile steps include Node.js 22 and @openai/codex package +func TestPropertyCodexInstallIncludesNodeAndPackage(t *testing.T) { + // Validates: Requirements 2.2, 2.3, 2.4 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered") + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.Contains(rt, content, "setup_22.x", + "Dockerfile must include Node.js 22 setup step") + require.Contains(rt, content, "@openai/codex", + "Dockerfile must include @openai/codex installation step") + }) +} + +// --------------------------------------------------------------------------- +// Property 5: Codex agent is registered and satisfies the Agent interface +// --------------------------------------------------------------------------- + +// Feature: codex-agent, Property 5: Codex agent is registered and satisfies the Agent interface +func TestPropertyCodexAgentSatisfiesInterface(t *testing.T) { + // Validates: Requirements 1.1, 1.3, 10.1 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(rt, err, "codex agent must be registered under constants.CodexAgentName") + require.NotNil(rt, a) + + // Verify all interface methods are callable without panicking. + id := a.ID() + require.NotEmpty(rt, id, "agent ID must not be empty") + + b := newTestBuilder() + a.Install(b) + + credPath := a.CredentialStorePath() + require.NotEmpty(rt, credPath, "CredentialStorePath must not be empty") + + mountPath := a.ContainerMountPath("/home/testuser") + require.NotEmpty(rt, mountPath, "ContainerMountPath must not be empty") + + tmpDir := t.TempDir() + _, err = a.HasCredentials(tmpDir) + require.NoError(rt, err, "HasCredentials on a valid temp dir must not error") + + // HealthCheck requires a live Docker daemon — covered by integration tests. + }) +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +// TestCodexAgentRegistered verifies that the blank import causes the codex +// agent to self-register and that agent.Lookup succeeds for constants.CodexAgentName. +func TestCodexAgentRegistered(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err, "codex agent must be registered under constants.CodexAgentName") + require.NotNil(t, a) + require.Equal(t, constants.CodexAgentName, a.ID()) +} + +// TestCodexInstallStepsPresent verifies that Install appends RUN steps that +// install Node.js 22 (setup_22.x) and the @openai/codex npm package. +func TestCodexInstallStepsPresent(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + b := newTestBuilder() + a.Install(b) + content := b.Build() + + require.Contains(t, content, "setup_22.x", + "Dockerfile must contain a Node.js 22 setup step") + require.Contains(t, content, "@openai/codex", + "Dockerfile must contain an @openai/codex installation step") +} + +// TestCodexCredentialPaths verifies that CredentialStorePath ends with ".codex". +func TestCodexCredentialPaths(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + storePath := a.CredentialStorePath() + require.NotEmpty(t, storePath) + require.Equal(t, ".codex", filepath.Base(storePath), + "CredentialStorePath must end with .codex") +} + +// TestCodexContainerMountPath verifies that ContainerMountPath equals +// "/.codex". +func TestCodexContainerMountPath(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + want := "/home/testuser/.codex" + require.Equal(t, want, a.ContainerMountPath("/home/testuser")) +} + +// TestCodexHasCredentialsEmpty verifies that HasCredentials returns (false, nil) +// when the store directory exists but contains no files. +func TestCodexHasCredentialsEmpty(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + hasCreds, err := a.HasCredentials(tmpDir) + require.NoError(t, err) + require.False(t, hasCreds) +} + +// TestCodexHasCredentialsAbsentDir verifies that HasCredentials returns +// (false, nil) when the store directory does not exist. +func TestCodexHasCredentialsAbsentDir(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + nonExistent := filepath.Join(tmpDir, "does-not-exist") + + hasCreds, err := a.HasCredentials(nonExistent) + require.NoError(t, err) + require.False(t, hasCreds) +} + +// TestCodexHasCredentialsPresent verifies that HasCredentials returns (true, nil) +// when auth.json exists inside the store directory. +func TestCodexHasCredentialsPresent(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + err = os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte(`{"token":"test"}`), 0o600) + require.NoError(t, err) + + hasCreds, err := a.HasCredentials(tmpDir) + require.NoError(t, err) + require.True(t, hasCreds) +} + +// TestCodexHasCredentialsStatError verifies that HasCredentials returns +// (false, error) when a filesystem error other than "not exists" occurs. +func TestCodexHasCredentialsStatError(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + // Use a regular file as the store path. When HasCredentials tries to stat + // "auth.json" inside it (filepath.Join(file, "auth.json")), the OS returns + // a "not a directory" error deterministically — no chmod tricks needed. + tmpDir := t.TempDir() + notADir := filepath.Join(tmpDir, "fakedir") + err = os.WriteFile(notADir, []byte("not a directory"), 0o644) + require.NoError(t, err) + + hasCreds, credErr := a.HasCredentials(notADir) + require.False(t, hasCreds) + require.Error(t, credErr) + require.Contains(t, credErr.Error(), "checking codex credentials") +} + +// TestCodexInstallNodeNotInstalled verifies that when IsNodeInstalled() +// returns false, Codex appends Node.js install steps and calls MarkNodeInstalled(). +func TestCodexInstallNodeNotInstalled(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + b := newTestBuilder() + require.False(t, b.IsNodeInstalled(), "fresh builder must have IsNodeInstalled() == false") + + a.Install(b) + content := b.Build() + + require.Contains(t, content, "setup_22.x", + "must install Node.js 22 when not already installed") + require.Contains(t, content, "nodejs", + "must install nodejs package when not already installed") + require.Contains(t, content, "@openai/codex", + "must always install the @openai/codex npm package") + require.True(t, b.IsNodeInstalled(), + "MarkNodeInstalled() must be called after Node.js installation") +} + +// TestCodexInstallNodeAlreadyInstalled verifies that when IsNodeInstalled() +// returns true, Codex skips Node.js install steps but still installs its npm package. +func TestCodexInstallNodeAlreadyInstalled(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err) + + b := newTestBuilder() + b.MarkNodeInstalled() // simulate a prior agent having installed Node.js + require.True(t, b.IsNodeInstalled()) + + linesBefore := len(b.Lines()) + a.Install(b) + content := b.Build() + + // Must NOT contain the Node.js setup step + require.NotContains(t, content, "setup_22.x", + "must skip Node.js setup when already installed") + + // Must still install the npm package + require.Contains(t, content, "@openai/codex", + "must always install the @openai/codex npm package") + + // Must still install curl/ca-certificates/git (idempotent prereqs) + require.Contains(t, content, "curl ca-certificates git", + "must always install curl, ca-certificates, git") + + // Should have added exactly 2 lines (apt-get prereqs + npm install) + linesAfter := len(b.Lines()) + require.Equal(t, linesBefore+2, linesAfter, + "must add exactly 2 RUN steps when Node.js is already installed (prereqs + npm)") +} + +// TestCodexSummaryInfoReturnsNil verifies that the Codex agent's SummaryInfo +// method returns (nil, nil) since it has no additional session summary info. +func TestCodexSummaryInfoReturnsNil(t *testing.T) { + a, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err, "codex agent must be registered") + + info, err := a.SummaryInfo(context.Background(), nil, "") + require.NoError(t, err) + require.Nil(t, info) +} + +// TestCodexNotInDefaultAgents verifies that constants.DefaultAgents does not +// contain "codex" — it is opt-in only. +func TestCodexNotInDefaultAgents(t *testing.T) { + require.False(t, strings.Contains(constants.DefaultAgents, "codex"), + "constants.DefaultAgents must NOT contain 'codex' — it is opt-in only") +} diff --git a/internal/agents/codex/integration_test.go b/internal/agents/codex/integration_test.go new file mode 100644 index 0000000..b5ae308 --- /dev/null +++ b/internal/agents/codex/integration_test.go @@ -0,0 +1,277 @@ +//go:build integration + +package codex_test + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + dockerimage "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/codex" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" + "github.com/koudis/bootstrap-ai-coding/internal/testutil" +) + +// Package-level shared container state — built once in TestMain, reused by all tests. +var ( + sharedContainerName string + sharedSSHPort int + sharedClient *docker.Client + sharedImageTag string +) + +// TestMain gates the integration suite behind an explicit consent prompt, +// builds the container image once, starts the container, and tears it down +// after all tests complete. +func TestMain(m *testing.M) { + if _, err := exec.LookPath("docker"); err != nil { + os.Exit(m.Run()) + } + + testutil.RequireIntegrationConsent() + + if err := testutil.EnsureBaseImageAbsent(); err != nil { + fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) + os.Exit(1) + } + + if err := setupSharedContainer(); err != nil { + fmt.Fprintf(os.Stderr, "setupSharedContainer: %v\n", err) + os.Exit(1) + } + + code := m.Run() + + teardownSharedContainer() + os.Exit(code) +} + +func setupSharedContainer() error { + ctx := context.Background() + + projectDir, err := os.MkdirTemp("", "bac-codex-integration-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + dirName := filepath.Base(projectDir) + + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating host key pair: %w", err) + } + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating user key pair: %w", err) + } + + info, err := hostinfo.Current() + if err != nil { + return fmt.Errorf("getting host info: %w", err) + } + + sharedClient, err = docker.NewClient() + if err != nil { + return fmt.Errorf("connecting to Docker daemon: %w", err) + } + + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, info.UID, info.GID) + if err != nil { + return fmt.Errorf("checking base image for UID/GID conflicts: %w", err) + } + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + builder := docker.NewBaseImageBuilder( + info, + strategy, conflictingUser, + "", + ) + + codexAgent, err := agent.Lookup(constants.CodexAgentName) + if err != nil { + return fmt.Errorf("looking up codex agent: %w", err) + } + codexAgent.Install(builder) + + port, err := findFreePortCodex() + if err != nil { + return fmt.Errorf("finding free port: %w", err) + } + + sharedContainerName = constants.ContainerNamePrefix + sanitizeCodex(dirName) + sharedImageTag = sharedContainerName + ":latest" + sharedSSHPort = port + + // Build base image + baseSpec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: constants.BaseImageTag, + Dockerfile: builder.Build(), + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) + if err != nil { + return fmt.Errorf("building base image with codex: %w", err) + } + + // Build instance image + instanceBuilder := docker.NewInstanceImageBuilder( + info, + userPubKey, + hostKeyPriv, hostKeyPub, + port, false, + ) + instanceBuilder.Finalize() + + spec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: sharedImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: []docker.Mount{ + { + HostPath: projectDir, + ContainerPath: constants.WorkspaceMountPath, + ReadOnly: false, + }, + }, + SSHPort: port, + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, spec, false) + if err != nil { + return fmt.Errorf("building container image with codex: %w", err) + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + if err != nil { + return fmt.Errorf("creating container: %w", err) + } + + err = docker.StartContainer(ctx, sharedClient, sharedContainerName) + if err != nil { + return fmt.Errorf("starting container: %w", err) + } + + // Codex installation takes longer (npm install) — allow up to 2 minutes for SSH. + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 120*time.Second) + if err != nil { + return fmt.Errorf("waiting for SSH to be ready: %w", err) + } + + return nil +} + +func teardownSharedContainer() { + ctx := context.Background() + if sharedClient == nil { + return + } + _ = docker.StopContainer(ctx, sharedClient, sharedContainerName) + _ = docker.RemoveContainer(ctx, sharedClient, sharedContainerName) + images, _ := docker.ListBACImages(ctx, sharedClient) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == sharedImageTag { + _, _ = sharedClient.ImageRemove(ctx, img.ID, dockerimage.RemoveOptions{Force: true}) + } + } + } +} + +// ---------------------------------------------------------------------------- +// TestCodexAvailableInContainer +// Validates: Requirements 11.2 +// ---------------------------------------------------------------------------- + +func TestCodexAvailableInContainer(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"codex", "--version"}) + require.NoError(t, err, "exec codex --version") + require.Equal(t, 0, exitCode, "expected 'codex --version' to exit 0") +} + +// ---------------------------------------------------------------------------- +// TestCodexHealthCheck +// Validates: Requirements 11.3 +// ---------------------------------------------------------------------------- + +func TestCodexHealthCheck(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + codexAgent, err := agent.Lookup(constants.CodexAgentName) + require.NoError(t, err, "looking up codex agent") + + err = codexAgent.HealthCheck(ctx, sharedClient, sharedContainerName) + require.NoError(t, err, "codex HealthCheck should return no error") +} + +// ---------------------------------------------------------------------------- +// Internal helpers +// ---------------------------------------------------------------------------- + +func findFreePortCodex() (int, error) { + for port := constants.SSHPortStart; port < 65535; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) +} + +func sanitizeCodex(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "tmp" + } + return result +} diff --git a/internal/agents/opencode/integration_test.go b/internal/agents/opencode/integration_test.go new file mode 100644 index 0000000..92c421b --- /dev/null +++ b/internal/agents/opencode/integration_test.go @@ -0,0 +1,334 @@ +//go:build integration + +package opencode_test + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + dockerimage "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/opencode" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" + sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" + "github.com/koudis/bootstrap-ai-coding/internal/testutil" +) + +// Package-level shared container state — built once in TestMain, reused by all tests. +var ( + sharedContainerName string + sharedSSHPort int + sharedClient *docker.Client + sharedImageTag string +) + +// TestMain gates the integration suite behind an explicit consent prompt, +// builds the container image once, starts the container, and tears it down +// after all tests complete. +func TestMain(m *testing.M) { + if _, err := exec.LookPath("docker"); err != nil { + os.Exit(m.Run()) + } + + testutil.RequireIntegrationConsent() + + if err := testutil.EnsureBaseImageAbsent(); err != nil { + fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) + os.Exit(1) + } + + if err := setupSharedContainer(); err != nil { + fmt.Fprintf(os.Stderr, "setupSharedContainer: %v\n", err) + os.Exit(1) + } + + code := m.Run() + + teardownSharedContainer() + os.Exit(code) +} + +func setupSharedContainer() error { + ctx := context.Background() + + projectDir, err := os.MkdirTemp("", "bac-opencode-integration-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + dirName := filepath.Base(projectDir) + + hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating host key pair: %w", err) + } + + _, userPubKey, err := sshpkg.GenerateHostKeyPair() + if err != nil { + return fmt.Errorf("generating user key pair: %w", err) + } + + info, err := hostinfo.Current() + if err != nil { + return fmt.Errorf("getting host info: %w", err) + } + + sharedClient, err = docker.NewClient() + if err != nil { + return fmt.Errorf("connecting to Docker daemon: %w", err) + } + + strategy := docker.UserStrategyCreate + conflictingUser := "" + conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, info.UID, info.GID) + if err != nil { + return fmt.Errorf("checking base image for UID/GID conflicts: %w", err) + } + if conflictingImageUser != nil { + strategy = docker.UserStrategyRename + conflictingUser = conflictingImageUser.Username + } + + builder := docker.NewBaseImageBuilder( + info, + strategy, conflictingUser, + "", + ) + + opencodeAgent, err := agent.Lookup(constants.OpenCodeAgentName) + if err != nil { + return fmt.Errorf("looking up opencode agent: %w", err) + } + opencodeAgent.Install(builder) + + port, err := findFreePortOpencode() + if err != nil { + return fmt.Errorf("finding free port: %w", err) + } + + sharedContainerName = constants.ContainerNamePrefix + sanitizeOpencode(dirName) + sharedImageTag = sharedContainerName + ":latest" + sharedSSHPort = port + + // Build base image + baseSpec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: constants.BaseImageTag, + Dockerfile: builder.Build(), + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, baseSpec, false) + if err != nil { + return fmt.Errorf("building base image with opencode: %w", err) + } + + // Build instance image + instanceBuilder := docker.NewInstanceImageBuilder( + info, + userPubKey, + hostKeyPriv, hostKeyPub, + port, false, + ) + instanceBuilder.Finalize() + + // Create temp directories for credential mounts + credAuthDir, err := os.MkdirTemp("", "opencode-auth-*") + if err != nil { + return fmt.Errorf("creating temp auth dir: %w", err) + } + credConfigDir, err := os.MkdirTemp("", "opencode-config-*") + if err != nil { + return fmt.Errorf("creating temp config dir: %w", err) + } + + // Prepare mounts: workspace + primary credential store + additional mounts + mounts := []docker.Mount{ + { + HostPath: projectDir, + ContainerPath: constants.WorkspaceMountPath, + ReadOnly: false, + }, + { + HostPath: credAuthDir, + ContainerPath: filepath.Join(info.HomeDir, ".local", "share", "opencode"), + ReadOnly: false, + }, + } + + // Add additional mounts from the AdditionalMounter interface + if mounter, ok := opencodeAgent.(agent.AdditionalMounter); ok { + for _, extra := range mounter.AdditionalMounts(info.HomeDir) { + mounts = append(mounts, docker.Mount{ + HostPath: credConfigDir, + ContainerPath: extra.ContainerPath, + ReadOnly: extra.ReadOnly, + }) + } + } + + spec := docker.ContainerSpec{ + Name: sharedContainerName, + ImageTag: sharedImageTag, + Dockerfile: instanceBuilder.Build(), + Mounts: mounts, + SSHPort: port, + Labels: map[string]string{ + "bac.managed": "true", + }, + HostInfo: info, + } + + _, err = docker.BuildImage(ctx, sharedClient, spec, false) + if err != nil { + return fmt.Errorf("building container image with opencode: %w", err) + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + if err != nil { + return fmt.Errorf("creating container: %w", err) + } + + err = docker.StartContainer(ctx, sharedClient, sharedContainerName) + if err != nil { + return fmt.Errorf("starting container: %w", err) + } + + // OpenCode installation takes longer (npm install) — allow up to 2 minutes for SSH. + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 120*time.Second) + if err != nil { + return fmt.Errorf("waiting for SSH to be ready: %w", err) + } + + return nil +} + +func teardownSharedContainer() { + ctx := context.Background() + if sharedClient == nil { + return + } + _ = docker.StopContainer(ctx, sharedClient, sharedContainerName) + _ = docker.RemoveContainer(ctx, sharedClient, sharedContainerName) + images, _ := docker.ListBACImages(ctx, sharedClient) + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == sharedImageTag { + _, _ = sharedClient.ImageRemove(ctx, img.ID, dockerimage.RemoveOptions{Force: true}) + } + } + } +} + +// ---------------------------------------------------------------------------- +// TestOpenCodeAvailableInContainer +// Validates: Requirements 2.3, 2.5 +// ---------------------------------------------------------------------------- + +func TestOpenCodeAvailableInContainer(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"opencode", "--version"}) + require.NoError(t, err, "exec opencode --version") + require.Equal(t, 0, exitCode, "expected 'opencode --version' to exit 0") +} + +// ---------------------------------------------------------------------------- +// TestOpenCodeHealthCheck +// Validates: Requirements 5.1 +// ---------------------------------------------------------------------------- + +func TestOpenCodeHealthCheck(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + opencodeAgent, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err, "looking up opencode agent") + + err = opencodeAgent.HealthCheck(ctx, sharedClient, sharedContainerName) + require.NoError(t, err, "opencode HealthCheck should return no error") +} + +// ---------------------------------------------------------------------------- +// TestOpenCodeCredentialMountsExist +// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5 +// ---------------------------------------------------------------------------- + +func TestOpenCodeCredentialMountsExist(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + ctx := context.Background() + + info, err := hostinfo.Current() + require.NoError(t, err, "getting host info") + + // Verify primary credential mount path exists (~/.local/share/opencode) + primaryPath := filepath.Join(info.HomeDir, ".local", "share", "opencode") + exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"ls", "-d", primaryPath}) + require.NoError(t, err, "exec ls -d primary credential path") + require.Equal(t, 0, exitCode, "expected primary credential mount path %s to exist", primaryPath) + + // Verify additional credential mount path exists (~/.config/opencode) + configPath := filepath.Join(info.HomeDir, ".config", "opencode") + exitCode, err = docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"ls", "-d", configPath}) + require.NoError(t, err, "exec ls -d additional credential path") + require.Equal(t, 0, exitCode, "expected additional credential mount path %s to exist", configPath) +} + +// ---------------------------------------------------------------------------- +// Internal helpers +// ---------------------------------------------------------------------------- + +func findFreePortOpencode() (int, error) { + for port := constants.SSHPortStart; port < 65535; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) +} + +func sanitizeOpencode(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "tmp" + } + return result +} diff --git a/internal/agents/opencode/opencode.go b/internal/agents/opencode/opencode.go new file mode 100644 index 0000000..6347d43 --- /dev/null +++ b/internal/agents/opencode/opencode.go @@ -0,0 +1,102 @@ +// Package opencode implements the OpenCode AI coding agent module. +// It self-registers with the agent registry via init() and satisfies both +// the agent.Agent and agent.AdditionalMounter interfaces. The core application +// has no direct dependency on this package — it is wired in exclusively via a +// blank import in main.go. +package opencode + +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" +) + +type opencodeAgent struct{} + +func init() { + agent.Register(&opencodeAgent{}) +} + +// ID returns the stable Agent_ID for the OpenCode agent. +func (a *opencodeAgent) ID() string { + return constants.OpenCodeAgentName +} + +// Install appends Dockerfile RUN steps that install Node.js 22 (deduplicated) +// and the opencode-ai npm package globally. +func (a *opencodeAgent) Install(b *docker.DockerfileBuilder) { + b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates git && rm -rf /var/lib/apt/lists/*") + if !b.IsNodeInstalled() { + 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() + } + b.Run("npm install -g --no-fund --no-audit opencode-ai") +} + +// CredentialStorePath returns the default host-side credential directory for +// OpenCode authentication tokens (~/.local/share/opencode). +func (a *opencodeAgent) CredentialStorePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "opencode") +} + +// ContainerMountPath returns the path inside the container where the +// primary credential store is bind-mounted. +func (a *opencodeAgent) ContainerMountPath(homeDir string) string { + return filepath.Join(homeDir, ".local", "share", "opencode") +} + +// HasCredentials reports whether the credential store contains the auth.json +// file with size > 0, indicating that the user has authenticated OpenCode. +// Returns (false, nil) when the directory or auth.json is absent or zero-length. +func (a *opencodeAgent) HasCredentials(storePath string) (bool, error) { + info, err := os.Stat(filepath.Join(storePath, "auth.json")) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("checking opencode credentials: %w", err) + } + if info.Size() == 0 { + return false, nil + } + return true, nil +} + +// HealthCheck verifies that the opencode binary is present and executable inside +// the running container by executing `opencode --version`. +func (a *opencodeAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { + exitCode, err := docker.ExecInContainer(ctx, c, containerID, []string{"opencode", "--version"}) + if err != nil { + return fmt.Errorf("opencode health check failed: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("opencode health check failed: 'opencode --version' exited with code %d", exitCode) + } + return nil +} + +// SummaryInfo returns no additional session summary information for the +// OpenCode agent. +func (a *opencodeAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { + return nil, nil +} + +// AdditionalMounts returns the extra bind-mounts required by OpenCode beyond +// the primary credential store. OpenCode needs its config directory +// (~/.config/opencode) mounted into the container. +func (a *opencodeAgent) AdditionalMounts(homeDir string) []docker.Mount { + hostHome, _ := os.UserHomeDir() + return []docker.Mount{ + { + HostPath: filepath.Join(hostHome, ".config", "opencode"), + ContainerPath: filepath.Join(homeDir, ".config", "opencode"), + ReadOnly: false, + }, + } +} diff --git a/internal/agents/opencode/opencode_test.go b/internal/agents/opencode/opencode_test.go new file mode 100644 index 0000000..17f602b --- /dev/null +++ b/internal/agents/opencode/opencode_test.go @@ -0,0 +1,426 @@ +// Package opencode_test contains property-based and unit tests for the OpenCode +// AI coding agent module. The blank import of the opencode package triggers its +// init() function, which registers the opencodeAgent with the global agent +// registry. +package opencode_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" + + "github.com/koudis/bootstrap-ai-coding/internal/agent" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/opencode" + "github.com/koudis/bootstrap-ai-coding/internal/constants" + "github.com/koudis/bootstrap-ai-coding/internal/docker" + "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" +) + +// newTestBuilder returns a DockerfileBuilder pre-seeded with the base layer, +// using fixed key material and UserStrategyCreate with uid=1000, gid=1000. +func newTestBuilder() *docker.DockerfileBuilder { + return docker.NewBaseImageBuilder( + &hostinfo.Info{Username: "testuser", HomeDir: "/home/testuser", UID: 1000, GID: 1000}, + docker.UserStrategyCreate, "", + "", + ) +} + +// --------------------------------------------------------------------------- +// Property 1: OpenCode agent ID is stable +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 1: OpenCode agent ID is stable +func TestPropertyOpenCodeAgentIDIsStable(t *testing.T) { + // Validates: Requirements 1.1, 1.2, 1.3 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered under constants.OpenCodeAgentName") + + id := a.ID() + require.Equal(rt, constants.OpenCodeAgentName, id, + "ID() must always return constants.OpenCodeAgentName (%q)", constants.OpenCodeAgentName) + }) +} + +// --------------------------------------------------------------------------- +// Property 2: OpenCode credential presence check is consistent +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 2: OpenCode credential presence check is consistent +func TestPropertyOpenCodeCredentialPresenceConsistent(t *testing.T) { + // Validates: Requirements 4.1, 4.2, 4.3, 4.4 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered") + + tmpDir := t.TempDir() + + hasFile := rapid.Bool().Draw(rt, "hasFile") + + if hasFile { + // Generate content of varying length: 0 means empty file, >0 means non-empty + contentLen := rapid.IntRange(0, 100).Draw(rt, "contentLen") + var content []byte + if contentLen > 0 { + content = []byte(strings.Repeat("x", contentLen)) + } + err := os.WriteFile(filepath.Join(tmpDir, "auth.json"), content, 0o600) + require.NoError(rt, err, "failed to create test credential file") + + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(rt, credErr, "HasCredentials must not error for a valid directory") + + // OpenCode checks file SIZE > 0 (unlike Codex which just checks existence) + if contentLen > 0 { + require.True(rt, hasCreds, + "HasCredentials must return true when auth.json exists with size > 0") + } else { + require.False(rt, hasCreds, + "HasCredentials must return false when auth.json exists with size == 0") + } + } else { + // No auth.json file + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(rt, credErr, "HasCredentials must not error for absent file") + require.False(rt, hasCreds, + "HasCredentials must return false when auth.json does not exist") + } + }) +} + +// Feature: opencode-agent, Property 2: OpenCode credential presence check is consistent +func TestPropertyOpenCodeCredentialAbsentDirectory(t *testing.T) { + // Validates: Requirements 4.1, 4.2, 4.3, 4.4 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered") + + // Use a path that does not exist. + tmpDir := t.TempDir() + nonExistent := filepath.Join(tmpDir, "does-not-exist") + + hasCreds, credErr := a.HasCredentials(nonExistent) + require.NoError(rt, credErr, "HasCredentials must return (false, nil) for absent directory") + require.False(rt, hasCreds, "HasCredentials must return false for absent directory") + }) +} + +// --------------------------------------------------------------------------- +// Property 3: OpenCode container mount path uses runtime-provided home directory +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 3: OpenCode container mount path uses runtime-provided home directory +func TestPropertyOpenCodeContainerMountPath(t *testing.T) { + // Validates: Requirements 3.2 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered") + + homeDir := rapid.StringMatching("/[a-zA-Z][a-zA-Z0-9_/.-]*").Draw(rt, "homeDir") + mountPath := a.ContainerMountPath(homeDir) + wantPath := filepath.Join(homeDir, ".local", "share", "opencode") + + require.Equal(rt, wantPath, mountPath, + "ContainerMountPath(%q) must return %q", homeDir, wantPath) + }) +} + +// --------------------------------------------------------------------------- +// Property 4: OpenCode Dockerfile steps include Node.js 22 and opencode-ai package +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 4: OpenCode Dockerfile steps include Node.js 22 and opencode-ai package +func TestPropertyOpenCodeInstallIncludesNodeAndPackage(t *testing.T) { + // Validates: Requirements 2.1, 2.2, 2.3 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered") + + nodePreInstalled := rapid.Bool().Draw(rt, "nodePreInstalled") + + b := newTestBuilder() + if nodePreInstalled { + b.MarkNodeInstalled() + } + + a.Install(b) + content := b.Build() + + // Must always contain opencode-ai + require.Contains(rt, content, "opencode-ai", + "Dockerfile must include opencode-ai installation step") + + if !nodePreInstalled { + // When Node.js is NOT pre-installed: must contain setup_22.x + require.Contains(rt, content, "setup_22.x", + "Dockerfile must include Node.js 22 setup step when not pre-installed") + } else { + // When Node.js IS pre-installed: must NOT contain setup_22.x + require.NotContains(rt, content, "setup_22.x", + "Dockerfile must NOT include Node.js 22 setup step when already pre-installed") + } + }) +} + +// --------------------------------------------------------------------------- +// Property 5: OpenCode additional mounts declare the config store +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 5: OpenCode additional mounts declare the config store +func TestPropertyOpenCodeAdditionalMounts(t *testing.T) { + // Validates: Requirements 3.3, 3.4 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered") + + homeDir := rapid.StringMatching("/[a-zA-Z][a-zA-Z0-9_/.-]*").Draw(rt, "homeDir") + + // Type-assert to AdditionalMounter + mounter, ok := a.(agent.AdditionalMounter) + require.True(rt, ok, "opencode agent must implement AdditionalMounter") + + mounts := mounter.AdditionalMounts(homeDir) + require.Len(rt, mounts, 1, + "AdditionalMounts must return exactly one mount") + + mount := mounts[0] + wantContainerPath := filepath.Join(homeDir, ".config", "opencode") + require.Equal(rt, wantContainerPath, mount.ContainerPath, + "mount ContainerPath must be /.config/opencode") + require.False(rt, mount.ReadOnly, + "mount ReadOnly must be false") + require.True(rt, strings.HasSuffix(mount.HostPath, filepath.Join(".config", "opencode")), + "mount HostPath must end with .config/opencode, got %q", mount.HostPath) + }) +} + +// --------------------------------------------------------------------------- +// Property 6: OpenCode agent satisfies the Agent interface without panicking +// --------------------------------------------------------------------------- + +// Feature: opencode-agent, Property 6: OpenCode agent satisfies the Agent interface without panicking +func TestPropertyOpenCodeAgentSatisfiesInterface(t *testing.T) { + // Validates: Requirements 1.1, 1.3, 6.1, 6.2 + rapid.Check(t, func(rt *rapid.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(rt, err, "opencode agent must be registered under constants.OpenCodeAgentName") + require.NotNil(rt, a) + + // Verify all interface methods are callable without panicking. + id := a.ID() + require.NotEmpty(rt, id, "agent ID must not be empty") + + b := newTestBuilder() + a.Install(b) + + credPath := a.CredentialStorePath() + require.NotEmpty(rt, credPath, "CredentialStorePath must not be empty") + + homeDir := rapid.StringMatching("/[a-zA-Z][a-zA-Z0-9_/.-]*").Draw(rt, "homeDir") + mountPath := a.ContainerMountPath(homeDir) + require.NotEmpty(rt, mountPath, "ContainerMountPath must not be empty") + + tmpDir := t.TempDir() + _, credErr := a.HasCredentials(tmpDir) + require.NoError(rt, credErr, "HasCredentials on a valid temp dir must not error") + + // Verify AdditionalMounter interface + mounter, ok := a.(agent.AdditionalMounter) + require.True(rt, ok, "opencode agent must implement AdditionalMounter") + mounts := mounter.AdditionalMounts(homeDir) + require.NotEmpty(rt, mounts, "AdditionalMounts must return at least one mount") + + // SummaryInfo with nil client (no Docker needed for this agent) + info, summaryErr := a.SummaryInfo(context.Background(), nil, "") + require.NoError(rt, summaryErr) + require.Nil(rt, info) + + // HealthCheck requires a live Docker daemon — covered by integration tests. + }) +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +// TestOpenCodeAgentRegistered verifies that the blank import causes the opencode +// agent to self-register and that agent.Lookup succeeds for constants.OpenCodeAgentName. +func TestOpenCodeAgentRegistered(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err, "opencode agent must be registered under constants.OpenCodeAgentName") + require.NotNil(t, a) + require.Equal(t, constants.OpenCodeAgentName, a.ID()) +} + +// TestOpenCodeHasCredentialsPresent verifies that HasCredentials returns (true, nil) +// when auth.json exists with content. +func TestOpenCodeHasCredentialsPresent(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + err = os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte(`{"token":"test"}`), 0o600) + require.NoError(t, err) + + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(t, credErr) + require.True(t, hasCreds) +} + +// TestOpenCodeHasCredentialsEmpty verifies that HasCredentials returns (false, nil) +// when the store directory exists but contains no auth.json file. +func TestOpenCodeHasCredentialsEmpty(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(t, credErr) + require.False(t, hasCreds) +} + +// TestOpenCodeHasCredentialsAbsentFile verifies that HasCredentials returns +// (false, nil) when the directory exists but auth.json is not present. +func TestOpenCodeHasCredentialsAbsentFile(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + // Create some other file, not auth.json + err = os.WriteFile(filepath.Join(tmpDir, "other.json"), []byte(`{}`), 0o600) + require.NoError(t, err) + + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(t, credErr) + require.False(t, hasCreds) +} + +// TestOpenCodeHasCredentialsZeroLength verifies that HasCredentials returns +// (false, nil) when auth.json exists but is zero-length. +func TestOpenCodeHasCredentialsZeroLength(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + err = os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte{}, 0o600) + require.NoError(t, err) + + hasCreds, credErr := a.HasCredentials(tmpDir) + require.NoError(t, credErr) + require.False(t, hasCreds) +} + +// TestOpenCodeHasCredentialsStatError verifies that HasCredentials returns +// (false, error) when a filesystem error other than "not exists" occurs. +func TestOpenCodeHasCredentialsStatError(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + // Use a regular file as the store path. When HasCredentials tries to stat + // "auth.json" inside it (filepath.Join(file, "auth.json")), the OS returns + // a "not a directory" error deterministically — no chmod tricks needed. + tmpDir := t.TempDir() + notADir := filepath.Join(tmpDir, "fakedir") + err = os.WriteFile(notADir, []byte("not a directory"), 0o644) + require.NoError(t, err) + + hasCreds, credErr := a.HasCredentials(notADir) + require.False(t, hasCreds) + require.Error(t, credErr) + require.Contains(t, credErr.Error(), "checking opencode credentials") +} + +// TestOpenCodeInstallNodeNotInstalled verifies that when IsNodeInstalled() +// returns false, OpenCode appends Node.js install steps and calls MarkNodeInstalled(). +func TestOpenCodeInstallNodeNotInstalled(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + b := newTestBuilder() + require.False(t, b.IsNodeInstalled(), "fresh builder must have IsNodeInstalled() == false") + + a.Install(b) + content := b.Build() + + require.Contains(t, content, "setup_22.x", + "must install Node.js 22 when not already installed") + require.Contains(t, content, "opencode-ai", + "must always install the opencode-ai npm package") + require.True(t, b.IsNodeInstalled(), + "MarkNodeInstalled() must be called after Node.js installation") +} + +// TestOpenCodeInstallNodeAlreadyInstalled verifies that when IsNodeInstalled() +// returns true, OpenCode skips Node.js install steps but still installs its npm package. +func TestOpenCodeInstallNodeAlreadyInstalled(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + b := newTestBuilder() + b.MarkNodeInstalled() // simulate a prior agent having installed Node.js + require.True(t, b.IsNodeInstalled()) + + a.Install(b) + content := b.Build() + + // Must NOT contain the Node.js setup step + require.NotContains(t, content, "setup_22.x", + "must skip Node.js setup when already installed") + + // Must still install the npm package + require.Contains(t, content, "opencode-ai", + "must always install the opencode-ai npm package") +} + +// TestOpenCodeContainerMountPath verifies that ContainerMountPath equals +// "/.local/share/opencode". +func TestOpenCodeContainerMountPath(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + want := "/home/testuser/.local/share/opencode" + require.Equal(t, want, a.ContainerMountPath("/home/testuser")) +} + +// TestOpenCodeAdditionalMounts verifies that AdditionalMounts returns exactly +// one mount with the correct ContainerPath and ReadOnly=false. +func TestOpenCodeAdditionalMounts(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err) + + mounter, ok := a.(agent.AdditionalMounter) + require.True(t, ok, "opencode agent must implement AdditionalMounter") + + mounts := mounter.AdditionalMounts("/home/testuser") + require.Len(t, mounts, 1) + + mount := mounts[0] + require.Equal(t, "/home/testuser/.config/opencode", mount.ContainerPath) + require.False(t, mount.ReadOnly) + require.True(t, strings.HasSuffix(mount.HostPath, filepath.Join(".config", "opencode")), + "HostPath must end with .config/opencode, got %q", mount.HostPath) +} + +// TestOpenCodeSummaryInfoReturnsNil verifies that the OpenCode agent's SummaryInfo +// method returns (nil, nil) since it has no additional session summary info. +func TestOpenCodeSummaryInfoReturnsNil(t *testing.T) { + a, err := agent.Lookup(constants.OpenCodeAgentName) + require.NoError(t, err, "opencode agent must be registered") + + info, summaryErr := a.SummaryInfo(context.Background(), nil, "") + require.NoError(t, summaryErr) + require.Nil(t, info) +} + +// TestOpenCodeNotInDefaultAgents verifies that constants.DefaultAgents does not +// contain "open-code" — it is opt-in only. +func TestOpenCodeNotInDefaultAgents(t *testing.T) { + require.False(t, strings.Contains(constants.DefaultAgents, constants.OpenCodeAgentName), + "constants.DefaultAgents must NOT contain %q — it is opt-in only", constants.OpenCodeAgentName) +} diff --git a/internal/agents/vibekanban/integration_test.go b/internal/agents/vibekanban/integration_test.go deleted file mode 100644 index 6c1a576..0000000 --- a/internal/agents/vibekanban/integration_test.go +++ /dev/null @@ -1,422 +0,0 @@ -//go:build integration - -package vibekanban_test - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - dockerimage "github.com/docker/docker/api/types/image" - "github.com/stretchr/testify/require" - - "github.com/koudis/bootstrap-ai-coding/internal/agent" - _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" - "github.com/koudis/bootstrap-ai-coding/internal/constants" - "github.com/koudis/bootstrap-ai-coding/internal/docker" - "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" - sshpkg "github.com/koudis/bootstrap-ai-coding/internal/ssh" - "github.com/koudis/bootstrap-ai-coding/internal/testutil" -) - -// Package-level shared container state — built once in TestMain, reused by all tests. -var ( - sharedContainerName string - sharedSSHPort int - sharedClient *docker.Client - sharedImageTag string - sharedProjectDir string -) - -// TestMain gates the integration suite behind an explicit consent prompt, -// builds the container image once, starts the container, and tears it down -// after all tests complete. -func TestMain(m *testing.M) { - if _, err := exec.LookPath("docker"); err != nil { - os.Exit(m.Run()) - } - - testutil.RequireIntegrationConsent() - - if err := testutil.EnsureBaseImageAbsent(); err != nil { - fmt.Fprintf(os.Stderr, "EnsureBaseImageAbsent: %v\n", err) - os.Exit(1) - } - - if err := setupSharedContainer(); err != nil { - fmt.Fprintf(os.Stderr, "setupSharedContainer: %v\n", err) - os.Exit(1) - } - - code := m.Run() - - teardownSharedContainer() - os.Exit(code) -} - -func setupSharedContainer() error { - ctx := context.Background() - - projectDir, err := os.MkdirTemp("", "bac-vibekanban-integration-*") - if err != nil { - return fmt.Errorf("creating temp dir: %w", err) - } - sharedProjectDir = projectDir - dirName := filepath.Base(projectDir) - - hostKeyPriv, hostKeyPub, err := sshpkg.GenerateHostKeyPair() - if err != nil { - return fmt.Errorf("generating host key pair: %w", err) - } - - _, userPubKey, err := sshpkg.GenerateHostKeyPair() - if err != nil { - return fmt.Errorf("generating user key pair: %w", err) - } - - info, err := hostinfo.Current() - if err != nil { - return fmt.Errorf("getting host info: %w", err) - } - - sharedClient, err = docker.NewClient() - if err != nil { - return fmt.Errorf("connecting to Docker daemon: %w", err) - } - - strategy := docker.UserStrategyCreate - conflictingUser := "" - conflictingImageUser, err := docker.FindConflictingUser(ctx, sharedClient, info.UID, info.GID) - if err != nil { - return fmt.Errorf("checking base image for UID/GID conflicts: %w", err) - } - if conflictingImageUser != nil { - strategy = docker.UserStrategyRename - conflictingUser = conflictingImageUser.Username - } - - builder := docker.NewBaseImageBuilder( - info, - strategy, conflictingUser, - "", - ) - - vkAgent, err := agent.Lookup(constants.VibeKanbanAgentName) - if err != nil { - return fmt.Errorf("looking up vibe-kanban agent: %w", err) - } - vkAgent.Install(builder) - - port, err := findFreePortVK() - if err != nil { - return fmt.Errorf("finding free port: %w", err) - } - - sharedContainerName = constants.ContainerNamePrefix + sanitizeVK(dirName) - sharedImageTag = sharedContainerName + ":latest" - sharedSSHPort = port - - // Build base image - baseSpec := docker.ContainerSpec{ - Name: sharedContainerName, - ImageTag: constants.BaseImageTag, - Dockerfile: builder.Build(), - Labels: map[string]string{ - "bac.managed": "true", - }, - HostInfo: info, - } - - _, err = docker.BuildImage(ctx, sharedClient, baseSpec, true) - if err != nil { - return fmt.Errorf("building base image with vibe-kanban: %w", err) - } - - // Build instance image - instanceBuilder := docker.NewInstanceImageBuilder( - info, - userPubKey, - hostKeyPriv, hostKeyPub, - port, false, - ) - instanceBuilder.Finalize() - - spec := docker.ContainerSpec{ - Name: sharedContainerName, - ImageTag: sharedImageTag, - Dockerfile: instanceBuilder.Build(), - Mounts: []docker.Mount{ - { - HostPath: projectDir, - ContainerPath: constants.WorkspaceMountPath, - ReadOnly: false, - }, - }, - SSHPort: port, - Labels: map[string]string{ - "bac.managed": "true", - }, - HostInfo: info, - } - - _, err = docker.BuildImage(ctx, sharedClient, spec, true) - if err != nil { - return fmt.Errorf("building container image with vibe-kanban: %w", err) - } - - _, err = docker.CreateContainer(ctx, sharedClient, spec) - if err != nil { - return fmt.Errorf("creating container: %w", err) - } - - err = docker.StartContainer(ctx, sharedClient, sharedContainerName) - if err != nil { - return fmt.Errorf("starting container: %w", err) - } - - err = docker.WaitForSSH(ctx, "127.0.0.1", port, 120*time.Second) - if err != nil { - return fmt.Errorf("waiting for SSH to be ready: %w", err) - } - - // Give vibe-kanban time to start up via the supervisor - time.Sleep(5 * time.Second) - - return nil -} - -func teardownSharedContainer() { - ctx := context.Background() - if sharedClient == nil { - return - } - _ = docker.StopContainer(ctx, sharedClient, sharedContainerName) - _ = docker.RemoveContainer(ctx, sharedClient, sharedContainerName) - images, _ := docker.ListBACImages(ctx, sharedClient) - for _, img := range images { - for _, tag := range img.RepoTags { - if tag == sharedImageTag { - _, _ = sharedClient.ImageRemove(ctx, img.ID, dockerimage.RemoveOptions{Force: true}) - } - } - } - if sharedProjectDir != "" { - if err := os.RemoveAll(sharedProjectDir); err != nil { - fmt.Fprintf(os.Stderr, "warning: removing temp project dir: %v\n", err) - } - } -} - -// ---------------------------------------------------------------------------- -// TestVibeKanbanInstallsAndRuns -// Validates: VK-2.3, VK-3.1, VK-5.1, VK-5.2 -// Full image build, binary present (which vibe-kanban exits 0), process running -// ---------------------------------------------------------------------------- - -func TestVibeKanbanInstallsAndRuns(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - // Verify binary is present - exitCode, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"which", "vibe-kanban"}) - require.NoError(t, err, "exec which vibe-kanban") - require.Equal(t, 0, exitCode, "expected 'which vibe-kanban' to exit 0 (binary present)") - - // Verify process is running - exitCode, err = docker.ExecInContainer(ctx, sharedClient, sharedContainerName, []string{"pgrep", "-f", "vibe-kanban"}) - require.NoError(t, err, "exec pgrep -f vibe-kanban") - require.Equal(t, 0, exitCode, "expected vibe-kanban process to be running") -} - -// ---------------------------------------------------------------------------- -// TestVibeKanbanHealthCheck -// Validates: VK-5.1, VK-5.2 -// HealthCheck passes on a live container -// ---------------------------------------------------------------------------- - -func TestVibeKanbanHealthCheck(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - vkAgent, err := agent.Lookup(constants.VibeKanbanAgentName) - require.NoError(t, err, "looking up vibe-kanban agent") - - err = vkAgent.HealthCheck(ctx, sharedClient, sharedContainerName) - require.NoError(t, err, "vibe-kanban HealthCheck should return no error") -} - -// ---------------------------------------------------------------------------- -// TestVibeKanbanPortDiscovery -// Validates: VK-8.1, VK-8.2 -// Port is discoverable via ss after startup -// ---------------------------------------------------------------------------- - -func TestVibeKanbanPortDiscovery(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - port := discoverVibeKanbanPort(t, ctx) - require.Greater(t, port, 0, "expected to discover a valid port for vibe-kanban") - require.LessOrEqual(t, port, 65535, "port must be a valid TCP port") -} - -// ---------------------------------------------------------------------------- -// TestVibeKanbanCrashRecovery -// Validates: VK-3.3, VK-3.5 -// Process restarts after being killed (kill + wait + verify running) -// ---------------------------------------------------------------------------- - -func TestVibeKanbanCrashRecovery(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - // Discover the current vibe-kanban port - port := discoverVibeKanbanPort(t, ctx) - require.Greater(t, port, 0, "must discover vibe-kanban port before killing") - - // Kill the vibe-kanban server process by finding what's listening on its port. - // This avoids accidentally killing the supervisor script. - _, err := docker.ExecInContainer(ctx, sharedClient, sharedContainerName, - []string{"bash", "-c", fmt.Sprintf( - "PID=$(ss -tlnp sport = :%d | grep -oP 'pid=\\K[0-9]+' | head -1); [ -n \"$PID\" ] && kill $PID; exit 0", - port)}) - require.NoError(t, err, "exec kill vibe-kanban via port lookup") - - // Wait for the supervisor to restart it (DELAY_SECONDS=5 + startup + port discovery) - time.Sleep(30 * time.Second) - - // Verify the server is running again by reading the port file - newPort := discoverVibeKanbanPort(t, ctx) - require.Greater(t, newPort, 0, "expected vibe-kanban process to be running after crash recovery") -} - -// ---------------------------------------------------------------------------- -// TestVibeKanbanAccessibleFromHost -// Validates: VK-8.1, VK-8.2 -// HTTP GET to localhost:port returns 2xx (host network mode) -// ---------------------------------------------------------------------------- - -func TestVibeKanbanAccessibleFromHost(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available") - } - - ctx := context.Background() - - port := discoverVibeKanbanPort(t, ctx) - require.Greater(t, port, 0, "must discover vibe-kanban port before testing HTTP access") - - url := fmt.Sprintf("http://localhost:%d", port) - - // Retry HTTP GET for up to 15 seconds (server may need time after restart) - var resp *http.Response - var httpErr error - deadline := time.Now().Add(15 * time.Second) - client := &http.Client{Timeout: 5 * time.Second} - - for time.Now().Before(deadline) { - resp, httpErr = client.Get(url) - if httpErr == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { - resp.Body.Close() - return // Success - } - if resp != nil { - resp.Body.Close() - } - time.Sleep(2 * time.Second) - } - - if httpErr != nil { - t.Fatalf("HTTP GET %s failed: %v", url, httpErr) - } - if resp != nil { - t.Fatalf("HTTP GET %s returned status %d, expected 2xx", url, resp.StatusCode) - } - t.Fatalf("HTTP GET %s timed out without a successful response", url) -} - -// ---------------------------------------------------------------------------- -// Internal helpers -// ---------------------------------------------------------------------------- - -// discoverVibeKanbanPort reads the port file written by the supervisor script. -// Retries for up to 60 seconds since the supervisor needs time to start -// vibe-kanban and discover its port. -func discoverVibeKanbanPort(t *testing.T, ctx context.Context) int { - t.Helper() - - const portFile = "/tmp/vibe-kanban.port" - deadline := time.Now().Add(60 * time.Second) - for time.Now().Before(deadline) { - exitCode, output, err := docker.ExecInContainerWithOutput(ctx, sharedClient, sharedContainerName, - []string{"cat", portFile}) - if err != nil { - t.Logf("cat port file error: %v", err) - time.Sleep(2 * time.Second) - continue - } - if exitCode == 0 { - portStr := strings.TrimSpace(output) - port, err := strconv.Atoi(portStr) - if err == nil && port > 0 { - return port - } - } - time.Sleep(2 * time.Second) - } - - t.Fatal("timed out waiting for vibe-kanban port file (60s)") - return 0 -} - -func findFreePortVK() (int, error) { - for port := constants.SSHPortStart; port < 65535; port++ { - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err == nil { - ln.Close() - return port, nil - } - } - return 0, fmt.Errorf("no free port found starting at %d", constants.SSHPortStart) -} - -func sanitizeVK(s string) string { - s = strings.ToLower(s) - var b strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - } else { - b.WriteByte('-') - } - } - result := b.String() - for strings.Contains(result, "--") { - result = strings.ReplaceAll(result, "--", "-") - } - result = strings.Trim(result, "-") - if result == "" { - result = "tmp" - } - return result -} diff --git a/internal/agents/vibekanban/vibekanban.go b/internal/agents/vibekanban/vibekanban.go deleted file mode 100644 index e1bbbb4..0000000 --- a/internal/agents/vibekanban/vibekanban.go +++ /dev/null @@ -1,218 +0,0 @@ -// Package vibekanban implements the Vibe Kanban agent module — a web-based -// project management tool that runs as a background service inside the container. -// It self-registers with the agent registry via init() and satisfies the -// agent.Agent interface. The core application has no direct dependency on -// this package — it is wired in exclusively via a blank import in main.go. -package vibekanban - -import ( - "context" - "encoding/base64" - "fmt" - "strconv" - "strings" - "time" - - "github.com/koudis/bootstrap-ai-coding/internal/agent" - "github.com/koudis/bootstrap-ai-coding/internal/constants" - "github.com/koudis/bootstrap-ai-coding/internal/docker" -) - -type vibeKanbanAgent struct{} - -func init() { - agent.Register(&vibeKanbanAgent{}) -} - -// ID returns the stable Agent_ID "vibe-kanban". -// Satisfies: VK-1.1 -func (a *vibeKanbanAgent) ID() string { - return constants.VibeKanbanAgentName -} - -// Install appends Dockerfile RUN steps that install Node.js (if not already -// installed), the vibe-kanban npm package, and the auto-start mechanism -// (supervisor script with crash recovery + ENTRYPOINT wrapper). -// Satisfies: VK-2.1, VK-2.2, VK-2.4, VK-3.1, VK-3.2, VK-3.5, VK-3.6 -func (a *vibeKanbanAgent) Install(b *docker.DockerfileBuilder) { - username := b.Username() - - // Node.js (conditional — skip if another agent already installed it) - if !b.IsNodeInstalled() { - b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*") - b.Run("curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*") - b.MarkNodeInstalled() - } - - // Runtime dependencies: iproute2 (ss for port discovery), procps (pgrep for health checks) - b.Run("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iproute2 procps && rm -rf /var/lib/apt/lists/*") - - // Install vibe-kanban globally and pre-download the platform binary. - // The timeout is short (15s) — enough for the download to complete and the - // server to start (confirming the binary works), then timeout kills it. - b.Run("npm install -g --no-fund --no-audit vibe-kanban") - b.Run(fmt.Sprintf("su -c 'BROWSER=none timeout 15 vibe-kanban || true' %s", username)) - - // Supervisor script with crash recovery (base64-encoded to avoid quoting issues) - supervisorScript := buildSupervisorScript(username) - supervisorB64 := base64.StdEncoding.EncodeToString([]byte(supervisorScript)) - b.Run(fmt.Sprintf("echo %s | base64 -d > /usr/local/bin/vibe-kanban-supervisor.sh && chmod +x /usr/local/bin/vibe-kanban-supervisor.sh", - supervisorB64)) - - // Entrypoint wrapper (base64-encoded to avoid quoting issues) - entrypoint := buildEntrypointScript() - entrypointB64 := base64.StdEncoding.EncodeToString([]byte(entrypoint)) - b.Run(fmt.Sprintf("echo %s | base64 -d > /usr/local/bin/bac-entrypoint.sh && chmod +x /usr/local/bin/bac-entrypoint.sh", - entrypointB64)) - - // ENTRYPOINT starts the supervisor before sshd - b.Entrypoint("/usr/local/bin/bac-entrypoint.sh") -} - -// buildSupervisorScript returns the supervisor shell script content. -// It substitutes the container username. The port is auto-assigned by -// vibe-kanban at startup (per VK-3.3) to avoid conflicts when multiple -// containers share the host network namespace. -// The script captures vibe-kanban's stdout to extract the auto-assigned port -// and writes it to a well-known file for SummaryInfo to read. -func buildSupervisorScript(username string) string { - return fmt.Sprintf(`#!/bin/bash -MAX_RESTARTS=5 -WINDOW_SECONDS=60 -DELAY_SECONDS=5 -PORT_FILE="/tmp/vibe-kanban.port" -LOG_FILE="/tmp/vibe-kanban.log" -RESTART_TIMES=() -while true; do - NOW=$(date +%%s) - PRUNED=() - for ts in "${RESTART_TIMES[@]}"; do - if (( NOW - ts < WINDOW_SECONDS )); then - PRUNED+=("$ts") - fi - done - RESTART_TIMES=("${PRUNED[@]}") - if (( ${#RESTART_TIMES[@]} >= MAX_RESTARTS )); then - echo "vibe-kanban-supervisor: exceeded $MAX_RESTARTS restarts in ${WINDOW_SECONDS}s, giving up" >&2 - exit 1 - fi - RESTART_TIMES+=("$(date +%%s)") - rm -f "$PORT_FILE" - su -c "exec env BROWSER=none HOST=0.0.0.0 vibe-kanban" "%s" > "$LOG_FILE" 2>&1 & - VK_PID=$! - # Wait up to 30s for the port to appear in the log output - for i in $(seq 1 30); do - sleep 1 - if [ -f "$LOG_FILE" ]; then - PORT=$(grep -oP 'Main server on :\K[0-9]+' "$LOG_FILE" 2>/dev/null | head -1) - if [ -n "$PORT" ]; then - echo "$PORT" > "$PORT_FILE" - break - fi - fi - done - wait $VK_PID 2>/dev/null || true - sleep "$DELAY_SECONDS" -done`, username) -} - -// buildEntrypointScript returns the entrypoint wrapper script content. -func buildEntrypointScript() string { - return `#!/bin/bash -set -e -/usr/local/bin/vibe-kanban-supervisor.sh & -exec "$@"` -} - -// CredentialStorePath returns empty — no credentials to persist. -// Satisfies: VK-4.1 -func (a *vibeKanbanAgent) CredentialStorePath() string { - return "" -} - -// ContainerMountPath returns empty — no bind-mount needed. -// Satisfies: VK-4.2 -func (a *vibeKanbanAgent) ContainerMountPath(homeDir string) string { - return "" -} - -// HasCredentials always returns true — nothing to check. -// Satisfies: VK-4.3 -func (a *vibeKanbanAgent) HasCredentials(storePath string) (bool, error) { - return true, nil -} - -// HealthCheck verifies that: -// 1. The vibe-kanban binary is present (vibe-kanban --version exits 0) -// 2. The vibe-kanban process is running (pgrep with retries) -// Satisfies: VK-5.1, VK-5.2 -func (a *vibeKanbanAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string) error { - // Check 1: Binary presence - exitCode, err := docker.ExecInContainer(ctx, c, containerID, []string{"vibe-kanban", "--version"}) - if err != nil { - return fmt.Errorf("vibe-kanban health check failed (binary): %w", err) - } - if exitCode != 0 { - return fmt.Errorf("vibe-kanban health check failed: 'vibe-kanban --version' exited with code %d", exitCode) - } - - // Check 2: Process running (with retries) - const maxRetries = 5 - const retryInterval = 2 * time.Second - - for attempt := 1; attempt <= maxRetries; attempt++ { - exitCode, err = docker.ExecInContainer(ctx, c, containerID, []string{"pgrep", "-f", "vibe-kanban"}) - if err != nil { - return fmt.Errorf("vibe-kanban health check failed (process check): %w", err) - } - if exitCode == 0 { - return nil // Process is running - } - if attempt < maxRetries { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(retryInterval): - } - } - } - - return fmt.Errorf("vibe-kanban health check failed: process not running after %d attempts", maxRetries) -} - -// vibeKanbanPortFile is the well-known path where the supervisor writes -// the auto-assigned port after vibe-kanban starts. -const vibeKanbanPortFile = "/tmp/vibe-kanban.port" - -// SummaryInfo discovers the port Vibe Kanban is listening on by reading -// the port file written by the supervisor script after startup. -// The port is auto-assigned by vibe-kanban at startup (VK-3.3, VK-9.1). -// Returns a single KeyValue with Key "Vibe Kanban" and Value "http://localhost:". -// Retries for up to 30 seconds with 2-second intervals. -// Satisfies: SI-5.1, SI-5.2, SI-5.3, SI-5.4 -func (a *vibeKanbanAgent) SummaryInfo(ctx context.Context, c *docker.Client, containerID string) ([]agent.KeyValue, error) { - deadline := time.Now().Add(30 * time.Second) - - for time.Now().Before(deadline) { - exitCode, output, err := docker.ExecInContainerWithOutput(ctx, c, containerID, - []string{"cat", vibeKanbanPortFile}) - if err != nil { - return nil, err - } - if exitCode == 0 { - portStr := strings.TrimSpace(output) - port, err := strconv.Atoi(portStr) - if err == nil && port > 0 && port <= 65535 { - return []agent.KeyValue{ - {Key: "Vibe Kanban", Value: fmt.Sprintf("http://localhost:%d", port)}, - }, nil - } - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(2 * time.Second): - } - } - return nil, fmt.Errorf("timed out after 30s waiting for vibe-kanban port file (%s)", vibeKanbanPortFile) -} diff --git a/internal/agents/vibekanban/vibekanban_test.go b/internal/agents/vibekanban/vibekanban_test.go deleted file mode 100644 index 6901c68..0000000 --- a/internal/agents/vibekanban/vibekanban_test.go +++ /dev/null @@ -1,578 +0,0 @@ -// Package vibekanban_test contains unit tests for the Vibe Kanban agent module. -// The blank import of the vibekanban package triggers its init() function, which -// registers the vibeKanbanAgent with the global agent registry. -package vibekanban_test - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/stretchr/testify/require" - "pgregory.net/rapid" - - "github.com/koudis/bootstrap-ai-coding/internal/agent" - _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" - "github.com/koudis/bootstrap-ai-coding/internal/constants" - "github.com/koudis/bootstrap-ai-coding/internal/docker" - "github.com/koudis/bootstrap-ai-coding/internal/hostinfo" -) - -// newTestBuilder returns a DockerfileBuilder pre-seeded with the base layer, -// using fixed key material and UserStrategyCreate with uid=1000, gid=1000. -func newTestBuilder() *docker.DockerfileBuilder { - return docker.NewBaseImageBuilder( - &hostinfo.Info{Username: "testuser", HomeDir: "/home/testuser", UID: 1000, GID: 1000}, - docker.UserStrategyCreate, "", - "", - ) -} - -func getAgent(t *testing.T) agent.Agent { - t.Helper() - a, err := agent.Lookup(constants.VibeKanbanAgentName) - require.NoError(t, err, "vibe-kanban agent must be registered") - return a -} - -// --------------------------------------------------------------------------- -// TestID — returns constants.VibeKanbanAgentName -// Validates: VK-1.1 -// --------------------------------------------------------------------------- - -func TestID(t *testing.T) { - a := getAgent(t) - require.Equal(t, constants.VibeKanbanAgentName, a.ID()) -} - -// --------------------------------------------------------------------------- -// TestInstallNodeAlreadyInstalled — skips Node.js when IsNodeInstalled() is true -// Validates: VK-2.1 -// --------------------------------------------------------------------------- - -func TestInstallNodeAlreadyInstalled(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - b.MarkNodeInstalled() // simulate a prior agent having installed Node.js - require.True(t, b.IsNodeInstalled()) - - a.Install(b) - content := b.Build() - - // Must NOT contain the Node.js setup step - require.NotContains(t, content, "setup_22.x", - "must skip Node.js setup when already installed") - - // Must still install the npm package - require.Contains(t, content, "vibe-kanban", - "must always install the vibe-kanban npm package") -} - -// --------------------------------------------------------------------------- -// TestInstallNodeNotInstalled — installs Node.js when IsNodeInstalled() is false -// Validates: VK-2.1 -// --------------------------------------------------------------------------- - -func TestInstallNodeNotInstalled(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - require.False(t, b.IsNodeInstalled(), "fresh builder must have IsNodeInstalled() == false") - - a.Install(b) - content := b.Build() - - require.Contains(t, content, "setup_22.x", - "must install Node.js 22 when not already installed") - require.Contains(t, content, "nodejs", - "must install nodejs package when not already installed") - require.True(t, b.IsNodeInstalled(), - "MarkNodeInstalled() must be called after Node.js installation") -} - -// --------------------------------------------------------------------------- -// TestInstallContainsNpmPackage — output contains `npm install -g` with `vibe-kanban` -// Validates: VK-2.2 -// --------------------------------------------------------------------------- - -func TestInstallContainsNpmPackage(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - a.Install(b) - content := b.Build() - - require.Contains(t, content, "npm install -g", - "must contain npm install -g") - require.Contains(t, content, "vibe-kanban", - "must contain vibe-kanban package name") -} - -// --------------------------------------------------------------------------- -// TestInstallContainsEntrypoint — output contains ENTRYPOINT instruction -// Validates: VK-3.1 -// --------------------------------------------------------------------------- - -func TestInstallContainsEntrypoint(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - a.Install(b) - content := b.Build() - - require.Contains(t, content, "ENTRYPOINT", - "must contain ENTRYPOINT instruction") - require.Contains(t, content, "bac-entrypoint.sh", - "ENTRYPOINT must reference bac-entrypoint.sh") -} - -// --------------------------------------------------------------------------- -// TestInstallContainsSupervisor — supervisor script (base64-encoded) contains crash recovery params -// Validates: VK-3.1, VK-2.4 -// --------------------------------------------------------------------------- - -func TestInstallContainsSupervisor(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - a.Install(b) - content := b.Build() - - require.Contains(t, content, "vibe-kanban-supervisor.sh", - "must contain supervisor script reference") - - // The script is base64-encoded in the Dockerfile. Decode it to verify contents. - // Find the line that writes the supervisor script: "echo | base64 -d > /usr/local/bin/vibe-kanban-supervisor.sh" - var supervisorB64 string - for _, line := range strings.Split(content, "\n") { - if strings.Contains(line, "vibe-kanban-supervisor.sh") && strings.Contains(line, "base64 -d") { - // Extract the base64 payload between "echo " and " | base64" - after := strings.TrimPrefix(line, "RUN echo ") - idx := strings.Index(after, " | base64") - if idx > 0 { - supervisorB64 = after[:idx] - } - break - } - } - require.NotEmpty(t, supervisorB64, "must find base64-encoded supervisor script in Dockerfile") - - decoded, err := base64Decode(supervisorB64) - require.NoError(t, err, "supervisor script base64 must decode cleanly") - - require.Contains(t, decoded, "MAX_RESTARTS=5", - "supervisor must have MAX_RESTARTS=5") - require.Contains(t, decoded, "WINDOW_SECONDS=60", - "supervisor must have WINDOW_SECONDS=60") - require.Contains(t, decoded, "DELAY_SECONDS=5", - "supervisor must have DELAY_SECONDS=5") -} - -// --------------------------------------------------------------------------- -// TestInstallDoesNotContainCMD — output does NOT contain CMD instruction -// Validates: VK-3.1 -// --------------------------------------------------------------------------- - -func TestInstallDoesNotContainCMD(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - a.Install(b) - content := b.Build() - - // Check that no line starts with CMD - for _, line := range strings.Split(content, "\n") { - require.False(t, strings.HasPrefix(strings.TrimSpace(line), "CMD"), - "Install() must not emit a CMD instruction, found: %q", line) - } -} - -// --------------------------------------------------------------------------- -// TestInstallNoRustNoPnpm — output does NOT contain rust/pnpm references -// Validates: VK-2.2 -// --------------------------------------------------------------------------- - -func TestInstallNoRustNoPnpm(t *testing.T) { - a := getAgent(t) - - b := newTestBuilder() - a.Install(b) - content := b.Build() - - require.NotContains(t, content, "rust", - "Install() must not contain rust references") - require.NotContains(t, content, "pnpm", - "Install() must not contain pnpm references") -} - -// --------------------------------------------------------------------------- -// TestCredentialStorePath — returns empty string -// Validates: VK-4.1 -// --------------------------------------------------------------------------- - -func TestCredentialStorePath(t *testing.T) { - a := getAgent(t) - require.Equal(t, "", a.CredentialStorePath()) -} - -// --------------------------------------------------------------------------- -// TestContainerMountPath — returns empty string for various homeDir values -// Validates: VK-4.2 -// --------------------------------------------------------------------------- - -func TestContainerMountPath(t *testing.T) { - a := getAgent(t) - - homeDirs := []string{ - "/home/testuser", - "/home/dev", - "/root", - "/home/alice", - } - for _, homeDir := range homeDirs { - require.Equal(t, "", a.ContainerMountPath(homeDir), - "ContainerMountPath(%q) must return empty string", homeDir) - } -} - -// --------------------------------------------------------------------------- -// TestHasCredentials — returns (true, nil) -// Validates: VK-4.3 -// --------------------------------------------------------------------------- - -func TestHasCredentials(t *testing.T) { - a := getAgent(t) - - has, err := a.HasCredentials("") - require.NoError(t, err) - require.True(t, has, "HasCredentials must always return true for vibe-kanban") - - has, err = a.HasCredentials("/some/path") - require.NoError(t, err) - require.True(t, has, "HasCredentials must always return true regardless of path") -} - -// --------------------------------------------------------------------------- -// Health check tests — mock Docker client via httptest with connection hijacking -// --------------------------------------------------------------------------- - -// hijackHandler handles the exec attach endpoint by hijacking the HTTP connection, -// which is what the Docker SDK expects for exec attach operations. -func hijackHandler(w http.ResponseWriter, _ *http.Request) { - hj, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijacking not supported", http.StatusInternalServerError) - return - } - conn, buf, err := hj.Hijack() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - // Write the HTTP 101 Switching Protocols response that Docker SDK expects - buf.WriteString("HTTP/1.1 101 UPGRADED\r\nContent-Type: application/vnd.docker.raw-stream\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") - buf.Flush() - conn.Close() -} - -// newFakeDockerClient creates a *docker.Client backed by a fake HTTP server. -// The exitCode parameter controls what exit code the exec inspect returns for -// all exec operations. -func newFakeDockerClient(t *testing.T, exitCode int) *docker.Client { - t.Helper() - - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - // Handle API version negotiation / ping - if strings.HasSuffix(path, "/_ping") || path == "/_ping" { - w.Header().Set("Api-Version", "1.47") - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "OK") - return - } - - // ContainerExecCreate - if r.Method == http.MethodPost && strings.Contains(path, "/exec") && !strings.Contains(path, "/start") && !strings.Contains(path, "/json") { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(container.ExecCreateResponse{ID: "fake-exec-id"}) - return - } - - // ContainerExecAttach (start) — requires connection hijacking - if r.Method == http.MethodPost && strings.Contains(path, "/exec/") && strings.Contains(path, "/start") { - hijackHandler(w, r) - return - } - - // ContainerExecInspect (json) - if r.Method == http.MethodGet && strings.Contains(path, "/exec/") && strings.Contains(path, "/json") { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(container.ExecInspect{ - ExitCode: exitCode, - Running: false, - }) - return - } - - // Default - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `{}`) - }) - - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - // Point DOCKER_HOST at our fake server (use tcp:// scheme for Docker SDK) - host := strings.Replace(srv.URL, "http://", "tcp://", 1) - t.Setenv("DOCKER_HOST", host) - client, err := docker.NewClient() - require.NoError(t, err) - - return client -} - -// newFakeDockerClientWithExecSequence creates a *docker.Client backed by a fake -// HTTP server where each successive exec returns a different exit code from the -// provided sequence. -func newFakeDockerClientWithExecSequence(t *testing.T, exitCodes []int) *docker.Client { - t.Helper() - - callCount := 0 - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - if strings.HasSuffix(path, "/_ping") || path == "/_ping" { - w.Header().Set("Api-Version", "1.47") - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "OK") - return - } - - // ContainerExecCreate — increment call count - if r.Method == http.MethodPost && strings.Contains(path, "/exec") && !strings.Contains(path, "/start") && !strings.Contains(path, "/json") { - callCount++ - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(container.ExecCreateResponse{ID: fmt.Sprintf("fake-exec-id-%d", callCount)}) - return - } - - // ContainerExecAttach (start) — requires connection hijacking - if r.Method == http.MethodPost && strings.Contains(path, "/exec/") && strings.Contains(path, "/start") { - hijackHandler(w, r) - return - } - - // ContainerExecInspect (json) — return exit code based on which exec this is - if r.Method == http.MethodGet && strings.Contains(path, "/exec/") && strings.Contains(path, "/json") { - // Determine which exec ID this is for - exitCode := 1 // default to failure - for i, code := range exitCodes { - id := fmt.Sprintf("fake-exec-id-%d", i+1) - if strings.Contains(path, id) { - exitCode = code - break - } - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(container.ExecInspect{ - ExitCode: exitCode, - Running: false, - }) - return - } - - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `{}`) - }) - - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - host := strings.Replace(srv.URL, "http://", "tcp://", 1) - t.Setenv("DOCKER_HOST", host) - client, err := docker.NewClient() - require.NoError(t, err) - - return client -} - -// --------------------------------------------------------------------------- -// TestHealthCheckBinaryFailure — error message identifies binary check -// Validates: VK-5.1 -// --------------------------------------------------------------------------- - -func TestHealthCheckBinaryFailure(t *testing.T) { - a := getAgent(t) - - // Create a fake Docker client that returns exit code 1 for all execs - // (simulating vibe-kanban --version failing) - client := newFakeDockerClient(t, 1) - - ctx := context.Background() - err := a.HealthCheck(ctx, client, "fake-container-id") - - require.Error(t, err, "HealthCheck must fail when binary check returns non-zero") - require.Contains(t, err.Error(), "vibe-kanban", - "error message must mention vibe-kanban") - // The binary check fails first — error identifies the version/binary check - require.Contains(t, err.Error(), "--version", - "error message must identify the binary check (references --version)") -} - -// --------------------------------------------------------------------------- -// TestHealthCheckProcessFailure — error message identifies process check -// Validates: VK-5.2 -// --------------------------------------------------------------------------- - -func TestHealthCheckProcessFailure(t *testing.T) { - a := getAgent(t) - - // First exec (binary check) passes with exit 0, all subsequent execs - // (pgrep process checks, up to 5 retries) fail with exit 1. - exitCodes := []int{0, 1, 1, 1, 1, 1} - client := newFakeDockerClientWithExecSequence(t, exitCodes) - - ctx := context.Background() - err := a.HealthCheck(ctx, client, "fake-container-id") - - require.Error(t, err, "HealthCheck must fail when process is not running") - require.Contains(t, err.Error(), "vibe-kanban", - "error message must mention vibe-kanban") - require.Contains(t, err.Error(), "process", - "error message must identify the process check") -} - -// Feature: bootstrap-ai-coding, Property 3: No-credential-store invariant -func TestPropertyNoCredentialStore(t *testing.T) { - rapid.Check(t, func(rt *rapid.T) { - homeDir := rapid.String().Draw(rt, "homeDir") - storePath := rapid.String().Draw(rt, "storePath") - - a, err := agent.Lookup(constants.VibeKanbanAgentName) - require.NoError(rt, err, "vibe-kanban agent must be registered") - - // **Validates: Requirements VK-4.2** - mountPath := a.ContainerMountPath(homeDir) - require.Equal(rt, "", mountPath, - "ContainerMountPath(%q) must always return empty string", homeDir) - - // **Validates: Requirements VK-4.3** - has, credErr := a.HasCredentials(storePath) - require.NoError(rt, credErr, - "HasCredentials(%q) must not return an error", storePath) - require.True(rt, has, - "HasCredentials(%q) must always return true", storePath) - }) -} - -// Feature: bootstrap-ai-coding, Property 1: Node.js conditional installation invariant -func TestPropertyNodeJSConditionalInstallation(t *testing.T) { - rapid.Check(t, func(rt *rapid.T) { - // draw inputs - nodePreInstalled := rapid.Bool().Draw(rt, "nodePreInstalled") - - // exercise the function - b := newTestBuilder() - if nodePreInstalled { - b.MarkNodeInstalled() - } - - a, err := agent.Lookup(constants.VibeKanbanAgentName) - require.NoError(rt, err, "vibe-kanban agent must be registered") - - a.Install(b) - - output := b.Build() - - // assert the property holds: - // 1. At most one occurrence of "setup_22.x" (Node.js installation block) - occurrences := strings.Count(output, "setup_22.x") - require.LessOrEqual(rt, occurrences, 1, - "Install() must produce at most one Node.js installation block, got %d", occurrences) - - // 2. After Install(), IsNodeInstalled() returns true - require.True(rt, b.IsNodeInstalled(), - "IsNodeInstalled() must return true after Install()") - }) -} - -// Feature: bootstrap-ai-coding, Property 2: Install does not emit CMD -func TestPropertyInstallDoesNotEmitCMD(t *testing.T) { - rapid.Check(t, func(rt *rapid.T) { - nodePreInstalled := rapid.Bool().Draw(rt, "nodePreInstalled") - - b := newTestBuilder() - if nodePreInstalled { - b.MarkNodeInstalled() - } - - a, err := agent.Lookup(constants.VibeKanbanAgentName) - require.NoError(rt, err, "vibe-kanban agent must be registered") - - a.Install(b) - - output := b.Build() - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - require.False(rt, strings.HasPrefix(trimmed, "CMD"), - "Install() must not emit any CMD instruction, but found: %q", trimmed) - } - }) -} - -// Feature: bootstrap-ai-coding, Property 3: Vibe Kanban URL format -func TestPropertyVibeKanbanURLFormat(t *testing.T) { - // **Validates: Requirements SI-5.2** - rapid.Check(t, func(rt *rapid.T) { - port := rapid.IntRange(1, 65535).Draw(rt, "port") - - // Construct the URL the same way SummaryInfo does. - url := fmt.Sprintf("http://localhost:%d", port) - - // The URL must match "http://localhost:" exactly. - expected := fmt.Sprintf("http://localhost:%d", port) - require.Equal(rt, expected, url, - "URL must be exactly http://localhost: for port %d", port) - - // Structural invariants: - // 1. Starts with the correct scheme and host - require.True(rt, strings.HasPrefix(url, "http://localhost:"), - "URL must start with http://localhost:") - - // 2. The port suffix is the decimal string representation of the port - portStr := strings.TrimPrefix(url, "http://localhost:") - require.Equal(rt, fmt.Sprintf("%d", port), portStr, - "port portion must be the decimal string of the port number") - - // 3. No trailing path, slash, or query string - require.NotContains(rt, portStr, "/", - "URL must not contain a trailing slash or path") - require.NotContains(rt, portStr, "?", - "URL must not contain a query string") - }) -} - -// base64Decode is a test helper that decodes a standard base64 string. -func base64Decode(s string) (string, error) { - b, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index df7f0e8..9847e4c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -723,6 +723,15 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age HostPath: s.resolvedPath, ContainerPath: containerPath, }) + // Check if the agent declares additional mounts (e.g. OpenCode config store). + if mounter, ok := s.a.(agent.AdditionalMounter); ok { + for _, extra := range mounter.AdditionalMounts(info.HomeDir) { + if err := datadir.EnsureCredentialDir(extra.HostPath); err != nil { + return fmt.Errorf("ensuring additional credential dir for %s: %w", s.a.ID(), err) + } + mounts = append(mounts, extra) + } + } } spec := dockerpkg.ContainerSpec{ @@ -748,7 +757,7 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age return fmt.Errorf("container started but SSH did not become ready: %w", err) } - // Run health checks for all enabled agents (CC-5, AC-5, BR-4, VK-5). + // Run health checks for all enabled agents (CC-5, AC-5, BR-4). for _, a := range enabledAgents { if err := a.HealthCheck(ctx, c, containerName); err != nil { fmt.Fprintf(os.Stderr, "warning: %s health check: %v\n", a.ID(), err) diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index e16346b..ce9c3cd 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -187,27 +187,25 @@ func TestFormatSessionSummaryValues(t *testing.T) { require.Contains(t, output, "aider") } -// TestFormatSessionSummaryWithVibeKanban verifies that the "Vibe Kanban:" line -// is present in the output when AgentInfo contains a Vibe Kanban entry. -// Validates: VK-8.3, VK-8.4 -func TestFormatSessionSummaryWithVibeKanban(t *testing.T) { +// TestFormatSessionSummaryWithAgentInfo verifies that the agent info line +// is present in the output when AgentInfo contains an entry. +func TestFormatSessionSummaryWithAgentInfo(t *testing.T) { summary := cmd.SessionSummary{ DataDir: "/home/user/.config/bootstrap-ai-coding/bac-myproject", ProjectDir: "/home/user/myproject", SSHPort: 2222, SSHConnect: "ssh bac-myproject", - EnabledAgents: []string{"claude-code", "vibe-kanban"}, - AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: "http://localhost:3000"}}, + EnabledAgents: []string{"claude-code"}, + AgentInfo: []agent.KeyValue{{Key: "My Agent", Value: "http://localhost:8080"}}, } output := cmd.FormatSessionSummary(summary) - require.Contains(t, output, "Vibe Kanban:") - require.Contains(t, output, "http://localhost:3000") + require.Contains(t, output, "My Agent:") + require.Contains(t, output, "http://localhost:8080") } -// TestFormatSessionSummaryWithoutVibeKanban verifies that the "Vibe Kanban:" line +// TestFormatSessionSummaryWithoutAgentInfo verifies that the "My Agent:" line // is absent from the output when AgentInfo is empty. -// Validates: VK-8.3, VK-8.4 -func TestFormatSessionSummaryWithoutVibeKanban(t *testing.T) { +func TestFormatSessionSummaryWithoutAgentInfo(t *testing.T) { summary := cmd.SessionSummary{ DataDir: "/home/user/.config/bootstrap-ai-coding/bac-myproject", ProjectDir: "/home/user/myproject", @@ -217,7 +215,7 @@ func TestFormatSessionSummaryWithoutVibeKanban(t *testing.T) { AgentInfo: nil, } output := cmd.FormatSessionSummary(summary) - require.NotContains(t, output, "Vibe Kanban:") + require.NotContains(t, output, "My Agent:") } // Feature: bootstrap-ai-coding, Property 35: --port is always within 1024–65535 when provided @@ -598,8 +596,8 @@ func TestPropertyCollectionPreservesOrderAndExcludesErrors(t *testing.T) { }) } -// Feature: bootstrap-ai-coding, Property 4: Session summary includes Vibe Kanban URL for any valid port -func TestPropertySessionSummaryIncludesVibeKanbanURL(t *testing.T) { +// Feature: bootstrap-ai-coding, Property 4: Session summary includes agent info URL for any valid port +func TestPropertySessionSummaryIncludesAgentInfoURL(t *testing.T) { rapid.Check(t, func(t *rapid.T) { port := rapid.IntRange(1, 65535).Draw(t, "port") url := fmt.Sprintf("http://localhost:%d", port) @@ -609,19 +607,19 @@ func TestPropertySessionSummaryIncludesVibeKanbanURL(t *testing.T) { ProjectDir: "/home/user/project", SSHPort: 2222, SSHConnect: "ssh bac-test", - EnabledAgents: []string{"vibe-kanban"}, - AgentInfo: []agent.KeyValue{{Key: "Vibe Kanban", Value: url}}, + EnabledAgents: []string{"claude-code"}, + AgentInfo: []agent.KeyValue{{Key: "My Agent", Value: url}}, } output := cmd.FormatSessionSummary(summary) - // When AgentInfo contains Vibe Kanban, output must contain "Vibe Kanban:" and the URL. - require.Contains(t, output, "Vibe Kanban:", - "output must contain 'Vibe Kanban:' label when AgentInfo is set") + // When AgentInfo contains My Agent, output must contain "My Agent:" and the URL. + require.Contains(t, output, "My Agent:", + "output must contain 'My Agent:' label when AgentInfo is set") require.Contains(t, output, url, - "output must contain the Vibe Kanban URL %q", url) + "output must contain the agent info URL %q", url) - // When AgentInfo is empty, output must NOT contain "Vibe Kanban:". + // When AgentInfo is empty, output must NOT contain "My Agent:". summaryEmpty := cmd.SessionSummary{ DataDir: "/home/user/.config/bootstrap-ai-coding/bac-test", ProjectDir: "/home/user/project", @@ -632,7 +630,7 @@ func TestPropertySessionSummaryIncludesVibeKanbanURL(t *testing.T) { } outputEmpty := cmd.FormatSessionSummary(summaryEmpty) - require.NotContains(t, outputEmpty, "Vibe Kanban:", - "output must NOT contain 'Vibe Kanban:' when AgentInfo is empty") + require.NotContains(t, outputEmpty, "My Agent:", + "output must NOT contain 'My Agent:' when AgentInfo is empty") }) } diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 2ed5fb0..6d03b59 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -63,10 +63,13 @@ const ( // Corresponds to the Agent_ID glossary term for Build Resources (BR-1). BuildResourcesAgentName = "build-resources" - // VibeKanbanAgentName is the stable Agent_ID for the Vibe Kanban agent - // module that provides a web-based project management tool. - // Corresponds to the Agent_ID glossary term for Vibe Kanban (VK-1). - VibeKanbanAgentName = "vibe-kanban" + // CodexAgentName is the stable Agent_ID for the OpenAI Codex CLI agent module. + // Corresponds to the Agent_ID glossary term for Codex (CX-1). + CodexAgentName = "codex" + + // OpenCodeAgentName is the stable Agent_ID for the OpenCode agent module. + // Corresponds to the Agent_ID glossary term for OpenCode. + OpenCodeAgentName = "open-code" // DefaultAgents is the comma-separated list of agent IDs enabled when the // --agents flag is omitted. Claude Code, Augment Code, and Build Resources diff --git a/internal/docker/builder_test.go b/internal/docker/builder_test.go index 0520771..c897200 100644 --- a/internal/docker/builder_test.go +++ b/internal/docker/builder_test.go @@ -1501,7 +1501,6 @@ func TestInstanceImageSSHDConfigHostNetwork(t *testing.T) { // --------------------------------------------------------------------------- // Unit tests for Entrypoint builder method -// Validates: VK-3.1 // --------------------------------------------------------------------------- func TestBuilderEntrypointSingleArg(t *testing.T) { diff --git a/main.go b/main.go index a51abc8..c9c94d9 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,8 @@ import ( _ "github.com/koudis/bootstrap-ai-coding/internal/agents/augment" _ "github.com/koudis/bootstrap-ai-coding/internal/agents/buildresources" _ "github.com/koudis/bootstrap-ai-coding/internal/agents/claude" - _ "github.com/koudis/bootstrap-ai-coding/internal/agents/vibekanban" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/codex" + _ "github.com/koudis/bootstrap-ai-coding/internal/agents/opencode" ) func main() {