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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +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.
- `config.yaml` supports `extraCACertificate` (default empty list); absolute host certificate paths are mounted read-only and installed in the workspace system trust store at startup. Legacy single-path config remains supported.
- 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 Down
11 changes: 6 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Example:
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
extraCACertificate: []
hostNetwork: false
runtimeArgs:
- --dns
Expand All @@ -195,10 +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 `extraCACertificate` to a list of absolute paths to existing host CA
certificate files to mount them read-only and install them in each workspace
container's Debian system trust store. The option is an empty list by default. A
certificate list or content change takes effect when the workspace next starts,
which recreates its container. Existing single-path configurations remain valid.

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
Expand Down
9 changes: 5 additions & 4 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Example:
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
extraCACertificate: []
baseImage:
name: debian:stable-slim
packages:
Expand All @@ -143,9 +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.
`extraCACertificate` is an optional list of absolute paths to existing host CA
certificate files. Each is mounted read-only and installed in every workspace
container's system trust store before OpenCode starts. A single legacy path is
also accepted.

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`.

Expand Down
21 changes: 12 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ documents every option.
workspaceRoot: /home/user/.local/share/opencode-manager
runtime: docker
useLocalOpenCodeAuth: false
extraCACertificate: ""
extraCACertificate: []
hostNetwork: false
runtimeArgs:
- --dns
Expand Down Expand Up @@ -58,18 +58,21 @@ share your host OpenCode login. Default `false` keeps auth isolated from the hos

### `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.
Optional list of absolute paths to host CA certificate files. The manager mounts
each file **read-only** into every workspace container and installs them in the
Debian system trust store before OpenCode starts. This lets OpenCode and other
workspace tools trust services signed by private or corporate CAs.

```yaml
extraCACertificate: /home/user/certificates/company-ca.crt
extraCACertificate:
- /home/user/certificates/company-root-ca.crt
- /home/user/certificates/partner-root-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.
Every path must point to an existing, readable regular file. Certificate list or
content changes take effect the next time a workspace starts; its container is
recreated while the workspace home and module state are preserved. Existing
single-path configurations remain supported.

### `hostNetwork`

Expand Down
44 changes: 35 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +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
// ExtraCACertificates are optional absolute paths to host CA certificates
// installed into every workspace container's system trust store.
ExtraCACertificate string `yaml:"extraCACertificate"`
ExtraCACertificates CACertificates `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 @@ -90,6 +90,29 @@ type Config struct {
WorkspacePreDeleteCommands []string `yaml:"workspacePreDeleteCommands"`
}

// CACertificates accepts the current YAML list form and the single-string form
// used by existing configs before multiple certificates were supported.
type CACertificates []string

func (c *CACertificates) UnmarshalYAML(unmarshal func(any) error) error {
var paths []string
if err := unmarshal(&paths); err == nil {
*c = paths
return nil
}

var path string
if err := unmarshal(&path); err != nil {
return err
}
if path == "" {
*c = nil
return nil
}
*c = []string{path}
return nil
}

