From 5aed30bca7d232fbf7fb39ae0f36cde1c6eea9e2 Mon Sep 17 00:00:00 2001 From: MickaelRoger Date: Thu, 23 Jul 2026 11:03:53 +0200 Subject: [PATCH] feat(config): support multiple CA certificates --- AGENTS.md | 2 +- ARCHITECTURE.md | 11 ++-- PROJECT.md | 9 ++-- docs/configuration.md | 21 ++++---- internal/config/config.go | 44 ++++++++++++---- internal/config/config_test.go | 41 +++++++++++++-- .../buildcontext/opencode-manager-entrypoint | 13 +++-- internal/runtime/runtime_test.go | 4 +- internal/workspace/lifecycle.go | 52 ++++++++++--------- internal/workspace/lifecycle_base_test.go | 10 ++-- internal/workspace/lifecycle_test.go | 39 +++++++------- scripts/postinstall.js | 2 +- 12 files changed, 161 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51827be..e5bc202 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4694175..5beeb62 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,7 +168,7 @@ Example: workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false -extraCACertificate: "" +extraCACertificate: [] hostNetwork: false runtimeArgs: - --dns @@ -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 diff --git a/PROJECT.md b/PROJECT.md index 4f44d6f..b66c8b7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -124,7 +124,7 @@ Example: workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false -extraCACertificate: "" +extraCACertificate: [] baseImage: name: debian:stable-slim packages: @@ -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`. diff --git a/docs/configuration.md b/docs/configuration.md index 78fcc98..b4cd3a0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,7 @@ documents every option. workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false -extraCACertificate: "" +extraCACertificate: [] hostNetwork: false runtimeArgs: - --dns @@ -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` diff --git a/internal/config/config.go b/internal/config/config.go index b228f7a..cbcb0c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, @@ -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"` @@ -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 } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8384eae..61ad929 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "math/big" "os" "path/filepath" + "reflect" "testing" "time" ) @@ -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 { @@ -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) } } diff --git a/internal/runtime/buildcontext/opencode-manager-entrypoint b/internal/runtime/buildcontext/opencode-manager-entrypoint index 558cd9e..4a5eb37 100755 --- a/internal/runtime/buildcontext/opencode-manager-entrypoint +++ b/internal/runtime/buildcontext/opencode-manager-entrypoint @@ -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 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 1e3c2b2..cdc0a98 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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) { diff --git a/internal/workspace/lifecycle.go b/internal/workspace/lifecycle.go index 6acd39f..281a7e6 100644 --- a/internal/workspace/lifecycle.go +++ b/internal/workspace/lifecycle.go @@ -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)...) @@ -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 diff --git a/internal/workspace/lifecycle_base_test.go b/internal/workspace/lifecycle_base_test.go index c5e990b..9265609 100644 --- a/internal/workspace/lifecycle_base_test.go +++ b/internal/workspace/lifecycle_base_test.go @@ -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} @@ -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 diff --git a/internal/workspace/lifecycle_test.go b/internal/workspace/lifecycle_test.go index 7919ead..340bf98 100644 --- a/internal/workspace/lifecycle_test.go +++ b/internal/workspace/lifecycle_test.go @@ -113,33 +113,36 @@ func TestOpenCodeMountsRequiresLocalAuthFile(t *testing.T) { } } -func TestExtraCACertificateMount(t *testing.T) { - certificate := filepath.Join(t.TempDir(), "company-ca.crt") - writeTestFile(t, certificate, testCACertificate(t)) - - mount, fingerprint, err := extraCACertificateMount(certificate) +func TestExtraCACertificateMounts(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first-ca.crt") + second := filepath.Join(dir, "second-ca.crt") + writeTestFile(t, first, testCACertificate(t)) + writeTestFile(t, second, testCACertificate(t)) + + mounts, fingerprint, err := extraCACertificateMounts([]string{first, second}) if err != nil { - t.Fatalf("extraCACertificateMount returned error: %v", err) + t.Fatalf("extraCACertificateMounts returned error: %v", err) } - if mount == nil { - t.Fatal("extraCACertificateMount returned nil mount") + want := []runtime.Mount{ + {Source: first, Target: "/run/opencode-manager-extra-ca-0.crt", ReadOnly: true}, + {Source: second, Target: "/run/opencode-manager-extra-ca-1.crt", ReadOnly: true}, } - want := runtime.Mount{Source: certificate, Target: extraCACertificateContainerPath, ReadOnly: true} - if !reflect.DeepEqual(*mount, want) { - t.Fatalf("mount = %#v, want %#v", *mount, want) + if !reflect.DeepEqual(mounts, want) { + t.Fatalf("mounts = %#v, want %#v", mounts, want) } if fingerprint == "" { t.Fatal("fingerprint should not be empty") } } -func TestExtraCACertificateMountIsOptional(t *testing.T) { - mount, fingerprint, err := extraCACertificateMount("") +func TestExtraCACertificateMountsAreOptional(t *testing.T) { + mounts, fingerprint, err := extraCACertificateMounts(nil) if err != nil { - t.Fatalf("extraCACertificateMount returned error: %v", err) + t.Fatalf("extraCACertificateMounts returned error: %v", err) } - if mount != nil || fingerprint != "" { - t.Fatalf("extraCACertificateMount = (%#v, %q), want (nil, empty)", mount, fingerprint) + if mounts != nil || fingerprint != "" { + t.Fatalf("extraCACertificateMounts = (%#v, %q), want (nil, empty)", mounts, fingerprint) } } @@ -147,8 +150,8 @@ func TestExtraCACertificateMountRejectsMalformedCertificate(t *testing.T) { certificate := filepath.Join(t.TempDir(), "company-ca.crt") writeTestFile(t, certificate, []byte("not a certificate\n")) - if _, _, err := extraCACertificateMount(certificate); err == nil { - t.Fatal("extraCACertificateMount should reject a malformed certificate") + if _, _, err := extraCACertificateMounts([]string{certificate}); err == nil { + t.Fatal("extraCACertificateMounts should reject a malformed certificate") } } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index a7ee85f..7a24fe5 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -34,7 +34,7 @@ function ensureConfig() { `workspaceRoot: ${yamlString(workspaceRoot)}`, "runtime: docker", "useLocalOpenCodeAuth: false", - 'extraCACertificate: ""', + "extraCACertificate: []", "baseImage:", " name: docker.io/mroger78/ocm-base:latest", " packages: []",