diff --git a/AGENTS.md b/AGENTS.md index 351bbf7..51827be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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. @@ -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 (`///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//`) 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e732a12..4694175 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,6 +168,7 @@ Example: workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false +extraCACertificate: "" hostNetwork: false runtimeArgs: - --dns @@ -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 diff --git a/PROJECT.md b/PROJECT.md index 2f35e09..4f44d6f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -124,6 +124,7 @@ Example: workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false +extraCACertificate: "" baseImage: name: debian:stable-slim packages: @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index dbfd1fd..78fcc98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ documents every option. workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false +extraCACertificate: "" hostNetwork: false runtimeArgs: - --dns @@ -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** diff --git a/internal/config/config.go b/internal/config/config.go index 9bffce9..b228f7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/x509" + "encoding/pem" "errors" "fmt" "log/slog" @@ -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, @@ -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") @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index baa6fef..8384eae 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { @@ -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) @@ -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") @@ -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 diff --git a/internal/runtime/buildcontext/opencode-manager-entrypoint b/internal/runtime/buildcontext/opencode-manager-entrypoint index c5ba8a0..558cd9e 100755 --- a/internal/runtime/buildcontext/opencode-manager-entrypoint +++ b/internal/runtime/buildcontext/opencode-manager-entrypoint @@ -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 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4e2ef4d..1e3c2b2 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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) diff --git a/internal/tui/app.go b/internal/tui/app.go index 4ecdb60..306af31 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -47,6 +47,8 @@ type model struct { runtimeError string loadError string message string + errorTitle string + errorMessage string // baseImageReady gates the whole dashboard: until the managed base image // finishes building (baseImageReadyMsg), the UI shows a blocking overlay and @@ -108,6 +110,7 @@ type model struct { // createWorkspace kicks off provisioning and removed when its // provisionWorkspaceMsg arrives. provisioning map[string]bool + updating map[string]bool // set when the npm registry reports a newer release than appVersion; the // header shows an "update available" notice (see checkForUpdate). @@ -435,6 +438,7 @@ func newModel(cfg config.Config) model { versions: map[string]versionState{}, installing: map[string]bool{}, provisioning: map[string]bool{}, + updating: map[string]bool{}, width: 100, height: 30, message: "Creating the base image...", @@ -456,6 +460,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { slog.Error("failed to load workspaces", "error", msg.err) m.loadError = msg.err.Error() + m.showError("Load Workspaces", fmt.Sprintf("Failed to load workspaces: %v", msg.err)) m.workspaces = nil return m, nil } @@ -534,7 +539,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case lifecycleActionMsg: if msg.err != nil { slog.Error("lifecycle action failed", "action", msg.action, "workspace", msg.name, "error", msg.err) - m.message = fmt.Sprintf("%s failed for %s: %v", msg.action, msg.name, msg.err) + m.showError(msg.action, fmt.Sprintf("%s failed for %s: %v", msg.action, msg.name, msg.err)) return m, tea.Batch(m.loadWorkspaces, m.loadStatuses) } slog.Info("lifecycle action completed", "action", msg.action, "workspace", msg.name) @@ -544,16 +549,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { delete(m.provisioning, msg.name) if msg.err != nil { slog.Error("workspace provisioning failed", "workspace", msg.name, "error", msg.err) - m.message = fmt.Sprintf("Create runtime provisioning failed for %s: %v", msg.name, msg.err) + m.showError("Create Workspace", fmt.Sprintf("Create runtime provisioning failed for %s: %v", msg.name, msg.err)) return m, tea.Batch(m.loadWorkspaces, m.loadStatuses) } slog.Info("workspace provisioned", "workspace", msg.name) m.message = fmt.Sprintf("Created workspace %s — container is up and running.", msg.name) return m, tea.Batch(m.loadWorkspaces, m.loadStatuses) case updateActionMsg: + delete(m.updating, msg.name) if msg.err != nil { slog.Error("OpenCode update failed", "workspace", msg.name, "error", msg.err) - m.message = fmt.Sprintf("Update failed for %s: %v", msg.name, msg.err) + m.showError("Update OpenCode", fmt.Sprintf("Update failed for %s: %v", msg.name, msg.err)) return m, tea.Batch(m.loadWorkspaces, m.loadStatuses) } slog.Info("OpenCode updated", "workspace", msg.name, "version", msg.version) @@ -564,7 +570,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { delete(m.installing, msg.name) if msg.err != nil { slog.Error("module edit failed", "workspace", msg.name, "error", msg.err) - m.message = fmt.Sprintf("Module edit failed for %s: %v", msg.name, msg.err) + m.showError("Edit Modules", fmt.Sprintf("Module edit failed for %s: %v", msg.name, msg.err)) return m, tea.Batch(m.loadWorkspaces, m.loadStatuses) } slog.Info("module edit completed", "workspace", msg.name, "summary", msg.summary) @@ -585,7 +591,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case attachReadyMsg: if msg.err != nil { slog.Error("session start failed", "kind", msg.noun, "workspace", msg.name, "error", msg.err) - m.message = fmt.Sprintf("%s failed for %s: %v", msg.noun, msg.name, msg.err) + m.showError(msg.noun, fmt.Sprintf("%s failed for %s: %v", msg.noun, msg.name, msg.err)) return m, m.loadStatuses } slog.Info("session started", "kind", msg.noun, "workspace", msg.name) @@ -600,7 +606,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.message = "Detached (Ctrl-C). Container still running in the background." case msg.Err != nil: slog.Error("attach session failed", "error", msg.Err) - m.message = fmt.Sprintf("Attach session failed: %v", msg.Err) + m.showError("Attach Session", fmt.Sprintf("Attach session failed: %v", msg.Err)) default: slog.Debug("attach session closed, container stopped") m.message = "Attach session closed; container stopped." @@ -609,7 +615,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case workspace.ShellResultMsg: if msg.Err != nil { slog.Error("shell session failed", "error", msg.Err) - m.message = fmt.Sprintf("Shell session failed: %v", msg.Err) + m.showError("Shell Session", fmt.Sprintf("Shell session failed: %v", msg.Err)) } else { slog.Debug("shell session closed") m.message = "Shell session closed." @@ -653,6 +659,16 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.errorMessage != "" { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc", "enter": + m.clearError() + } + return m, nil + } + if m.confirmDelete { return m.updateDeleteConfirmation(msg) } @@ -997,14 +1013,14 @@ func (m model) validateCreateName() (string, bool) { func (m model) createWorkspace(name string, tmpl *workspace.Template) (tea.Model, tea.Cmd) { if name == "" { - m.message = "Workspace name is required." + m.showError("Create Workspace", "Workspace name is required.") return m, nil } result, err := m.registry.Create(name) if err != nil { slog.Error("failed to create workspace", "name", name, "error", err) - m.message = fmt.Sprintf("Create failed: %v", err) + m.showError("Create Workspace", fmt.Sprintf("Create failed: %v", err)) return m, nil } @@ -1017,7 +1033,7 @@ func (m model) createWorkspace(name string, tmpl *workspace.Template) (tea.Model manifest.UpdatedAt = time.Now().UTC() if err := workspace.SaveManifest(filepath.Join(result.Path, workspace.ManifestFile), manifest); err != nil { slog.Error("failed to apply template to workspace", "name", name, "template", tmpl.Name, "error", err) - m.message = fmt.Sprintf("Created workspace %s but applying template %q failed: %v", result.Manifest.Name, tmpl.Name, err) + m.showError("Create Workspace", fmt.Sprintf("Created workspace %s but applying template %q failed: %v", result.Manifest.Name, tmpl.Name, err)) return m, m.loadWorkspaces } result.Manifest = manifest @@ -1077,7 +1093,7 @@ func (m model) stopSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Stop failed: " + m.lifecycleErr + m.showError("Stop Workspace", "Stop failed: "+m.lifecycleErr) return m, nil } @@ -1096,7 +1112,7 @@ func (m model) deleteSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Delete failed: " + m.lifecycleErr + m.showError("Delete Workspace", "Delete failed: "+m.lifecycleErr) return m, nil } @@ -1115,7 +1131,7 @@ func (m model) attachSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Attach failed: " + m.lifecycleErr + m.showError("Attach Workspace", "Attach failed: "+m.lifecycleErr) return m, nil } if m.installing[selected.Manifest.Name] { @@ -1139,7 +1155,7 @@ func (m model) shellSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Shell failed: " + m.lifecycleErr + m.showError("Shell Workspace", "Shell failed: "+m.lifecycleErr) return m, nil } if m.installing[selected.Manifest.Name] { @@ -1179,7 +1195,7 @@ func (m model) startSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Start failed: " + m.lifecycleErr + m.showError("Start Workspace", "Start failed: "+m.lifecycleErr) return m, nil } @@ -1202,7 +1218,7 @@ func (m model) updateSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Update failed: " + m.lifecycleErr + m.showError("Update OpenCode", "Update failed: "+m.lifecycleErr) return m, nil } @@ -1213,6 +1229,10 @@ func (m model) updateSelected() (tea.Model, tea.Cmd) { return m, nil } + if m.updating == nil { + m.updating = map[string]bool{} + } + m.updating[name] = true m.message = "Updating OpenCode in " + name + " (this restarts the container)..." return m, func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) @@ -1348,6 +1368,9 @@ func (m model) View() string { if m.showHelp { view = overlayCentered(view, m.renderHelp(), width, height) } + if m.errorMessage != "" { + view = overlayCentered(view, m.renderErrorDialog(), width, height) + } // The base-image gate sits on top of everything: while it is up the rest of // the UI is frozen, so it must always win the overlay stack. @@ -1930,6 +1953,35 @@ func (m model) renderDeleteConfirmation() string { return k9sDialog("Confirm Delete", content, colBorder) } +func (m model) renderErrorDialog() string { + title := m.errorTitle + if title == "" { + title = "Error" + } + width := clamp(m.width-16, 40, 96) + content := lipgloss.JoinVertical( + lipgloss.Left, + errorStyle.Render(title), + "", + dialogText.Render(wrapText(m.errorMessage, width)), + "", + mutedStyle.Render("Press Enter or Esc to close."), + ) + return k9sDialog("Error", content, colError) +} + +func (m *model) showError(title, message string) { + m.errorTitle = title + m.errorMessage = message + m.message = "Error: " + title + ". Press Enter or Esc to view the dashboard." +} + +func (m *model) clearError() { + m.errorTitle = "" + m.errorMessage = "" + m.message = "" +} + func (m model) renderCreatePrompt() string { // Name field: a focus caret only while the name has focus, so it is clear which // element is active. @@ -2158,6 +2210,10 @@ func (m model) runtimeStatus() string { // workspaceStatus returns the display text and color for a workspace container. func (m model) workspaceStatus(ws workspace.Summary) (string, lipgloss.Color) { + if m.updating[ws.Manifest.Name] { + return "updating", colStarting + } + // A freshly created workspace has no container yet (the runtime reports it // "missing") while its image builds and the container is created. Surface // that in-progress work as "creating" until provisioning completes. @@ -2584,6 +2640,34 @@ func fit(s string, w int) string { return s + strings.Repeat(" ", w-len(r)) } +func wrapText(s string, width int) string { + if width <= 0 { + return s + } + + var lines []string + for _, raw := range strings.Split(s, "\n") { + words := strings.Fields(raw) + if len(words) == 0 { + lines = append(lines, "") + continue + } + + line := words[0] + for _, word := range words[1:] { + if lipgloss.Width(line)+1+lipgloss.Width(word) <= width { + line += " " + word + continue + } + lines = append(lines, line) + line = word + } + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + func clamp(value, minValue, maxValue int) int { if maxValue < minValue { return minValue diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 1068306..b266b58 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "strings" "testing" "time" @@ -171,6 +172,7 @@ func updateTestModel(activity workspace.Activity) model { statuses: map[string]workspace.Status{ "app": {Activity: activity}, }, + updating: map[string]bool{}, } } @@ -287,6 +289,24 @@ func TestWorkspaceStatusCreatingWhileProvisioning(t *testing.T) { } } +func TestWorkspaceStatusUpdatingWhileUpdateInProgress(t *testing.T) { + ws := workspace.Summary{Manifest: workspace.Manifest{Name: "app"}} + m := model{ + workspaces: []workspace.Summary{ws}, + statuses: map[string]workspace.Status{"app": {Container: runtime.StatusRunning}}, + updating: map[string]bool{"app": true}, + } + + if label, _ := m.workspaceStatus(ws); label != "updating" { + t.Fatalf("status while updating = %q, want %q", label, "updating") + } + + delete(m.updating, "app") + if label, _ := m.workspaceStatus(ws); label != "running" { + t.Fatalf("status after update = %q, want %q", label, "running") + } +} + // Update must be refused while OpenCode is mid-task so the post-update restart // cannot interrupt active work. func TestUpdateRefusedWhileTaskRunning(t *testing.T) { @@ -313,6 +333,29 @@ func TestUpdateDispatchedWhenIdle(t *testing.T) { if msg := next.(model).message; !strings.Contains(msg, "Updating OpenCode") { t.Fatalf("activity %q: message = %q, want progress message", activity, msg) } + if label, _ := next.(model).workspaceStatus(m.workspaces[0]); label != "updating" { + t.Fatalf("activity %q: status = %q, want updating", activity, label) + } + } +} + +func TestUpdateErrorOpensFullErrorDialog(t *testing.T) { + m := model{width: 70, baseImageReady: true} + longErr := "first line with enough text to wrap without truncating and a unique-tail-token" + updated, _ := m.Update(updateActionMsg{name: "app", err: errors.New(longErr)}) + next := updated.(model) + + if next.errorMessage == "" { + t.Fatal("expected error dialog state") + } + dialog := next.renderErrorDialog() + if !strings.Contains(dialog, "unique-tail-token") { + t.Fatalf("dialog should contain the complete error message, got:\n%s", dialog) + } + + closed, _ := next.updateKey(tea.KeyMsg{Type: tea.KeyEsc}) + if closed.(model).errorMessage != "" { + t.Fatal("Esc should dismiss the error dialog") } } diff --git a/internal/tui/edit.go b/internal/tui/edit.go index ec9f346..ebf6d65 100644 --- a/internal/tui/edit.go +++ b/internal/tui/edit.go @@ -55,14 +55,14 @@ func (m model) editSelected() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Edit failed: " + m.lifecycleErr + m.showError("Edit Modules", "Edit failed: "+m.lifecycleErr) return m, nil } catalog, err := m.lifecycle.Catalog() if err != nil { m.catalogErr = err.Error() - m.message = "Failed to load modules: " + err.Error() + m.showError("Edit Modules", "Failed to load modules: "+err.Error()) return m, nil } if len(catalog) == 0 { @@ -136,7 +136,7 @@ func (m model) openTemplateEditor(t workspace.Template, isNew bool) (tea.Model, catalog, err := m.lifecycle.Catalog() if err != nil { m.catalogErr = err.Error() - m.message = "Failed to load modules: " + err.Error() + m.showError("Edit Template", "Failed to load modules: "+err.Error()) return m, nil } if len(catalog) == 0 { @@ -395,7 +395,7 @@ func (m *model) prepareEditPrompt() { if cur.Type == module.PromptSelect || cur.Type == module.PromptMultiSelect { opts, err := loadPromptOptions(m.editPromptMod, cur) if err != nil { - m.message = fmt.Sprintf("Failed to load options for %s: %v", cur.Label, err) + m.showError("Module Options", fmt.Sprintf("Failed to load options for %s: %v", cur.Label, err)) } m.editPromptOptions = opts m.editPromptChosen = make([]bool, len(opts)) @@ -945,7 +945,7 @@ func (m model) applyTemplateEdit() (tea.Model, tea.Cmd) { t.UpdatedAt = now if err := m.templateRegistry.Save(t); err != nil { - m.message = fmt.Sprintf("Save template failed: %v", err) + m.showError("Save Template", fmt.Sprintf("Save template failed: %v", err)) return m, nil } @@ -954,7 +954,7 @@ func (m model) applyTemplateEdit() (tea.Model, tea.Cmd) { m.editEntries = nil m.editFilter = "" if err := m.reloadTemplates(); err != nil { - m.message = fmt.Sprintf("Saved template %q, but reloading failed: %v", t.Name, err) + m.showError("Save Template", fmt.Sprintf("Saved template %q, but reloading failed: %v", t.Name, err)) return m, nil } m.selectTemplate(t.Name) diff --git a/internal/tui/templates.go b/internal/tui/templates.go index 40ad0d7..a4aff34 100644 --- a/internal/tui/templates.go +++ b/internal/tui/templates.go @@ -18,7 +18,7 @@ func (m model) showTemplates() (tea.Model, tea.Cmd) { m.templateFilterMode = false m.templatePos = 0 if err := m.reloadTemplates(); err != nil { - m.message = "Failed to load templates: " + err.Error() + m.showError("Templates", "Failed to load templates: "+err.Error()) return m, nil } m.message = "Templates — c create, e edit, ^d delete, :workspaces to go back." @@ -172,7 +172,7 @@ func (m model) editSelectedTemplate() (tea.Model, tea.Cmd) { return m, nil } if m.lifecycleErr != "" { - m.message = "Edit failed: " + m.lifecycleErr + m.showError("Edit Template", "Edit failed: "+m.lifecycleErr) return m, nil } return m.openTemplateEditor(t, false) @@ -194,11 +194,11 @@ func (m model) deleteSelectedTemplate() (tea.Model, tea.Cmd) { return m, nil } if err := m.templateRegistry.Delete(t.Name); err != nil { - m.message = fmt.Sprintf("Delete failed: %v", err) + m.showError("Delete Template", fmt.Sprintf("Delete failed: %v", err)) return m, nil } if err := m.reloadTemplates(); err != nil { - m.message = "Deleted, but reloading templates failed: " + err.Error() + m.showError("Delete Template", "Deleted, but reloading templates failed: "+err.Error()) return m, nil } m.message = fmt.Sprintf("Deleted template %q.", t.Name) diff --git a/internal/workspace/lifecycle.go b/internal/workspace/lifecycle.go index 8545525..6acd39f 100644 --- a/internal/workspace/lifecycle.go +++ b/internal/workspace/lifecycle.go @@ -155,6 +155,9 @@ func (l Lifecycle) EnsureStarted(ctx context.Context, summary Summary) error { return fmt.Errorf("start failed: %w; recreate failed: %v", err, recreateErr) } } + if err := l.verifyStarted(ctx, name); err != nil { + return err + } slog.Info("workspace container started", "workspace", summary.Manifest.Name, "container", name) } else { slog.Debug("workspace container already running", "workspace", summary.Manifest.Name, "container", name) @@ -175,6 +178,38 @@ func (l Lifecycle) EnsureStarted(ctx context.Context, summary Summary) error { return nil } +const ( + startupGracePeriod = time.Second + startupPollInterval = 50 * time.Millisecond +) + +// verifyStarted catches entrypoint failures that occur just after the runtime +// accepts start, including failed CA trust-store installation. +func (l Lifecycle) verifyStarted(ctx context.Context, name string) error { + timer := time.NewTimer(startupGracePeriod) + defer timer.Stop() + ticker := time.NewTicker(startupPollInterval) + defer ticker.Stop() + + for { + status, err := l.driver.ContainerStatus(ctx, name) + if err != nil { + return fmt.Errorf("verify started container: %w", err) + } + if status != runtime.StatusRunning { + return fmt.Errorf("workspace container exited during startup: %s", status) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + case <-ticker.C: + } + } +} + func (l Lifecycle) provision(ctx context.Context, summary Summary) (string, runtime.ContainerSpec, error) { if err := l.driver.Available(ctx); err != nil { return runtime.StatusUnknown, runtime.ContainerSpec{}, err @@ -225,6 +260,13 @@ 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) + if err != nil { + return runtime.StatusUnknown, runtime.ContainerSpec{}, err + } + if extraCAMount != nil { + mounts = append(mounts, *extraCAMount) + } // Mount the host module directory read-only so module install/uninstall // scripts are runnable inside the container. mounts = append(mounts, moduleMounts(l.cfg)...) @@ -237,11 +279,14 @@ func (l Lifecycle) provision(ctx context.Context, summary Summary) (string, runt // Carry the workspace's manifest env plus the assigned OpenCode port into the // container without mutating the persisted env map. The entrypoint binds the // server to this port and the attach client connects to it. - env := make(map[string]string, len(manifest.Env)+1) + env := make(map[string]string, len(manifest.Env)+2) for k, v := range manifest.Env { env[k] = v } env[OpenCodePortEnv] = strconv.Itoa(manifest.OpenCodePort) + if extraCAFingerprint != "" { + env[extraCACertificateFingerprintEnv] = extraCAFingerprint + } spec := runtime.ContainerSpec{ Name: manifest.ContainerName, @@ -347,6 +392,38 @@ 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 == "" { + 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) + } + fingerprint := fmt.Sprintf("%x", sha256.Sum256(data)) + + return &runtime.Mount{Source: path, Target: extraCACertificateContainerPath, ReadOnly: true}, fingerprint, nil +} + // openCodeMounts returns the read-only bind mounts that expose the global // OpenCode templates (~/.config/opencode-manager) inside the workspace at // /home/debian/.config/opencode. Only the single-file templates (AGENTS.md and @@ -645,11 +722,9 @@ func (l Lifecycle) containerImageStale(ctx context.Context, manifest Manifest) ( // containerSpecDrift reports whether the existing container's runtime config no // longer matches the desired spec in ways that require recreation: a changed -// network namespace (config.HostNetwork toggled) or a stale/missing assigned -// OpenCode port. Either leaves the workspace's OpenCode server and attach client -// on the wrong port — under host networking that means the shared default 4096, -// colliding with another workspace. A failed inspect returns false so a transient -// error never churns containers. +// network namespace (config.HostNetwork toggled), a stale/missing assigned +// OpenCode port, or a changed extra CA certificate. A failed inspect returns +// false so a transient error never churns containers. func (l Lifecycle) containerSpecDrift(ctx context.Context, manifest Manifest, spec runtime.ContainerSpec) bool { rc, err := l.driver.ContainerRuntimeConfig(ctx, manifest.ContainerName) if err != nil { @@ -667,6 +742,11 @@ func (l Lifecycle) containerSpecDrift(ctx context.Context, manifest Manifest, sp return true } + if rc.Env[extraCACertificateFingerprintEnv] != spec.Env[extraCACertificateFingerprintEnv] { + slog.Debug("container extra CA certificate differs from desired", "workspace", manifest.Name, "container", manifest.ContainerName) + return true + } + return false } diff --git a/internal/workspace/lifecycle_base_test.go b/internal/workspace/lifecycle_base_test.go index 5466cb9..c5e990b 100644 --- a/internal/workspace/lifecycle_base_test.go +++ b/internal/workspace/lifecycle_base_test.go @@ -220,3 +220,35 @@ func TestProvisionBackfillsMissingPort(t *testing.T) { t.Fatalf("backfilled port = %d, want within [%d, %d]", saved.OpenCodePort, OpenCodePortMin, OpenCodePortMax) } } + +func TestProvisionMountsExtraCACertificate(t *testing.T) { + rec := &specRecordingDriver{fakeDriver: &fakeDriver{}} + root := t.TempDir() + 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"}, + } + l := Lifecycle{cfg: cfg, registry: NewRegistry(cfg), driver: rec} + + created, err := l.registry.Create("demo") + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, spec, err := l.provision(context.Background(), Summary{Manifest: created.Manifest, Path: created.Path}); err != nil { + t.Fatalf("provision: %v", err) + } else if spec.Env[extraCACertificateFingerprintEnv] == "" { + t.Fatalf("spec.Env[%s] should contain certificate fingerprint", extraCACertificateFingerprintEnv) + } + + want := runtime.Mount{Source: certificate, Target: extraCACertificateContainerPath, ReadOnly: true} + for _, mount := range rec.created.Mounts { + if mount == want { + return + } + } + t.Fatalf("extra CA mount not found in %#v", rec.created.Mounts) +} diff --git a/internal/workspace/lifecycle_drift_test.go b/internal/workspace/lifecycle_drift_test.go index 19890df..7ecbcab 100644 --- a/internal/workspace/lifecycle_drift_test.go +++ b/internal/workspace/lifecycle_drift_test.go @@ -70,6 +70,12 @@ func TestContainerSpecDrift(t *testing.T) { spec: runtime.ContainerSpec{HostNetwork: true}, want: false, }, + { + name: "extra CA certificate changed", + rc: runtime.ContainerRuntimeConfig{NetworkMode: "bridge", Env: map[string]string{OpenCodePortEnv: "4097", extraCACertificateFingerprintEnv: "old"}}, + spec: runtime.ContainerSpec{Env: map[string]string{extraCACertificateFingerprintEnv: "new"}}, + want: true, + }, } for _, tc := range cases { diff --git a/internal/workspace/lifecycle_test.go b/internal/workspace/lifecycle_test.go index f6eb533..7919ead 100644 --- a/internal/workspace/lifecycle_test.go +++ b/internal/workspace/lifecycle_test.go @@ -1,10 +1,18 @@ package workspace import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "path/filepath" "reflect" "strings" "testing" + "time" "github.com/mickael-menu/opencode-manager/internal/runtime" ) @@ -104,3 +112,89 @@ func TestOpenCodeMountsRequiresLocalAuthFile(t *testing.T) { t.Fatalf("openCodeMounts error = %q, want useLocalOpenCodeAuth context", err.Error()) } } + +func TestExtraCACertificateMount(t *testing.T) { + certificate := filepath.Join(t.TempDir(), "company-ca.crt") + writeTestFile(t, certificate, testCACertificate(t)) + + mount, fingerprint, err := extraCACertificateMount(certificate) + if err != nil { + t.Fatalf("extraCACertificateMount returned error: %v", err) + } + if mount == nil { + t.Fatal("extraCACertificateMount returned nil mount") + } + want := runtime.Mount{Source: certificate, Target: extraCACertificateContainerPath, ReadOnly: true} + if !reflect.DeepEqual(*mount, want) { + t.Fatalf("mount = %#v, want %#v", *mount, want) + } + if fingerprint == "" { + t.Fatal("fingerprint should not be empty") + } +} + +func TestExtraCACertificateMountIsOptional(t *testing.T) { + mount, fingerprint, err := extraCACertificateMount("") + if err != nil { + t.Fatalf("extraCACertificateMount returned error: %v", err) + } + if mount != nil || fingerprint != "" { + t.Fatalf("extraCACertificateMount = (%#v, %q), want (nil, empty)", mount, fingerprint) + } +} + +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") + } +} + +type startupStatusDriver struct { + *fakeDriver + statuses []string +} + +func (d *startupStatusDriver) ContainerStatus(context.Context, string) (string, error) { + status := d.statuses[0] + if len(d.statuses) > 1 { + d.statuses = d.statuses[1:] + } + return status, nil +} + +func TestVerifyStartedRejectsContainerThatExitsDuringStartup(t *testing.T) { + driver := &startupStatusDriver{fakeDriver: &fakeDriver{}, statuses: []string{runtime.StatusRunning, runtime.StatusExited}} + l := Lifecycle{driver: driver} + + if err := l.verifyStarted(context.Background(), "demo"); err == nil { + t.Fatal("verifyStarted should reject a container that exits during startup") + } +} + +func testCACertificate(t *testing.T) []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: true, + BasicConstraintsValid: true, + 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}) +} diff --git a/modules/source-code/git/install b/modules/source-code/git/install index 6d54877..240f4b8 100755 --- a/modules/source-code/git/install +++ b/modules/source-code/git/install @@ -1,9 +1,9 @@ #!/bin/sh -# Configures the git identity from the values resolved off the host and clones the -# requested repository into the workspace home directory. Idempotent: identity is -# (re)written only for values the host actually provides, and a repo whose target -# directory already exists is left untouched, so reconcile never re-clones or -# clobbers local work. +# Configures git from values resolved off the host and clones the requested +# repository into the workspace home directory. Idempotent: identity/signing +# config is (re)written only for values the host actually provides, and a repo +# whose target directory already exists is left untouched, so reconcile never +# re-clones or clobbers local work. set -e # Install git if missing (needed for both the config below and cloning). @@ -24,6 +24,28 @@ if [ -n "$OCM_NAME" ] || [ -n "$OCM_EMAIL" ]; then echo "git identity configured: ${OCM_NAME:-unset} <${OCM_EMAIL:-unset}>" fi +# OpenPGP signing config comes from the host resolve hook. The secret key is not +# persisted in the workspace manifest; it is passed only for this install run. +if [ -n "$OCM_GPG_SECRET_KEY_B64" ] && [ -n "$OCM_GPG_SIGNINGKEY" ]; then + if ! command -v gpg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends gnupg + fi + mkdir -p "$HOME/.gnupg" + chmod 700 "$HOME/.gnupg" + printf '%s' "$OCM_GPG_SECRET_KEY_B64" | base64 -d | gpg --batch --import >/dev/null 2>&1 + git config --global user.signingkey "$OCM_GPG_SIGNINGKEY" + git config --global gpg.format openpgp + git config --global gpg.program gpg + if [ -n "$OCM_GPG_COMMIT_SIGN" ]; then + git config --global commit.gpgsign "$OCM_GPG_COMMIT_SIGN" + fi + if [ -n "$OCM_GPG_TAG_SIGN" ]; then + git config --global tag.gpgsign "$OCM_GPG_TAG_SIGN" + fi + echo "git OpenPGP signing key configured: $OCM_GPG_SIGNINGKEY" +fi + # No repo to clone (e.g. an identity-only legacy entry): identity is configured # above, nothing left to do. if [ -z "$OCM_REPO" ]; then diff --git a/modules/source-code/git/module.yml b/modules/source-code/git/module.yml index bb06c0a..9aec6b2 100644 --- a/modules/source-code/git/module.yml +++ b/modules/source-code/git/module.yml @@ -1,10 +1,10 @@ name: git -version: 3 -description: Clone a git repository into the workspace. The git identity (user.name / user.email) is imported from the host automatically when set. +version: 4 +description: Clone a git repository into the workspace. The git identity and OpenPGP signing configuration are imported from the host automatically when set. # Multi-instance: one entry per repository. Each install writes ~/.gitconfig -# (identity resolved from the host) and clones the repo into the workspace home -# directory; nothing in ~/.env, so it never bounces the OpenCode server and can be -# edited while a task is running. +# (identity and signing config resolved from the host) and clones the repo into +# the workspace home directory; nothing in ~/.env, so it never bounces the +# OpenCode server and can be edited while a task is running. restartServer: false key: repo prompts: diff --git a/modules/source-code/git/resolve b/modules/source-code/git/resolve index 5497f63..10735dd 100755 --- a/modules/source-code/git/resolve +++ b/modules/source-code/git/resolve @@ -1,9 +1,8 @@ #!/bin/sh -# Host-side resolve hook: emit the host's global git identity (user.name / -# user.email) as install values so the workspace is configured with the same -# identity as the host, without the user typing it. A value the host has not set -# is simply not emitted, leaving that identity field unset in the container (git -# then falls back to its own behaviour). The identity is pulled from the host on +# Host-side resolve hook: emit the host's global git identity and OpenPGP signing +# configuration as install values so the workspace is configured like the host, +# without the user typing it. A value the host has not set is simply not emitted, +# leaving that field unset in the container. Values are pulled from the host on # every install/reconcile and never persisted to the manifest. # # Runs on the host (where ~/.gitconfig lives) before the container install. @@ -13,7 +12,28 @@ command -v git >/dev/null 2>&1 || exit 0 name="$(git config --global user.name 2>/dev/null || true)" email="$(git config --global user.email 2>/dev/null || true)" +signingkey="$(git config --global user.signingkey 2>/dev/null || true)" +gpgformat="$(git config --global gpg.format 2>/dev/null || true)" +commitgpgsign="$(git config --global commit.gpgsign 2>/dev/null || true)" +taggpgsign="$(git config --global tag.gpgsign 2>/dev/null || true)" [ -n "$name" ] && printf 'name=%s\n' "$name" [ -n "$email" ] && printf 'email=%s\n' "$email" + +# Only OpenPGP signing is imported. Git also supports ssh/x509 signing formats, +# but those need different host material and container configuration. +case "$gpgformat" in + ""|openpgp) ;; + *) exit 0 ;; +esac + +if [ -n "$signingkey" ] && command -v gpg >/dev/null 2>&1; then + secret_key_b64="$(gpg --batch --armor --export-secret-keys "$signingkey" 2>/dev/null | base64 | tr -d '\n' || true)" + if [ -n "$secret_key_b64" ]; then + printf 'gpg_signingkey=%s\n' "$signingkey" + printf 'gpg_secret_key_b64=%s\n' "$secret_key_b64" + [ -n "$commitgpgsign" ] && printf 'gpg_commit_sign=%s\n' "$commitgpgsign" + [ -n "$taggpgsign" ] && printf 'gpg_tag_sign=%s\n' "$taggpgsign" + fi +fi exit 0 diff --git a/scripts/postinstall.js b/scripts/postinstall.js index c8174ce..a7ee85f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -34,6 +34,7 @@ function ensureConfig() { `workspaceRoot: ${yamlString(workspaceRoot)}`, "runtime: docker", "useLocalOpenCodeAuth: false", + 'extraCACertificate: ""', "baseImage:", " name: docker.io/mroger78/ocm-base:latest", " packages: []",