Skip to content
Merged
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- New workspaces seed OpenCode files from `~/.config/opencode-manager/opencode/` into `home/.config/opencode/` for `opencode.json`, `agent/`, `commands/`, `plugins/`, and `skills/`.
- `config.yaml` defines `baseImage.name`, `baseImage.packages`, and `baseImage.commands`; commands run during image build immediately after package installation.
- `config.yaml` supports `useLocalOpenCodeAuth` (default `false`); when `true`, `~/.local/share/opencode/auth.json` is mounted read-write into the same path in workspace containers.
- `config.yaml` supports `extraCACertificate` (default empty); when set to an absolute host certificate file, it is mounted read-only and installed in the workspace system trust store at startup.
- Generated workspace images must include `npx`, `uvx`, `git`, `ripgrep`, and `jq` by default.
- Managed base images are tagged from a stable hash of `baseImage` definition and reused until that definition changes.
- The TUI ensures the managed base image exists at startup and shows `Creating the base image...` while it is built.
Expand All @@ -38,6 +39,7 @@

- Go module path: `github.com/mickael-menu/opencode-manager`.
- TUI stack: Bubble Tea and Lip Gloss.
- TUI operation failures should call `model.showError` so the complete error appears in a popup instead of being truncated in the bottom prompt.
- YAML library: `go.yaml.in/yaml/v4`.
- Runtime execution must use structured `exec.CommandContext` calls, not shell command strings.
- Run `gofmt -w cmd internal` and `go test ./...` after Go changes.
Expand All @@ -52,5 +54,6 @@
- The published base image `docker.io/mroger78/ocm-base` is the default `baseImage.name`. Its recipe lives as real files in `internal/runtime/buildcontext/` (`Dockerfile`, `Dockerfile.overlay`, `Dockerfile.workspace`, and the `opencode-manager-attach`/`opencode-manager-entrypoint` scripts). The pipeline publishes `buildcontext/Dockerfile` directly; the binary embeds the directory (`//go:embed`), materializes it to a temp dir, and builds it as a fallback, passing `BASE_IMAGE`, `EXTRA_PACKAGES`, and `EXTRA_COMMANDS` (and `UID`/`GID` for the workspace image) as `--build-arg` values (`baseBuildArgs`/`workspaceBuildArgs` in `internal/runtime/runtime.go`). So the published image and the local build never drift, and there is no Go-generated Dockerfile. `cmd/opencode-manager selftest` is an internal, undocumented integration entrypoint, not part of the public `list`/`attach` CLI.
- Built-in module sources live in the repo's top-level `modules/` directory (pure data, no Go files), grouped under a **category** directory: `cloud/aws`, `cloud/outscale`, `infra/kubernetes`, `source-code/git`, `tools/ssh`. The category is the parent directory name (`module.Module.Category`, derived in `module.Load`); `module.Catalog` scans two levels (`<root>/<category>/<module>/module.yml`) and sorts by category then name. A module is still identified by its globally unique `name`. The TUI shows a category's directory slug sentence-cased (`source-code` → "Source code") via `categoryLabel`.
- The category is mirrored in the container mount path (`/opt/opencode-manager/modules/<category>/<name>`) and recorded in each `ModuleInstance.Category` in the manifest (with a catalog fallback for pre-category manifests), so install/uninstall/reconcile can find a module's scripts. The TUI editor (`internal/tui/edit.go`) renders modules as a category browser and supports `/` filtering over name/description/category/label.
- The built-in `git` module imports host global `user.name`/`user.email` and OpenPGP signing config through its host-side `resolve` hook. If `user.signingkey` exists and the secret key is exportable, the armored secret key is base64-passed only to the container install environment, imported into `~/.gnupg`, and never persisted to the manifest.
- The npm postinstall script creates the same user config directory used by Go's `os.UserConfigDir`, writes `config.yaml` only when absent, removes legacy flat built-in module dirs from older installs, and syncs the `category/module` layout from `modules/` into the user config, overwriting built-in modules so updates take effect while leaving user-authored modules untouched.
- Built-in modules are **not** embedded in the Go binary; they are installed and updated exclusively through the npm package.
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ Example:
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
hostNetwork: false
runtimeArgs:
- --dns
Expand All @@ -194,6 +195,11 @@ Set `useLocalOpenCodeAuth: true` to mount the host file
`~/.local/share/opencode/auth.json` read-write into the same path in each
workspace container. The default `false` keeps auth isolated from the host.

Set `extraCACertificate` to an absolute path to an existing host CA certificate
file to mount it read-only and install it in each workspace container's Debian
system trust store. The option is empty by default. A certificate change takes
effect when the workspace next starts, which recreates its container.

