Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .kiro/specs/bootstrap-ai-coding/design-build-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ RUN useradd <username> ← 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 /etc/profile.d/workspace-cd.sh ← workspace cd-on-login (Req 27)
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
Expand Down
16 changes: 16 additions & 0 deletions .kiro/specs/bootstrap-ai-coding/design-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ This script runs on every SSH login (interactive shells source `/etc/profile.d/*

---

## Workspace Working Directory (Req 27)

A shell profile script (`/etc/profile.d/workspace-cd.sh`) is installed that changes the working directory to `/workspace` on SSH login, so the user lands directly in their project directory without a manual `cd`.

```sh
#!/bin/sh
# /etc/profile.d/workspace-cd.sh — land in the workspace directory on SSH login
cd /workspace 2>/dev/null || true
```

The `2>/dev/null || true` suppresses errors if `/workspace` is not mounted (defensive; in practice the mount is always present). Like the keyring script, this runs on every interactive SSH login via the `/etc/profile.d/*.sh` sourcing mechanism.

**Validates: Req 27**

---

## Git Configuration Forwarding (Req 24)

The `DockerfileBuilder` injects the host user's `~/.gitconfig` into the container image at build time, following the same pattern as SSH host key injection (step 6 in the constructor). The git config content is read by the caller (`cmd/root.go`) and passed to the builder as an optional string parameter.
Expand Down
8 changes: 8 additions & 0 deletions .kiro/specs/bootstrap-ai-coding/design-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,14 @@

---

#### Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript

*For any* UID/GID combination and user strategy (Create or Rename), the Base_Image Dockerfile produced by `NewBaseImageBuilder` SHALL contain a `RUN` instruction that creates a script at `constants.WorkspaceProfileScript` (`/etc/profile.d/workspace-cd.sh`) which references `constants.WorkspaceMountPath` (`/workspace`) and is made executable (`chmod +x`).

**Validates: Req 27.1, 27.2, 27.3**

---

### Agent Module Properties

#### Property 27: All registered agents satisfy the Agent interface
Expand Down
14 changes: 14 additions & 0 deletions .kiro/specs/bootstrap-ai-coding/requirements-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,17 @@ The core application is responsible for all orchestration: Docker lifecycle mana
12. THE `--host-network-off` flag SHALL only be valid in START mode; it is a START-only flag subject to the CLI-3 constraint.
13. THE `--host-network-off` value SHALL influence the Instance_Image build: when absent (host mode), sshd_config includes `Port <SSH_Port>` and `ListenAddress 127.0.0.1`; when set (bridge mode), these directives are omitted.
14. WHEN `--host-network-off` is changed between invocations for the same project (e.g. added or removed), THE CLI SHALL require `--rebuild` to regenerate the Instance_Image with the correct sshd_config. IF the network mode has changed and `--rebuild` is not set, THE CLI SHALL print a message instructing the user to run with `--rebuild` and exit with a zero exit code.

---

### Requirement 27: Workspace as Default Working Directory on SSH Login

**User Story:** As a developer, I want to land directly in `/workspace` when I SSH into the container, so that I can start working on my project immediately without navigating there manually.

#### Acceptance Criteria

1. WHEN a user connects to the Container via SSH, THE shell session SHALL have its working directory set to `constants.WorkspaceMountPath` (`/workspace`).
2. THE working directory change SHALL be implemented via a shell profile script at `constants.WorkspaceProfileScript` (`/etc/profile.d/workspace-cd.sh`) installed in the Base_Image layer.
3. THE profile script SHALL fail silently (no error output, no non-zero exit) if `/workspace` is not accessible.
4. THE Container_User's home directory SHALL remain unchanged — only the initial working directory of the SSH login session is affected.
5. THE integration test SHALL verify this requirement via an actual SSH connection (using `golang.org/x/crypto/ssh`) — not via `docker exec` or `su -l` — to confirm the real user path works end-to-end.
1 change: 1 addition & 0 deletions .kiro/steering/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ This means:
| `BaseImageTag` | `"bac-base:latest"` | Full base image reference (TL-11) |
| `GitConfigPerm` | `0o444` | Injected .gitconfig permissions (Req 24) |
| `KeyringProfileScript` | `"/etc/profile.d/dbus-keyring.sh"` | Keyring startup script path (CC-7) |
| `WorkspaceProfileScript` | `"/etc/profile.d/workspace-cd.sh"` | Workspace cd-on-login script path (Req 27) |
| `ImageBuildTimeout` | `8 * time.Minute` | Image_Build_Timeout (Req 14.7) |

### Variables (not const — Go does not support slice/map constants)
Expand Down
1 change: 1 addition & 0 deletions .kiro/steering/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,4 @@ import (
- Default agents: `claude-code,augment-code,build-resources,open-code,codex` (constants.DefaultAgents)
- File permissions: Tool_Data_Dir `0700` (constants.ToolDataDirPerm), all files within `0600` (constants.ToolDataFilePerm)
- Headless keyring: D-Bus session bus + gnome-keyring-daemon started via `/etc/profile.d/dbus-keyring.sh` on SSH login — enables libsecret-based credential storage (CC-7)
- Workspace landing directory: `/etc/profile.d/workspace-cd.sh` changes the working directory to `/workspace` on SSH login (Req 27)
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# bootstrap-ai-coding

`bootstrap-ai-coding` (`bac`) is a Go CLI tool that provisions an isolated Docker container for AI-assisted coding sessions.
`bootstrap-ai-coding` (`bac`) is a Go CLI tool that provisions an isolated Docker container for AI-assisted coding sessions. Your agent credentials are bind-mounted from the host — no login or settings transfer needed, just run and code.

Primarily designed to work with Visual Studio Code but it works with any IDE with code-over-ssh.
Primarily designed to work with Codium and Visual Studio Code but it works with any IDE with code-over-ssh.

## Install

Expand All @@ -18,7 +18,7 @@ wget https://github.com/koudis/bootstrap-ai-coding/releases/latest/download/bac-

1. `bac <project_path>`
2. Open Visual Studio Code, press Ctrl+Shift+P, run Remote-SSH and choose `bac-<project_folder_name>` target to connect.
3. `<project_path>` can be found under `/workspace`
3. Your shell starts in `/workspace` — your mounted project directory, ready to go.

where `project_folder_name` is the name of the bottom-most folder in `project_path`. (`/my/nice/project` → `project`)

Expand All @@ -43,7 +43,7 @@ SSH connect: ssh bac-myproject
Enabled agents: claude-code, augment-code, build-resources
```

After that, `ssh bac-myproject` works — no port or username to remember.
After that, `ssh bac-myproject` works — no port or username to remember. You land directly in `/workspace` (your mounted project directory).

## Prerequisites

Expand Down
4 changes: 4 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ const (
// Satisfies CC-7.
KeyringProfileScript = "/etc/profile.d/dbus-keyring.sh"

// WorkspaceProfileScript is the path to the shell profile script inside the
// container that changes the working directory to WorkspaceMountPath on SSH login.
WorkspaceProfileScript = "/etc/profile.d/workspace-cd.sh"

// HostBindIP is the IP address containers bind their SSH port to on the host.
// Satisfies R7.
HostBindIP = "127.0.0.1"
Expand Down
7 changes: 6 additions & 1 deletion internal/docker/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ func NewBaseImageBuilder(info *hostinfo.Info, strategy UserStrategy, conflicting
b.Run(fmt.Sprintf("printf '%s' > %s && chmod +x %s",
keyringScript, constants.KeyringProfileScript, constants.KeyringProfileScript))

// 7. Inject host user's ~/.gitconfig into the container (Req 24).
// 7. Install profile.d script that changes to the workspace directory on SSH login.
workspaceCdScript := fmt.Sprintf(`#!/bin/sh\ncd %s 2>/dev/null || true\n`, constants.WorkspaceMountPath)
b.Run(fmt.Sprintf("printf '%s' > %s && chmod +x %s",
workspaceCdScript, constants.WorkspaceProfileScript, constants.WorkspaceProfileScript))

// 8. Inject host user's ~/.gitconfig into the container (Req 24).
if b.gitConfig != "" {
encoded := base64.StdEncoding.EncodeToString([]byte(b.gitConfig))
gitConfigPath := fmt.Sprintf("%s/.gitconfig", info.HomeDir)
Expand Down
36 changes: 36 additions & 0 deletions internal/docker/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,42 @@ func TestKeyringProfileScriptPresentInRenameStrategy(t *testing.T) {
"Rename strategy Dockerfile must also create keyring profile script")
}

// ---------------------------------------------------------------------------
// Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript
// ---------------------------------------------------------------------------

// Feature: bootstrap-ai-coding, Property 58: Workspace profile script is always created at constants.WorkspaceProfileScript
// Validates: Req 27
func TestPropertyWorkspaceProfileScriptCreated(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
uid := rapid.IntRange(1000, 65000).Draw(t, "uid")
gid := rapid.IntRange(1000, 65000).Draw(t, "gid")

b := newCreateBuilder(uid, gid)
content := b.Build()

require.Contains(t, content, constants.WorkspaceProfileScript,
"Dockerfile must reference WorkspaceProfileScript path %q", constants.WorkspaceProfileScript)
require.Contains(t, content, constants.WorkspaceMountPath,
"Workspace script must reference WorkspaceMountPath %q", constants.WorkspaceMountPath)
require.Contains(t, content, "chmod +x "+constants.WorkspaceProfileScript,
"Workspace script must be made executable")
})
}

// TestWorkspaceProfileScriptPresentInRenameStrategy verifies that the workspace-cd
// setup is also present when using UserStrategyRename.
// Validates: Req 27
func TestWorkspaceProfileScriptPresentInRenameStrategy(t *testing.T) {
b := newRenameBuilder(1000, 1000, "ubuntu")
content := b.Build()

require.Contains(t, content, constants.WorkspaceProfileScript,
"Rename strategy Dockerfile must also create workspace profile script")
require.Contains(t, content, constants.WorkspaceMountPath,
"Rename strategy Dockerfile must reference WorkspaceMountPath in workspace script")
}

// ---------------------------------------------------------------------------
// Node.js deduplication tracking tests
// ---------------------------------------------------------------------------
Expand Down
130 changes: 130 additions & 0 deletions internal/docker/integration_container_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//go:build integration

package docker_test

import (
"context"
"fmt"
"net"
"os/exec"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/koudis/bootstrap-ai-coding/internal/constants"
"github.com/koudis/bootstrap-ai-coding/internal/docker"
"github.com/koudis/bootstrap-ai-coding/internal/hostinfo"
)

// ----------------------------------------------------------------------------
// 16.1 TestContainerStartsAndSSHConnects
// Validates: Req 3.3, 4.3
// ----------------------------------------------------------------------------

func TestContainerStartsAndSSHConnects(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}

_, sshPort, _, cleanup := startContainerFromSharedImage(t)
t.Cleanup(cleanup)

addr := fmt.Sprintf("127.0.0.1:%d", sshPort)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
require.NoError(t, err, "expected TCP connection to SSH port %d to succeed", sshPort)
conn.Close()
}

// ----------------------------------------------------------------------------
// 16.2 TestWorkspaceMountLiveSync
// Validates: Req 2.3
// ----------------------------------------------------------------------------

func TestWorkspaceMountLiveSync(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}

containerName, _, client, cleanup := startContainerFromSharedImage(t)
t.Cleanup(cleanup)

ctx := context.Background()

exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{
"bash", "-c", "echo 'hello from container' > /workspace/sync-test.txt",
})
require.NoError(t, err, "exec to create file in /workspace")
require.Equal(t, 0, exitCode, "expected exit 0 when creating file in /workspace")

exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{
"test", "-f", constants.WorkspaceMountPath + "/sync-test.txt",
})
require.NoError(t, err, "exec to verify file in /workspace")
require.Equal(t, 0, exitCode, "expected file to exist at %s/sync-test.txt", constants.WorkspaceMountPath)
}

// ----------------------------------------------------------------------------
// 16.3 TestFileOwnershipMatchesHostUser
// Validates: Req 10.6
// ----------------------------------------------------------------------------

func TestFileOwnershipMatchesHostUser(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}

containerName, _, client, cleanup := startContainerFromSharedImage(t)
t.Cleanup(cleanup)

ctx := context.Background()

info, err := hostinfo.Current()
require.NoError(t, err)

exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{
"su", "-c", "touch /workspace/ownership-test.txt", info.Username,
})
require.NoError(t, err)
require.Equal(t, 0, exitCode, "expected exit 0 when creating file")

checkUID := fmt.Sprintf(`[ "$(stat -c '%%u' /workspace/ownership-test.txt)" = "%d" ]`, info.UID)
exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkUID})
require.NoError(t, err, "exec to check file UID")
require.Equal(t, 0, exitCode,
"expected file UID inside container to match host user UID=%d", info.UID)

checkGID := fmt.Sprintf(`[ "$(stat -c '%%g' /workspace/ownership-test.txt)" = "%d" ]`, info.GID)
exitCode, err = docker.ExecInContainer(ctx, client, containerName, []string{"bash", "-c", checkGID})
require.NoError(t, err, "exec to check file GID")
require.Equal(t, 0, exitCode,
"expected file GID inside container to match host user GID=%d", info.GID)
}

// ----------------------------------------------------------------------------
// 16.12 TestContainerHostnameMatchesContainerName
// Validates: Req 23.1, 23.2
// ----------------------------------------------------------------------------

func TestContainerHostnameMatchesContainerName(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}

containerName, _, client, cleanup := startContainerFromSharedImage(t)
t.Cleanup(cleanup)

ctx := context.Background()

// Verify via container inspect that the hostname is set correctly.
info, err := docker.InspectContainer(ctx, client, containerName)
require.NoError(t, err, "inspecting container")
require.NotNil(t, info, "container should exist")
require.Equal(t, containerName, info.Config.Hostname,
"container hostname should match container name")

// Also verify by running `hostname` inside the container.
exitCode, err := docker.ExecInContainer(ctx, client, containerName, []string{"hostname"})
require.NoError(t, err, "exec hostname command")
require.Equal(t, 0, exitCode, "hostname command should exit 0")
}
Loading
Loading