type BaseImageConfig struct {
Name string `yaml:"name"`
Packages []string `yaml:"packages"`
Expand Down Expand Up @@ -286,18 +309,21 @@ 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")
for _, path := range c.ExtraCACertificates {
if path == "" {
return errors.New("extraCACertificate cannot contain empty paths")
}
if !filepath.IsAbs(path) {
return errors.New("extraCACertificate paths must be absolute")
}
info, err := os.Stat(c.ExtraCACertificate)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("check extra CA certificate %q: %w", c.ExtraCACertificate, err)
return fmt.Errorf("check extra CA certificate %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("extra CA certificate %q must be a regular file", c.ExtraCACertificate)
return fmt.Errorf("extra CA certificate %q must be a regular file", path)
}
if err := ValidateCACertificate(c.ExtraCACertificate); err != nil {
if err := ValidateCACertificate(path); err != nil {
return err
}
}
Expand Down
41 changes: 37 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"math/big"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
Expand All @@ -31,8 +32,8 @@ 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 len(cfg.ExtraCACertificates) != 0 {
t.Fatalf("ExtraCACertificates = %#v, want empty by default", cfg.ExtraCACertificates)
}

if cfg.LogLevel != LogLevelWarning {
Expand Down Expand Up @@ -69,8 +70,40 @@ func TestLoadParsesExtraCACertificate(t *testing.T) {
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ExtraCACertificate != certificate {
t.Fatalf("ExtraCACertificate = %q, want %q", cfg.ExtraCACertificate, certificate)
if len(cfg.ExtraCACertificates) != 1 || cfg.ExtraCACertificates[0] != certificate {
t.Fatalf("ExtraCACertificates = %#v, want [%q]", cfg.ExtraCACertificates, certificate)
}
}

func TestLoadAcceptsEmptyLegacyExtraCACertificate(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
writeFile(t, path, []byte("extraCACertificate: \"\"\n"))

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(cfg.ExtraCACertificates) != 0 {
t.Fatalf("ExtraCACertificates = %#v, want empty", cfg.ExtraCACertificates)
}
}

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

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
want := []string{first, second}
if !reflect.DeepEqual([]string(cfg.ExtraCACertificates), want) {
t.Fatalf("ExtraCACertificates = %#v, want %#v", cfg.ExtraCACertificates, want)
}
}

Expand Down
13 changes: 9 additions & 4 deletions internal/runtime/buildcontext/opencode-manager-entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ 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
# The manager mounts optional host CA certificates read-only. Install them into
# the Debian trust store before starting OpenCode so all workspace tools trust them.
installed=false
for certificate in /run/opencode-manager-extra-ca-*.crt; do
[ -f "$certificate" ] || continue
sudo install -m 0644 "$certificate" "/usr/local/share/ca-certificates/${certificate##*/}" || exit 1
installed=true
done
if [ "$installed" = true ]; then
sudo update-ca-certificates || exit 1
fi

Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ 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",
"/run/opencode-manager-extra-ca-*.crt",
"/usr/local/share/ca-certificates/${certificate##*/}",
"update-ca-certificates",
} {
if !strings.Contains(content, want) {
Expand Down
52 changes: 27 additions & 25 deletions internal/workspace/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,11 @@ func (l Lifecycle) provision(ctx context.Context, summary Summary) (string, runt
if err != nil {
return runtime.StatusUnknown, runtime.ContainerSpec{}, err
}
extraCAMount, extraCAFingerprint, err := extraCACertificateMount(l.cfg.ExtraCACertificate)
extraCAMounts, extraCAFingerprint, err := extraCACertificateMounts(l.cfg.ExtraCACertificates)
if err != nil {
return runtime.StatusUnknown, runtime.ContainerSpec{}, err
}
if extraCAMount != nil {
mounts = append(mounts, *extraCAMount)
}
mounts = append(mounts, extraCAMounts...)
// Mount the host module directory read-only so module install/uninstall
// scripts are runnable inside the container.
mounts = append(mounts, moduleMounts(l.cfg)...)
Expand Down Expand Up @@ -393,35 +391,39 @@ const openCodeConfigDir = openCodeHomeDir + "/.config/opencode"
const openCodeAuthRelPath = ".local/share/opencode/auth.json"

const (
extraCACertificateContainerPath = "/run/opencode-manager-extra-ca.crt"
extraCACertificateFingerprintEnv = "OCM_EXTRA_CA_CERTIFICATE_SHA256"
)

// extraCACertificateMount returns the mount and content fingerprint for an
// optional host CA certificate. The fingerprint recreates containers after a
// certificate update.
func extraCACertificateMount(path string) (*runtime.Mount, string, error) {
if path == "" {
// extraCACertificateMounts returns mounts and a combined fingerprint for all
// optional host CA certificates. Any list or content change recreates containers.
func extraCACertificateMounts(paths []string) ([]runtime.Mount, string, error) {
if len(paths) == 0 {
return nil, "", nil
}

info, err := os.Stat(path)
if err != nil {
return nil, "", fmt.Errorf("check extra CA certificate %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, "", fmt.Errorf("extra CA certificate %q must be a regular file", path)
}
if err := config.ValidateCACertificate(path); err != nil {
return nil, "", err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("read extra CA certificate %q: %w", path, err)
mounts := make([]runtime.Mount, 0, len(paths))
hash := sha256.New()
for index, path := range paths {
info, err := os.Stat(path)
if err != nil {
return nil, "", fmt.Errorf("check extra CA certificate %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, "", fmt.Errorf("extra CA certificate %q must be a regular file", path)
}
if err := config.ValidateCACertificate(path); err != nil {
return nil, "", err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("read extra CA certificate %q: %w", path, err)
}
fmt.Fprintf(hash, "%d:%s\x00", index, path)
hash.Write(data)
mounts = append(mounts, runtime.Mount{Source: path, Target: fmt.Sprintf("/run/opencode-manager-extra-ca-%d.crt", index), ReadOnly: true})
}
fingerprint := fmt.Sprintf("%x", sha256.Sum256(data))

return &runtime.Mount{Source: path, Target: extraCACertificateContainerPath, ReadOnly: true}, fingerprint, nil
return mounts, fmt.Sprintf("%x", hash.Sum(nil)), nil
}

// openCodeMounts returns the read-only bind mounts that expose the global
Expand Down
10 changes: 5 additions & 5 deletions internal/workspace/lifecycle_base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,10 @@ func TestProvisionMountsExtraCACertificate(t *testing.T) {
certificate := filepath.Join(root, "company-ca.crt")
writeTestFile(t, certificate, testCACertificate(t))
cfg := config.Config{
WorkspaceRoot: root,
Runtime: config.RuntimeDocker,
ExtraCACertificate: certificate,
BaseImage: config.BaseImageConfig{Name: "debian:stable-slim"},
WorkspaceRoot: root,
Runtime: config.RuntimeDocker,
ExtraCACertificates: config.CACertificates{certificate},
BaseImage: config.BaseImageConfig{Name: "debian:stable-slim"},
}
l := Lifecycle{cfg: cfg, registry: NewRegistry(cfg), driver: rec}

Expand All @@ -244,7 +244,7 @@ func TestProvisionMountsExtraCACertificate(t *testing.T) {
t.Fatalf("spec.Env[%s] should contain certificate fingerprint", extraCACertificateFingerprintEnv)
}

want := runtime.Mount{Source: certificate, Target: extraCACertificateContainerPath, ReadOnly: true}
want := runtime.Mount{Source: certificate, Target: "/run/opencode-manager-extra-ca-0.crt", ReadOnly: true}
for _, mount := range rec.created.Mounts {
if mount == want {
return
Expand Down
Loading
Loading