Set `hostNetwork: true` to run each container in the host's network namespace
(`--network host`) instead of an isolated one, so the agent and its tools can
reach services on the host loopback. The default `false` keeps each container
Expand Down
5 changes: 5 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Example:
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
baseImage:
name: debian:stable-slim
packages:
Expand All @@ -142,6 +143,10 @@ When `useLocalOpenCodeAuth` is `true`, the host file
workspace containers. It defaults to `false`, so host OpenCode auth is not shared
unless explicitly enabled.

`extraCACertificate` is an optional absolute path to an existing host CA
certificate file. It is mounted read-only and installed in each workspace
container's system trust store before OpenCode starts.

Generated workspace images always include `npx`, `uvx`, `git`, `ripgrep`, and `jq`. Additional Debian packages are declared through `baseImage.packages`, and additional build steps are declared through `baseImage.commands`.

The managed base image is tagged from a stable hash of the base image definition and is reused while that definition stays unchanged. Workspace-specific images are built from the managed base image and add only workspace-specific setup such as the matching UID/GID user.
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ documents every option.
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
hostNetwork: false
runtimeArgs:
- --dns
Expand Down Expand Up @@ -55,6 +56,21 @@ When `true`, the host file `~/.local/share/opencode/auth.json` is mounted
share your host OpenCode login. Default `false` keeps auth isolated from the host
— in keeping with the [security principle](concepts.md#security-principle).

### `extraCACertificate`

Optional absolute path to a host CA certificate file. When set, the manager
mounts the file **read-only** into each workspace container and installs it in
the Debian system trust store before OpenCode starts. This lets OpenCode and
other workspace tools trust services signed by a private or corporate CA.

```yaml
extraCACertificate: /home/user/certificates/company-ca.crt
```

The path must point to an existing, readable regular file. Certificate changes
take effect the next time a workspace starts; its container is recreated while
the workspace home and module state are preserved.

### `hostNetwork`

When `true`, each workspace container shares the **host's network namespace**
Expand Down
52 changes: 52 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package config

import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log/slog"
Expand Down Expand Up @@ -56,6 +58,9 @@ type Config struct {
WorkspaceRoot string `yaml:"workspaceRoot"`
Runtime string `yaml:"runtime"`
UseLocalOpenCodeAuth bool `yaml:"useLocalOpenCodeAuth"`
// ExtraCACertificate is an optional absolute path to a host CA certificate
// installed into every workspace container's system trust store.
ExtraCACertificate string `yaml:"extraCACertificate"`
// HostNetwork shares the host's network namespace with each workspace
// container (docker/podman `--network host`) instead of giving it an isolated
// one. Off by default. Because every workspace then shares the host loopback,
Expand Down Expand Up @@ -281,6 +286,22 @@ func (c Config) Validate() error {
return errors.New("baseImage.name is required")
}

if c.ExtraCACertificate != "" {
if !filepath.IsAbs(c.ExtraCACertificate) {
return errors.New("extraCACertificate must be an absolute path")
}
info, err := os.Stat(c.ExtraCACertificate)
if err != nil {
return fmt.Errorf("check extra CA certificate %q: %w", c.ExtraCACertificate, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("extra CA certificate %q must be a regular file", c.ExtraCACertificate)
}
if err := ValidateCACertificate(c.ExtraCACertificate); err != nil {
return err
}
}

for _, pkg := range c.BaseImage.Packages {
if pkg == "" {
return errors.New("baseImage.packages cannot contain empty package names")
Expand Down Expand Up @@ -313,3 +334,34 @@ func (c Config) Validate() error {

return nil
}

// ValidateCACertificate verifies that path contains one or more PEM-encoded CA
// certificates suitable for installation into the container trust store.
func ValidateCACertificate(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read extra CA certificate %q: %w", path, err)
}

count := 0
for len(data) > 0 {
block, rest := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return fmt.Errorf("extra CA certificate %q must contain PEM-encoded X.509 certificates", path)
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("parse extra CA certificate %q: %w", path, err)
}
if !certificate.IsCA {
return fmt.Errorf("extra CA certificate %q must contain CA certificates", path)
}
count++
data = rest
}
if count == 0 {
return fmt.Errorf("extra CA certificate %q must contain PEM-encoded X.509 certificates", path)
}

return nil
}
102 changes: 102 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package config

import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)

func TestLoadUsesDefaultsWhenConfigDoesNotExist(t *testing.T) {
Expand All @@ -24,6 +31,9 @@ func TestLoadUsesDefaultsWhenConfigDoesNotExist(t *testing.T) {
if cfg.UseLocalOpenCodeAuth {
t.Fatal("UseLocalOpenCodeAuth should default to false")
}
if cfg.ExtraCACertificate != "" {
t.Fatalf("ExtraCACertificate = %q, want empty by default", cfg.ExtraCACertificate)
}

if cfg.LogLevel != LogLevelWarning {
t.Fatalf("LogLevel = %q, want %q", cfg.LogLevel, LogLevelWarning)
Expand All @@ -48,6 +58,69 @@ func TestLoadParsesHostNetwork(t *testing.T) {
}
}

func TestLoadParsesExtraCACertificate(t *testing.T) {
dir := t.TempDir()
certificate := filepath.Join(dir, "company-ca.crt")
writeFile(t, certificate, testCACertificate(t))
path := filepath.Join(dir, "config.yaml")
writeFile(t, path, []byte("extraCACertificate: "+certificate+"\n"))

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ExtraCACertificate != certificate {
t.Fatalf("ExtraCACertificate = %q, want %q", cfg.ExtraCACertificate, certificate)
}
}

func TestLoadRejectsInvalidExtraCACertificate(t *testing.T) {
dir := t.TempDir()
cases := []struct {
name string
value string
}{
{name: "relative path", value: "company-ca.crt"},
{name: "missing file", value: filepath.Join(dir, "missing.crt")},
{name: "directory", value: dir},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
writeFile(t, path, []byte("extraCACertificate: "+tc.value+"\n"))

if _, err := Load(path); err == nil {
t.Fatal("Load should reject invalid extraCACertificate")
}
})
}
}

func TestLoadRejectsMalformedExtraCACertificate(t *testing.T) {
dir := t.TempDir()
certificate := filepath.Join(dir, "company-ca.crt")
writeFile(t, certificate, []byte("not a certificate\n"))
path := filepath.Join(dir, "config.yaml")
writeFile(t, path, []byte("extraCACertificate: "+certificate+"\n"))

if _, err := Load(path); err == nil {
t.Fatal("Load should reject a malformed extraCACertificate")
}
}

func TestLoadRejectsNonCAExtraCACertificate(t *testing.T) {
dir := t.TempDir()
certificate := filepath.Join(dir, "leaf.crt")
writeFile(t, certificate, testCertificate(t, false))
path := filepath.Join(dir, "config.yaml")
writeFile(t, path, []byte("extraCACertificate: "+certificate+"\n"))

if _, err := Load(path); err == nil {
t.Fatal("Load should reject a non-CA extraCACertificate")
}
}

func TestLoadParsesRuntimeArgs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
Expand Down Expand Up @@ -275,6 +348,35 @@ func writeFile(t *testing.T, path string, data []byte) {
}
}

func testCACertificate(t *testing.T) []byte {
return testCertificate(t, true)
}

func testCertificate(t *testing.T, isCA bool) []byte {
t.Helper()

publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate test key: %v", err)
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test CA"},
NotBefore: now,
NotAfter: now.Add(time.Hour),
IsCA: isCA,
BasicConstraintsValid: isCA,
KeyUsage: x509.KeyUsageCertSign,
}
certificate, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
if err != nil {
t.Fatalf("create test certificate: %v", err)
}

return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate})
}

func TestIsManagedBaseImage(t *testing.T) {
cases := []struct {
name string
Expand Down
7 changes: 7 additions & 0 deletions internal/runtime/buildcontext/opencode-manager-entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ if [ -z "$OCM_OPENCODE_PORT" ]; then
exit 1
fi

# The manager mounts an optional host CA certificate read-only. Install it into
# the Debian trust store before starting OpenCode so all workspace tools trust it.
if [ -f /run/opencode-manager-extra-ca.crt ]; then
sudo install -m 0644 /run/opencode-manager-extra-ca.crt /usr/local/share/ca-certificates/opencode-manager-extra-ca.crt || exit 1
sudo update-ca-certificates || exit 1
fi

child=""
shutdown() {
[ -n "$child" ] && kill -TERM "$child" 2>/dev/null
Expand Down
13 changes: 13 additions & 0 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,19 @@ func TestEntrypointAndAttachUseAssignedPort(t *testing.T) {
}
}

func TestEntrypointInstallsExtraCACertificate(t *testing.T) {
content := readBuildFile(t, "opencode-manager-entrypoint")
for _, want := range []string{
"/run/opencode-manager-extra-ca.crt",
"/usr/local/share/ca-certificates/opencode-manager-extra-ca.crt",
"update-ca-certificates",
} {
if !strings.Contains(content, want) {
t.Fatalf("entrypoint should install the extra CA certificate using %q:\n%s", want, content)
}
}
}

func readBuildFile(t *testing.T, name string) string {
t.Helper()
data, err := buildContextFS.ReadFile("buildcontext/" + name)
Expand Down
Loading
Loading