diff --git a/AGENTS.md b/AGENTS.md index 833045e..b92b3cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,11 @@ - The `key` prompt of a multi-instance module may carry an `optionsCommand` (the only non-select prompt allowed to). It runs on the host to list accounts importable as new instances (e.g. `aws`/`outscale` list profiles, `ssh` lists `~/.ssh/config` host aliases). In the editor's add flow this drives an import picker: selecting one or more host accounts creates one instance each storing only the key value, with the actual credentials pulled by the module's `resolve` hook at install time (so secrets never reach the manifest); the picker also offers an "Add manually…" option that opens a single-page form (all fields at once) for an account not present on the host, whose typed values are stored as usual. The key prompt stays a free-text `string` so manual entry still works; `resolve` must emit nothing for an account it does not find on the host, leaving the manually-typed values in place. - A module.yml may declare `restartServer: false` to mark itself environment-neutral: it writes only its own config files (e.g. `~/.kube/config`, `~/.aws/credentials`) that tools read live, never `~/.env`. Such a module is never bounced (the manager skips the server restart even if `~/.env` somehow changed — and logs a warning, since that means the manifest lies) and, crucially, its add/remove is allowed from the editor **while a task is running**, because nothing interrupts the in-flight session. Omitting the key (or setting `restartServer: true`) keeps the conservative default: the module is assumed to touch `~/.env`, so the manager bounces the server when `~/.env` changes and the editor blocks the edit until the workspace is idle. All current built-ins (`aws`, `git`, `kubernetes`, `outscale`, `ssh`) are `restartServer: false`. - Workspace containers are long-lived and tied to the workspace lifecycle. -- New workspaces seed OpenCode files from `~/.config/opencode-manager/opencode/` into `home/.config/opencode/` for `opencode.json`, `agent/`, `commands/`, `plugins/`, and `skills/`. +- Shared OpenCode config lives in `~/.config/opencode-manager/opencode/` and is copied one-way into each workspace's `home/.config/opencode/` on provision, startup reconcile, and source changes while the manager runs. A per-workspace journal permits safe removal of previously managed source entries. The shared source must not contain generated top-level state (`.gitignore`, package manifests/lockfiles, or `node_modules`); those are preserved per workspace. Existing legacy root templates migrate only when `opencode/` is absent or empty. - `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 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. +- `config.yaml` supports `workspaceEnv`, a map applied to every workspace container. Literal values and `{env:HOST_VARIABLE}` references are supported; host values are resolved on every provision and are never persisted to manifests. - 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 5beeb62..afeefe0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,8 +49,9 @@ Each workspace has: - A name. - A dedicated directory under the configured workspace root. - A dedicated home directory. -- A global `opencode.json` mounted read-only at `home/.config/opencode/opencode.json`. -- Globally shared OpenCode commands, skills, agents, and plugins, mounted read-only. +- A one-way copy of `~/.config/opencode-manager/opencode/` at + `home/.config/opencode/`, reconciled at manager startup and on source changes. +- Per-workspace generated OpenCode state remains local and never syncs back. - Selected module configuration. - A generated image. - A long-lived container. @@ -169,6 +170,7 @@ workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false extraCACertificate: [] +workspaceEnv: {} hostNetwork: false runtimeArgs: - --dns @@ -201,6 +203,10 @@ 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. +`workspaceEnv` passes named environment variables to every workspace. Values may +be literal or `{env:HOST_VARIABLE}`, which is resolved from the manager host at +workspace provisioning time. Resolved values are not persisted to manifests. + 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 @@ -262,11 +268,10 @@ When the TUI starts, it ensures the base image is available and shows ## Global OpenCode Templates -OpenCode configuration is shared across all workspaces from the global config -directory: +OpenCode configuration is shared across all workspaces from this source tree: ```text -~/.config/opencode-manager/ +~/.config/opencode-manager/opencode/ ├── AGENTS.md ├── opencode.json ├── agents/ @@ -275,14 +280,13 @@ directory: └── skills/ ``` -These entries are **mounted read-only** into every workspace container at -`/home/debian/.config/opencode/`. Editing a file on the host propagates live to -all running workspaces — no copy is made and no recreation is needed. Adding or -removing a template takes effect the next time a workspace container is -(re)created. +The manager copies this tree one way into each workspace at +`/home/debian/.config/opencode/` during provisioning, at startup, and after a +host-side source change while it is active. A per-workspace journal makes source +removals safe by removing only entries the manager previously copied. Generated +top-level state remains local to each workspace and never syncs back. -On startup, `opencode-manager` creates any missing entries (so the mounts always -have a source): +On startup, `opencode-manager` creates any missing entries in the shared source: - `AGENTS.md` and the `agents/`, `commands/`, `plugins/`, `skills/` directories are created empty. diff --git a/PROJECT.md b/PROJECT.md index b66c8b7..f6b0fde 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -125,6 +125,7 @@ workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false extraCACertificate: [] +workspaceEnv: {} baseImage: name: debian:stable-slim packages: @@ -148,6 +149,10 @@ 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. +`workspaceEnv` defines environment variables for every workspace. A value may be +literal or `{env:HOST_VARIABLE}` to resolve the current manager-host value when +the workspace is provisioned. + 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/README.md b/README.md index 4a20919..96bdaef 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ directory on your `PATH`. 1. **Install** (see above). -2. **Add your OpenCode config.** Every workspace shares the OpenCode templates in - your global config directory. Copy your existing `opencode.json`, skills, +2. **Add your OpenCode config.** Every workspace receives a one-way copy of the + shared config in `~/.config/opencode-manager/opencode/`. Copy your existing `opencode.json`, skills, commands, agents, and plugins into it: ```sh - cd ~/.config/opencode-manager + cd ~/.config/opencode-manager/opencode cp /path/to/your/opencode.json . cp -r /path/to/your/skills/* skills/ @@ -78,9 +78,11 @@ directory on your `PATH`. cp /path/to/your/AGENTS.md . ``` - These are mounted read-only into every workspace, so editing them on the host - updates all your workspaces live. (`ocm` creates this directory and an empty - `opencode.json` for you on first run, so the layout already exists.) + `ocm` copies this tree to each workspace on creation, reconciles it at startup, + and watches it while active. Host changes win; workspace changes never flow + back. Per-workspace generated state such as lockfiles and `node_modules` is + preserved. (`ocm` creates this directory and an empty `opencode.json` for you + on first run.) 3. **Launch the dashboard.** diff --git a/cmd/opencode-manager/main.go b/cmd/opencode-manager/main.go index 92793f8..bdada9d 100644 --- a/cmd/opencode-manager/main.go +++ b/cmd/opencode-manager/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log/slog" "os" @@ -44,6 +45,14 @@ func main() { os.Exit(1) } + syncContext, cancelSync := context.WithCancel(context.Background()) + defer cancelSync() + if err := workspace.StartOpenCodeConfigSync(syncContext, cfg); err != nil { + slog.Error("failed to synchronize shared OpenCode config", "error", err) + fmt.Fprintf(os.Stderr, "failed to synchronize shared OpenCode config: %v\n", err) + os.Exit(1) + } + if err := cli.NewRootCommand(cfg).Execute(); err != nil { slog.Error("command failed", "args", os.Args[1:], "error", err) fmt.Fprintf(os.Stderr, "%v\n", err) diff --git a/docs/concepts.md b/docs/concepts.md index a83fcd6..f43929c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -25,9 +25,8 @@ container. Each workspace has: - a **name**; - a dedicated directory under the configured workspace root; - a dedicated **home directory** (`home/`); -- the global `opencode.json` mounted read-only; -- the globally shared OpenCode commands, skills, agents, and plugins, mounted - read-only; +- a one-way copy of shared OpenCode configuration, including `opencode.json`, + commands, skills, agents, and plugins; - its selected **module** configuration; - a generated **image** and a long-lived, attachable **container** that runs OpenCode interactively. @@ -80,11 +79,10 @@ with exactly those modules already installed. Templates are stored as ## Shared OpenCode config -OpenCode configuration is shared across all workspaces from the global config -directory: +OpenCode configuration is shared across all workspaces from this source tree: ```text -~/.config/opencode-manager/ +~/.config/opencode-manager/opencode/ ├── AGENTS.md ├── opencode.json ├── agents/ @@ -93,12 +91,14 @@ directory: └── skills/ ``` -These are **mounted read-only** into every workspace at -`/home/debian/.config/opencode/`. Editing a file on the host propagates live to -all running workspaces — no copy, no recreation. Adding or removing an entry -takes effect the next time a workspace container is (re)created. +The manager copies this tree one way into each workspace at +`/home/debian/.config/opencode/` during provisioning, at startup, and after a +host-side source change while it is active. Host changes win and workspace +changes never flow back. A per-workspace journal lets source removals remove only +entries that the manager previously copied. Generated top-level state such as +package manifests, lockfiles, and `node_modules` is intentionally local. -On startup `ocm` creates any missing entries so the mounts always have a source: +On startup `ocm` creates any missing entries in the shared source: empty `AGENTS.md` and `agents/`, `commands/`, `plugins/`, `skills/` directories, and a minimal valid `opencode.json`: diff --git a/docs/configuration.md b/docs/configuration.md index b4cd3a0..4acb901 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,6 +16,7 @@ workspaceRoot: /home/user/.local/share/opencode-manager runtime: docker useLocalOpenCodeAuth: false extraCACertificate: [] +workspaceEnv: {} hostNetwork: false runtimeArgs: - --dns @@ -106,6 +107,25 @@ workspace (the home is bind-mounted and modules reinstall idempotently). > also has limited support on Docker Desktop (macOS/Windows); on Linux with > docker/podman it works as expected. +### `workspaceEnv` + +Environment variables passed to every workspace container. Values can be literal +or a reference to a variable in the manager host's environment: + +```yaml +workspaceEnv: + LOG_LEVEL: debug + MY_VAR: "{env:LOCAL_VAR}" +``` + +`MY_VAR` is available in each workspace with the current host value of +`LOCAL_VAR`. The reference is resolved whenever a workspace is provisioned; a +missing host variable prevents the workspace from starting. Changes to literal +values, referenced host values, or configured keys recreate the workspace +container. Referenced values are not written to `workspace.yaml`, but they are +visible to users who can inspect the container runtime. Module exports in the +workspace's `~/.env` take precedence for the OpenCode process. + ### `runtimeArgs` Optional list of extra flags passed **verbatim** to the `docker`/`podman create` diff --git a/docs/getting-started.md b/docs/getting-started.md index c99fe7b..8c6a15d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,11 +13,11 @@ npm install -g @mickaelroger78/opencode-manager ## 2. Add your OpenCode config -Every workspace shares the OpenCode configuration in your global config -directory. Copy your existing setup into it so all workspaces inherit it: +Every workspace receives a one-way copy of the shared OpenCode configuration. +Copy your existing setup into it so all workspaces inherit it: ```sh -cd ~/.config/opencode-manager +cd ~/.config/opencode-manager/opencode cp /path/to/your/opencode.json . cp -r /path/to/your/skills/* skills/ @@ -28,9 +28,11 @@ cp -r /path/to/your/plugins/* plugins/ cp /path/to/your/AGENTS.md . ``` -These are **mounted read-only** into every workspace, so editing them on the -host updates all workspaces live. `ocm` creates this directory and an empty -`opencode.json` on first run, so the layout already exists. +`ocm` copies this tree to each workspace when it is provisioned, reconciles it +on startup, and watches it while active. Host changes win and workspace changes +never flow back. Generated workspace state such as lockfiles and `node_modules` +is preserved. `ocm` creates this directory and an empty `opencode.json` on first +run, so the layout already exists. See [Concepts → Shared OpenCode config](concepts.md#shared-opencode-config) for exactly what is shared and how. diff --git a/go.mod b/go.mod index 4c7180a..05d43e1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.8 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v4 v4.0.0-rc.6 ) diff --git a/go.sum b/go.sum index fcd0faf..a8b3248 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/config/config.go b/internal/config/config.go index cbcb0c0..ce859b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ import ( "log/slog" "os" "path/filepath" + "regexp" + "sort" "strings" "go.yaml.in/yaml/v4" @@ -61,6 +63,9 @@ type Config struct { // ExtraCACertificates are optional absolute paths to host CA certificates // installed into every workspace container's system trust store. ExtraCACertificates CACertificates `yaml:"extraCACertificate"` + // WorkspaceEnv defines environment variables passed to every workspace + // container. Values may be literal or {env:HOST_VARIABLE} references. + WorkspaceEnv map[string]string `yaml:"workspaceEnv"` // 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 +95,44 @@ type Config struct { WorkspacePreDeleteCommands []string `yaml:"workspacePreDeleteCommands"` } +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// ResolveWorkspaceEnv resolves host environment references without persisting +// their values to workspace manifests. +func (c Config) ResolveWorkspaceEnv() (map[string]string, error) { + resolved := make(map[string]string, len(c.WorkspaceEnv)) + for key, value := range c.WorkspaceEnv { + if strings.HasPrefix(value, "{env:") { + if !strings.HasSuffix(value, "}") { + return nil, fmt.Errorf("workspaceEnv.%s has an invalid host environment reference", key) + } + hostKey := strings.TrimSuffix(strings.TrimPrefix(value, "{env:"), "}") + if !environmentName.MatchString(hostKey) { + return nil, fmt.Errorf("workspaceEnv.%s has an invalid host environment variable name", key) + } + hostValue, ok := os.LookupEnv(hostKey) + if !ok { + return nil, fmt.Errorf("workspaceEnv.%s requires host environment variable %q", key, hostKey) + } + resolved[key] = hostValue + continue + } + resolved[key] = value + } + return resolved, nil +} + +// WorkspaceEnvKeys returns the stable key list used to detect removed global +// environment variables on existing containers without exposing their values. +func (c Config) WorkspaceEnvKeys() string { + keys := make([]string, 0, len(c.WorkspaceEnv)) + for key := range c.WorkspaceEnv { + keys = append(keys, key) + } + sort.Strings(keys) + return strings.Join(keys, ",") +} + // CACertificates accepts the current YAML list form and the single-string form // used by existing configs before multiple certificates were supported. type CACertificates []string @@ -129,9 +172,8 @@ func DefaultPath() (string, error) { } // GlobalDir returns the opencode-manager configuration directory -// (~/.config/opencode-manager). It holds config.yaml as well as the OpenCode -// templates (AGENTS.md, opencode.json, agents/, commands/, plugins/, skills/) -// mounted read-only into every workspace container. +// (~/.config/opencode-manager). It holds config.yaml, modules, and the shared +// OpenCode configuration directory. func GlobalDir() (string, error) { dir, err := os.UserConfigDir() if err != nil { @@ -141,6 +183,16 @@ func GlobalDir() (string, error) { return filepath.Join(dir, "opencode-manager"), nil } +// OpenCodeDir returns the host-side OpenCode configuration that is copied into +// each workspace. It is deliberately separate from manager configuration. +func OpenCodeDir() (string, error) { + dir, err := GlobalDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "opencode"), nil +} + // DataDir returns the opencode-manager data directory // (~/.local/share/opencode-manager). It holds workspaces and logs. func DataDir() (string, error) { @@ -163,8 +215,8 @@ func LogDir() (string, error) { return filepath.Join(dataDir, "logs"), nil } -// GlobalTemplateDirs are the OpenCode template subdirectories created in the -// global config directory and mounted read-only into each workspace. +// GlobalTemplateDirs are conventional OpenCode config subdirectories created in +// the shared OpenCode configuration directory. var GlobalTemplateDirs = []string{"agents", "commands", "plugins", "skills"} // defaultOpenCodeJSON is the minimal valid content seeded into the global @@ -172,9 +224,8 @@ var GlobalTemplateDirs = []string{"agents", "commands", "plugins", "skills"} // config, so it cannot be left blank like AGENTS.md. const defaultOpenCodeJSON = "{\n \"$schema\": \"https://opencode.ai/config.json\"\n}\n" -// EnsureGlobalConfig creates the global config directory and the OpenCode -// template files/directories if they are missing. Existing files are never -// overwritten so user edits are preserved. +// EnsureGlobalConfig creates the global config directory and shared OpenCode +// files/directories if they are missing. Existing files are never overwritten. func EnsureGlobalConfig() error { dir, err := GlobalDir() if err != nil { @@ -185,8 +236,16 @@ func EnsureGlobalConfig() error { return fmt.Errorf("create global config directory %q: %w", dir, err) } + openCodeDir := filepath.Join(dir, "opencode") + if err := migrateLegacyOpenCodeTemplates(dir, openCodeDir); err != nil { + return err + } + if err := os.MkdirAll(openCodeDir, 0o700); err != nil { + return fmt.Errorf("create shared OpenCode directory %q: %w", openCodeDir, err) + } + for _, name := range GlobalTemplateDirs { - path := filepath.Join(dir, name) + path := filepath.Join(openCodeDir, name) if err := os.MkdirAll(path, 0o700); err != nil { return fmt.Errorf("create global template directory %q: %w", path, err) } @@ -197,7 +256,7 @@ func EnsureGlobalConfig() error { "opencode.json": defaultOpenCodeJSON, } for name, content := range files { - path := filepath.Join(dir, name) + path := filepath.Join(openCodeDir, name) if err := ensureFile(path, content); err != nil { return err } @@ -206,16 +265,38 @@ func EnsureGlobalConfig() error { return nil } +// migrateLegacyOpenCodeTemplates moves the former fixed root template layout +// into opencode/. It only runs when the new location is absent or empty so an +// existing shared config is never overwritten. +func migrateLegacyOpenCodeTemplates(globalDir, openCodeDir string) error { + entries, err := os.ReadDir(openCodeDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read shared OpenCode directory %q: %w", openCodeDir, err) + } + if err == nil && len(entries) != 0 { + return nil + } + if err := os.MkdirAll(openCodeDir, 0o700); err != nil { + return fmt.Errorf("create shared OpenCode directory %q: %w", openCodeDir, err) + } + + for _, name := range append([]string{"AGENTS.md", "opencode.json"}, GlobalTemplateDirs...) { + legacy := filepath.Join(globalDir, name) + if _, err := os.Lstat(legacy); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return fmt.Errorf("check legacy OpenCode template %q: %w", legacy, err) + } + if err := os.Rename(legacy, filepath.Join(openCodeDir, name)); err != nil { + return fmt.Errorf("migrate legacy OpenCode template %q: %w", legacy, err) + } + } + return nil +} + // ensureFile writes content to path when the file does not already exist, and in -// all cases makes the file world-readable. -// -// AGENTS.md and opencode.json are bind-mounted read-only into every workspace -// container. Under rootless Podman's user namespace, the workspace process's UID -// usually does not map to the host file's owner (the file appears owned by root / -// a sub-UID / nobody inside the container), so a 0600 file is unreadable there. -// 0644 keeps the file private on the host (it lives in the 0700 config dir) while -// guaranteeing the container can read it regardless of the UID mapping. Existing -// 0600 files from older installs are healed here on the next launch. +// all cases makes the file world-readable. Workspace sync writes private copies, +// but readable source files remain convenient for host-side OpenCode tooling. func ensureFile(path, content string) error { if info, err := os.Stat(path); err == nil { if perm := info.Mode().Perm(); perm&0o044 != 0o044 { @@ -309,6 +390,21 @@ func (c Config) Validate() error { return errors.New("baseImage.name is required") } + for key, value := range c.WorkspaceEnv { + if !environmentName.MatchString(key) { + return fmt.Errorf("workspaceEnv key %q is not a valid environment variable name", key) + } + if key == "HOME" || key == "TERM" || strings.HasPrefix(key, "OCM_") { + return fmt.Errorf("workspaceEnv key %q is reserved by the manager", key) + } + if strings.ContainsRune(value, '\x00') { + return fmt.Errorf("workspaceEnv.%s cannot contain NUL", key) + } + if strings.HasPrefix(value, "{env:") && (!strings.HasSuffix(value, "}") || !environmentName.MatchString(strings.TrimSuffix(strings.TrimPrefix(value, "{env:"), "}"))) { + return fmt.Errorf("workspaceEnv.%s has an invalid host environment reference", key) + } + } + for _, path := range c.ExtraCACertificates { if path == "" { return errors.New("extraCACertificate cannot contain empty paths") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 61ad929..578e677 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -203,6 +203,52 @@ func TestLoadParsesWorkspaceHookCommands(t *testing.T) { } } +func TestLoadResolvesWorkspaceEnv(t *testing.T) { + t.Setenv("LOCAL_TOKEN", "host-secret") + path := filepath.Join(t.TempDir(), "config.yaml") + writeFile(t, path, []byte("workspaceEnv:\n LOG_LEVEL: debug\n API_TOKEN: '{env:LOCAL_TOKEN}'\n")) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + resolved, err := cfg.ResolveWorkspaceEnv() + if err != nil { + t.Fatalf("ResolveWorkspaceEnv returned error: %v", err) + } + if !reflect.DeepEqual(resolved, map[string]string{"LOG_LEVEL": "debug", "API_TOKEN": "host-secret"}) { + t.Fatalf("resolved workspace env = %#v", resolved) + } + if got := cfg.WorkspaceEnvKeys(); got != "API_TOKEN,LOG_LEVEL" { + t.Fatalf("WorkspaceEnvKeys = %q, want API_TOKEN,LOG_LEVEL", got) + } +} + +func TestLoadRejectsInvalidWorkspaceEnv(t *testing.T) { + for _, content := range []string{ + "workspaceEnv:\n OCM_OPENCODE_PORT: value\n", + "workspaceEnv:\n INVALID-NAME: value\n", + "workspaceEnv:\n TOKEN: '{env:INVALID-NAME}'\n", + } { + path := filepath.Join(t.TempDir(), "config.yaml") + writeFile(t, path, []byte(content)) + if _, err := Load(path); err == nil { + t.Fatalf("Load should reject workspaceEnv config %q", content) + } + } +} + +func TestResolveWorkspaceEnvRequiresHostVariable(t *testing.T) { + cfg, err := Default() + if err != nil { + t.Fatalf("Default returned error: %v", err) + } + cfg.WorkspaceEnv = map[string]string{"TOKEN": "{env:MISSING_TOKEN}"} + if _, err := cfg.ResolveWorkspaceEnv(); err == nil { + t.Fatal("ResolveWorkspaceEnv should reject a missing host variable") + } +} + func TestLoadRejectsEmptyWorkspaceHookCommand(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") @@ -277,7 +323,7 @@ func TestEnsureGlobalConfigCreatesTemplates(t *testing.T) { t.Fatalf("EnsureGlobalConfig returned error: %v", err) } - dir := filepath.Join(configHome, "opencode-manager") + dir := filepath.Join(configHome, "opencode-manager", "opencode") agents, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) if err != nil { @@ -309,9 +355,7 @@ func TestEnsureGlobalConfigCreatesTemplates(t *testing.T) { } } -// AGENTS.md and opencode.json are bind-mounted read-only into workspace -// containers; under rootless podman the workspace user's UID may not map to the -// file owner, so they must be world-readable or the container cannot read them. +// Shared source files remain readable for host-side OpenCode tooling. func TestEnsureGlobalConfigMakesMountedFilesWorldReadable(t *testing.T) { configHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configHome) @@ -320,7 +364,7 @@ func TestEnsureGlobalConfigMakesMountedFilesWorldReadable(t *testing.T) { t.Fatalf("EnsureGlobalConfig returned error: %v", err) } - dir := filepath.Join(configHome, "opencode-manager") + dir := filepath.Join(configHome, "opencode-manager", "opencode") for _, name := range []string{"AGENTS.md", "opencode.json"} { info, err := os.Stat(filepath.Join(dir, name)) if err != nil { @@ -364,7 +408,7 @@ func TestEnsureGlobalConfigPreservesExistingFiles(t *testing.T) { t.Fatalf("EnsureGlobalConfig returned error: %v", err) } - got, err := os.ReadFile(filepath.Join(dir, "opencode.json")) + got, err := os.ReadFile(filepath.Join(dir, "opencode", "opencode.json")) if err != nil { t.Fatalf("read opencode.json: %v", err) } @@ -373,6 +417,27 @@ func TestEnsureGlobalConfigPreservesExistingFiles(t *testing.T) { } } +func TestEnsureGlobalConfigMigratesLegacyTemplatesWhenSharedDirectoryIsEmpty(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + global := filepath.Join(configHome, "opencode-manager") + if err := os.MkdirAll(global, 0o700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(global, "opencode.json"), []byte("{\"model\":\"legacy\"}")) + + if err := EnsureGlobalConfig(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(global, "opencode", "opencode.json")) + if err != nil || string(data) != "{\"model\":\"legacy\"}" { + t.Fatalf("migrated config = %q, %v", data, err) + } + if _, err := os.Stat(filepath.Join(global, "opencode.json")); !os.IsNotExist(err) { + t.Fatalf("legacy config remains or stat failed: %v", err) + } +} + func writeFile(t *testing.T, path string, data []byte) { t.Helper() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 389bbf4..2ce397b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "embed" + "encoding/json" "fmt" "log/slog" "os" @@ -357,22 +358,32 @@ func (d CLIDriver) ContainerRuntimeConfig(ctx context.Context, name string) (Con return ContainerRuntimeConfig{}, fmt.Errorf("container name is required") } - // First line is the network mode; each remaining line is a KEY=VALUE env var. - const format = `{{println .HostConfig.NetworkMode}}{{range .Config.Env}}{{println .}}{{end}}` + const format = `{{json .}}` cmd := exec.CommandContext(ctx, d.binary, "inspect", "-f", format, name) output, err := cmd.CombinedOutput() if err != nil { return ContainerRuntimeConfig{}, fmt.Errorf("inspect container %q: %w: %s", name, err, strings.TrimSpace(string(output))) } + return parseContainerRuntimeConfig(output) +} +func parseContainerRuntimeConfig(output []byte) (ContainerRuntimeConfig, error) { + var inspected struct { + HostConfig struct { + NetworkMode string + } + Config struct { + Env []string + } + } + if err := json.Unmarshal(output, &inspected); err != nil { + return ContainerRuntimeConfig{}, fmt.Errorf("parse container runtime config: %w", err) + } rc := ContainerRuntimeConfig{Env: map[string]string{}} - lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n") - if len(lines) > 0 { - rc.NetworkMode = strings.TrimSpace(lines[0]) - for _, line := range lines[1:] { - if i := strings.IndexByte(line, '='); i > 0 { - rc.Env[line[:i]] = line[i+1:] - } + rc.NetworkMode = inspected.HostConfig.NetworkMode + for _, line := range inspected.Config.Env { + if i := strings.IndexByte(line, '='); i > 0 { + rc.Env[line[:i]] = line[i+1:] } } return rc, nil @@ -598,18 +609,40 @@ func (d CLIDriver) Exec(ctx context.Context, spec ExecSpec) ([]byte, error) { } func (d CLIDriver) run(ctx context.Context, args ...string) error { - slog.Debug("running runtime command", "runtime", d.binary, "args", args) + redactedArgs := redactEnvArgs(args) + slog.Debug("running runtime command", "runtime", d.binary, "args", redactedArgs) cmd := exec.CommandContext(ctx, d.binary, args...) output, err := cmd.CombinedOutput() if err != nil { - slog.Debug("runtime command failed", "runtime", d.binary, "args", args, "error", err, "output", strings.TrimSpace(string(output))) - return fmt.Errorf("%s %s: %w: %s", d.binary, strings.Join(args, " "), err, strings.TrimSpace(string(output))) + slog.Debug("runtime command failed", "runtime", d.binary, "args", redactedArgs, "error", err, "output", strings.TrimSpace(string(output))) + return fmt.Errorf("%s %s: %w: %s", d.binary, strings.Join(redactedArgs, " "), err, strings.TrimSpace(string(output))) } - slog.Debug("runtime command succeeded", "runtime", d.binary, "args", args) + slog.Debug("runtime command succeeded", "runtime", d.binary, "args", redactedArgs) return nil } +func redactEnvArgs(args []string) []string { + redacted := append([]string(nil), args...) + for i, arg := range redacted { + if arg == "--env" && i+1 < len(redacted) { + redacted[i+1] = redactEnvValue(redacted[i+1]) + continue + } + if strings.HasPrefix(arg, "--env=") { + redacted[i] = "--env=" + redactEnvValue(strings.TrimPrefix(arg, "--env=")) + } + } + return redacted +} + +func redactEnvValue(value string) string { + if key, _, ok := strings.Cut(value, "="); ok { + return key + "=" + } + return "" +} + func (d CLIDriver) runAllowMissing(ctx context.Context, args []string, resource string) error { slog.Debug("running runtime command", "runtime", d.binary, "args", args) cmd := exec.CommandContext(ctx, d.binary, args...) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 05bc0cd..f7f4d80 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -132,6 +133,24 @@ func TestCreateArgsAppendsExtraArgsBeforeImage(t *testing.T) { } } +func TestRedactEnvArgs(t *testing.T) { + got := redactEnvArgs([]string{"create", "--env", "TOKEN=secret", "--env=NAME=value", "image"}) + want := []string{"create", "--env", "TOKEN=", "--env=NAME=", "image"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("redactEnvArgs = %#v, want %#v", got, want) + } +} + +func TestParseContainerRuntimeConfigPreservesMultilineEnv(t *testing.T) { + rc, err := parseContainerRuntimeConfig([]byte(`{"HostConfig":{"NetworkMode":"host"},"Config":{"Env":["TOKEN=first\nsecond","PLAIN=value"]}}`)) + if err != nil { + t.Fatalf("parseContainerRuntimeConfig returned error: %v", err) + } + if rc.NetworkMode != "host" || rc.Env["TOKEN"] != "first\nsecond" || rc.Env["PLAIN"] != "value" { + t.Fatalf("runtime config = %#v", rc) + } +} + func indexOf(args []string, want string) int { for i, a := range args { if a == want { diff --git a/internal/tui/app.go b/internal/tui/app.go index 306af31..6be373c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -613,10 +613,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.loadStatuses case workspace.ShellResultMsg: - if msg.Err != nil { + switch { + case msg.StillRunning && msg.Completed: + slog.Debug("shell session closed, container still running") + m.message = "Shell session closed; container still running." + case msg.Err != nil: slog.Error("shell session failed", "error", msg.Err) m.showError("Shell Session", fmt.Sprintf("Shell session failed: %v", msg.Err)) - } else { + default: slog.Debug("shell session closed") m.message = "Shell session closed." } diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index b266b58..3d3bc42 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -359,6 +359,36 @@ func TestUpdateErrorOpensFullErrorDialog(t *testing.T) { } } +func TestShellResultWithRunningContainerDoesNotOpenErrorDialog(t *testing.T) { + m := model{baseImageReady: true} + updated, _ := m.Update(workspace.ShellResultMsg{Err: errors.New("exit status 1"), StillRunning: true, Completed: true}) + next := updated.(model) + if next.errorMessage != "" { + t.Fatalf("errorMessage = %q, want empty", next.errorMessage) + } + if !strings.Contains(next.message, "container still running") { + t.Fatalf("message = %q, want normal shell closure", next.message) + } +} + +func TestShellResultInfrastructureFailureWithRunningContainerOpensErrorDialog(t *testing.T) { + m := model{baseImageReady: true} + updated, _ := m.Update(workspace.ShellResultMsg{Err: errors.New("docker exec failed"), StillRunning: true}) + next := updated.(model) + if next.errorTitle != "Shell Session" || !strings.Contains(next.errorMessage, "docker exec failed") { + t.Fatalf("shell error dialog = (%q, %q)", next.errorTitle, next.errorMessage) + } +} + +func TestShellResultFailureAfterContainerStopsOpensErrorDialog(t *testing.T) { + m := model{baseImageReady: true} + updated, _ := m.Update(workspace.ShellResultMsg{Err: errors.New("exit status 1")}) + next := updated.(model) + if next.errorTitle != "Shell Session" || !strings.Contains(next.errorMessage, "exit status 1") { + t.Fatalf("shell error dialog = (%q, %q)", next.errorTitle, next.errorMessage) + } +} + func TestActionsUseK9sBindings(t *testing.T) { keys := map[string]string{} for _, a := range actions { diff --git a/internal/workspace/configsync.go b/internal/workspace/configsync.go new file mode 100644 index 0000000..b19f31c --- /dev/null +++ b/internal/workspace/configsync.go @@ -0,0 +1,311 @@ +package workspace + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/mickael-menu/opencode-manager/internal/config" +) + +const openCodeSyncJournal = ".ocm-opencode-sync.json" + +var ( + openCodeSyncMu sync.Mutex + reservedOpenCodeSourceNames = map[string]struct{}{ + ".gitignore": {}, + "package.json": {}, + "package-lock.json": {}, + "npm-shrinkwrap.json": {}, + "bun.lock": {}, + "bun.lockb": {}, + "pnpm-lock.yaml": {}, + "yarn.lock": {}, + "node_modules": {}, + } +) + +type openCodeSyncJournalData struct { + Entries []string `json:"entries"` +} + +// StartOpenCodeConfigSync reconciles existing workspaces immediately and keeps +// their OpenCode configuration current while the manager process is active. +func StartOpenCodeConfigSync(ctx context.Context, cfg config.Config) error { + syncer := openCodeConfigSyncer{registry: NewRegistry(cfg)} + if err := syncer.reconcile(); err != nil { + return err + } + + source, err := config.OpenCodeDir() + if err != nil { + return err + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("create shared OpenCode config watcher: %w", err) + } + if err := addOpenCodeWatches(watcher, source); err != nil { + watcher.Close() + return err + } + + go syncer.watch(ctx, watcher, source) + return nil +} + +type openCodeConfigSyncer struct { + registry Registry +} + +func (s openCodeConfigSyncer) reconcile() error { + workspaces, err := s.registry.List() + if err != nil { + return fmt.Errorf("list workspaces for shared OpenCode config sync: %w", err) + } + for _, workspace := range workspaces { + if err := syncWorkspaceOpenCodeConfig(workspace.Manifest.HomeDir); err != nil { + return fmt.Errorf("sync shared OpenCode config to workspace %q: %w", workspace.Manifest.Name, err) + } + } + return nil +} + +func (s openCodeConfigSyncer) watch(ctx context.Context, watcher *fsnotify.Watcher, source string) { + defer watcher.Close() + var timer *time.Timer + var timerC <-chan time.Time + for { + select { + case <-ctx.Done(): + if timer != nil { + timer.Stop() + } + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Create) { + if err := addOpenCodeWatches(watcher, source); err != nil { + slog.Warn("refresh shared OpenCode config watches", "error", err) + } + } + if timer == nil { + timer = time.NewTimer(100 * time.Millisecond) + timerC = timer.C + } else if !timer.Stop() { + select { + case <-timer.C: + default: + } + timer.Reset(100 * time.Millisecond) + } else { + timer.Reset(100 * time.Millisecond) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + slog.Warn("shared OpenCode config watcher error", "error", err) + case <-timerC: + timer = nil + timerC = nil + if err := s.reconcile(); err != nil { + slog.Warn("synchronize changed shared OpenCode config", "error", err) + } + } + } +} + +func addOpenCodeWatches(watcher *fsnotify.Watcher, source string) error { + return filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() { + return nil + } + if err := watcher.Add(path); err != nil && !errors.Is(err, fsnotify.ErrNonExistentWatch) { + return fmt.Errorf("watch shared OpenCode directory %q: %w", path, err) + } + return nil + }) +} + +// syncWorkspaceOpenCodeConfig copies every shared source entry one way into a +// workspace and records manager-owned paths. A missing source entry removes only +// a previously journaled path, never arbitrary workspace configuration. +func syncWorkspaceOpenCodeConfig(homeDir string) error { + openCodeSyncMu.Lock() + defer openCodeSyncMu.Unlock() + + if err := config.EnsureGlobalConfig(); err != nil { + return err + } + source, err := config.OpenCodeDir() + if err != nil { + return err + } + entries, err := sourceEntries(source) + if err != nil { + return err + } + + destination := filepath.Join(homeDir, ".config", "opencode") + if err := os.MkdirAll(destination, 0o700); err != nil { + return fmt.Errorf("create workspace OpenCode directory %q: %w", destination, err) + } + journalPath := filepath.Join(homeDir, openCodeSyncJournal) + previous, err := loadOpenCodeSyncJournal(journalPath) + if err != nil { + return err + } + sort.Slice(previous, func(i, j int) bool { + return len(previous[i]) > len(previous[j]) + }) + for _, rel := range previous { + if _, exists := entries[rel]; exists { + continue + } + if err := removeManagedOpenCodeEntry(destination, rel); err != nil { + return err + } + } + + paths := make([]string, 0, len(entries)) + for rel := range entries { + paths = append(paths, rel) + } + sort.Strings(paths) + for _, rel := range paths { + if entries[rel] { + if err := os.MkdirAll(filepath.Join(destination, rel), 0o700); err != nil { + return fmt.Errorf("create workspace OpenCode directory %q: %w", rel, err) + } + continue + } + if err := copyOpenCodeFile(filepath.Join(source, rel), filepath.Join(destination, rel)); err != nil { + return err + } + } + if err := saveOpenCodeSyncJournal(journalPath, paths); err != nil { + return err + } + return EnsureWorkspaceStatusPlugin(destination) +} + +// sourceEntries validates the top-level shared source and returns relative paths +// with true for directories and false for regular files. +func sourceEntries(source string) (map[string]bool, error) { + entries := make(map[string]bool) + err := filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if path == source { + return nil + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + if filepath.Dir(rel) == "." { + if _, reserved := reservedOpenCodeSourceNames[entry.Name()]; reserved { + return fmt.Errorf("shared OpenCode config %q cannot contain top-level %q: it is generated per workspace", source, entry.Name()) + } + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("shared OpenCode config entry %q must not be a symbolic link", path) + } + if entry.IsDir() { + entries[rel] = true + return nil + } + if !entry.Type().IsRegular() { + return fmt.Errorf("shared OpenCode config entry %q must be a regular file or directory", path) + } + entries[rel] = false + return nil + }) + if err != nil { + return nil, fmt.Errorf("read shared OpenCode config %q: %w", source, err) + } + return entries, nil +} + +func copyOpenCodeFile(source, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return fmt.Errorf("open shared OpenCode file %q: %w", source, err) + } + defer in.Close() + out, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open workspace OpenCode file %q: %w", destination, err) + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return fmt.Errorf("copy shared OpenCode file %q: %w", source, copyErr) + } + if closeErr != nil { + return fmt.Errorf("close workspace OpenCode file %q: %w", destination, closeErr) + } + return nil +} + +func loadOpenCodeSyncJournal(path string) ([]string, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read OpenCode sync journal %q: %w", path, err) + } + var journal openCodeSyncJournalData + if err := json.Unmarshal(data, &journal); err != nil { + return nil, fmt.Errorf("parse OpenCode sync journal %q: %w", path, err) + } + return journal.Entries, nil +} + +func saveOpenCodeSyncJournal(path string, entries []string) error { + data, err := json.Marshal(openCodeSyncJournalData{Entries: entries}) + if err != nil { + return err + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, data, 0o600); err != nil { + return fmt.Errorf("write OpenCode sync journal %q: %w", temporary, err) + } + if err := os.Rename(temporary, path); err != nil { + return fmt.Errorf("save OpenCode sync journal %q: %w", path, err) + } + return nil +} + +func removeManagedOpenCodeEntry(destination, rel string) error { + if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return fmt.Errorf("invalid OpenCode sync journal path %q", rel) + } + path := filepath.Join(destination, rel) + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return fmt.Errorf("remove managed workspace OpenCode entry %q: %w", path, err) + } + return nil +} diff --git a/internal/workspace/configsync_test.go b/internal/workspace/configsync_test.go new file mode 100644 index 0000000..999526b --- /dev/null +++ b/internal/workspace/configsync_test.go @@ -0,0 +1,141 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mickael-menu/opencode-manager/internal/config" +) + +func TestSyncWorkspaceOpenCodeConfigCopiesAndUpdatesSource(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + if err := config.EnsureGlobalConfig(); err != nil { + t.Fatal(err) + } + source, err := config.OpenCodeDir() + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(source, "agents", "review.md"), []byte("first")) + home := t.TempDir() + + if err := syncWorkspaceOpenCodeConfig(home); err != nil { + t.Fatalf("syncWorkspaceOpenCodeConfig returned error: %v", err) + } + destination := filepath.Join(home, ".config", "opencode", "agents", "review.md") + data, err := os.ReadFile(destination) + if err != nil || string(data) != "first" { + t.Fatalf("copied file = %q, %v; want first, nil", data, err) + } + + writeTestFile(t, filepath.Join(source, "agents", "review.md"), []byte("second")) + if err := syncWorkspaceOpenCodeConfig(home); err != nil { + t.Fatalf("second sync returned error: %v", err) + } + data, err = os.ReadFile(destination) + if err != nil || string(data) != "second" { + t.Fatalf("updated file = %q, %v; want second, nil", data, err) + } +} + +func TestSyncWorkspaceOpenCodeConfigRemovesOnlyJournaledEntries(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + if err := config.EnsureGlobalConfig(); err != nil { + t.Fatal(err) + } + source, _ := config.OpenCodeDir() + managed := filepath.Join(source, "commands", "shared.md") + writeTestFile(t, managed, []byte("shared")) + home := t.TempDir() + destination := filepath.Join(home, ".config", "opencode") + writeTestFile(t, filepath.Join(destination, "commands", "local.md"), []byte("local")) + + if err := syncWorkspaceOpenCodeConfig(home); err != nil { + t.Fatal(err) + } + if err := os.Remove(managed); err != nil { + t.Fatal(err) + } + if err := syncWorkspaceOpenCodeConfig(home); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "commands", "shared.md")); !os.IsNotExist(err) { + t.Fatalf("managed file remains or stat failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(destination, "commands", "local.md")) + if err != nil || string(data) != "local" { + t.Fatalf("local file = %q, %v; want preserved", data, err) + } +} + +func TestSyncWorkspaceOpenCodeConfigRejectsReservedTopLevelState(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + if err := config.EnsureGlobalConfig(); err != nil { + t.Fatal(err) + } + source, _ := config.OpenCodeDir() + writeTestFile(t, filepath.Join(source, "package.json"), []byte("{}")) + + err := syncWorkspaceOpenCodeConfig(t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "package.json") || !strings.Contains(err.Error(), "generated per workspace") { + t.Fatalf("sync error = %v, want reserved source error", err) + } +} + +func TestSyncWorkspaceOpenCodeConfigKeepsGeneratedState(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + if err := config.EnsureGlobalConfig(); err != nil { + t.Fatal(err) + } + home := t.TempDir() + destination := filepath.Join(home, ".config", "opencode") + writeTestFile(t, filepath.Join(destination, "package.json"), []byte("{\"private\":true}")) + + if err := syncWorkspaceOpenCodeConfig(home); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(destination, "package.json")) + if err != nil || string(data) != "{\"private\":true}" { + t.Fatalf("generated state = %q, %v; want preserved", data, err) + } +} + +func TestStartOpenCodeConfigSyncWatchesSourceChanges(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + cfg := testConfig(t) + created, err := NewRegistry(cfg).Create("demo") + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := StartOpenCodeConfigSync(ctx, cfg); err != nil { + t.Fatal(err) + } + source, err := config.OpenCodeDir() + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(source, "skills", "watched.md"), []byte("watched")) + destination := filepath.Join(created.Manifest.HomeDir, ".config", "opencode", "skills", "watched.md") + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(destination) + if err == nil && string(data) == "watched" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("watcher did not copy %q before deadline", destination) +} diff --git a/internal/workspace/lifecycle.go b/internal/workspace/lifecycle.go index 281a7e6..77640c8 100644 --- a/internal/workspace/lifecycle.go +++ b/internal/workspace/lifecycle.go @@ -2,6 +2,7 @@ package workspace import ( "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -43,7 +44,9 @@ type AttachResultMsg struct { } type ShellResultMsg struct { - Err error + Err error + StillRunning bool + Completed bool } func NewLifecycle(cfg config.Config) (Lifecycle, error) { @@ -250,9 +253,9 @@ func (l Lifecycle) provision(ctx context.Context, summary Summary) (string, runt return runtime.StatusUnknown, runtime.ContainerSpec{}, err } - // Seed the workspace's OpenCode asset directories (and the manager status - // plugin) into the bind-mounted home so they are writable by module scripts. - if err := ensureWorkspaceOpenCodeAssets(manifest.HomeDir); err != nil { + // Converge shared host OpenCode configuration into the writable workspace + // copy before starting the server. + if err := syncWorkspaceOpenCodeConfig(manifest.HomeDir); err != nil { return runtime.StatusUnknown, runtime.ContainerSpec{}, err } @@ -273,15 +276,23 @@ func (l Lifecycle) provision(ctx context.Context, summary Summary) (string, runt return runtime.StatusUnknown, runtime.ContainerSpec{}, fmt.Errorf("create workspace OpenCode data directory: %w", err) } } + workspaceEnv, err := l.cfg.ResolveWorkspaceEnv() + if err != nil { + return runtime.StatusUnknown, runtime.ContainerSpec{}, err + } // 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)+2) + env := make(map[string]string, len(manifest.Env)+len(workspaceEnv)+3) for k, v := range manifest.Env { env[k] = v } + for k, v := range workspaceEnv { + env[k] = v + } env[OpenCodePortEnv] = strconv.Itoa(manifest.OpenCodePort) + env[workspaceEnvKeysEnv] = l.cfg.WorkspaceEnvKeys() if extraCAFingerprint != "" { env[extraCACertificateFingerprintEnv] = extraCAFingerprint } @@ -392,6 +403,7 @@ const openCodeAuthRelPath = ".local/share/opencode/auth.json" const ( extraCACertificateFingerprintEnv = "OCM_EXTRA_CA_CERTIFICATE_SHA256" + workspaceEnvKeysEnv = "OCM_WORKSPACE_ENV_KEYS" ) // extraCACertificateMounts returns mounts and a combined fingerprint for all @@ -426,31 +438,10 @@ func extraCACertificateMounts(paths []string) ([]runtime.Mount, string, error) { return mounts, fmt.Sprintf("%x", hash.Sum(nil)), 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 -// opencode.json) are mounted live; editing one propagates to every workspace. -// -// The asset directories (agents/, commands/, plugins/, skills/) are NOT mounted: -// they are seeded into the workspace home so module install scripts can write -// OpenCode commands/skills/agents/plugins into them (see -// ensureWorkspaceOpenCodeAssets). They are then workspace-owned, writable, and -// persist across container recreation via the bind-mounted home. +// openCodeMounts returns optional OpenCode-related mounts. Shared configuration +// is copied into the workspace home rather than mounted, so it remains writable. func openCodeMounts(useLocalAuth bool) ([]runtime.Mount, error) { - dir, err := config.GlobalDir() - if err != nil { - return nil, err - } - - names := []string{"AGENTS.md", "opencode.json"} - mounts := make([]runtime.Mount, 0, len(names)+1) - for _, name := range names { - mounts = append(mounts, runtime.Mount{ - Source: filepath.Join(dir, name), - Target: openCodeConfigDir + "/" + name, - ReadOnly: true, - }) - } + mounts := make([]runtime.Mount, 0, 1) if useLocalAuth { source, err := localOpenCodeAuthPath() if err != nil { @@ -469,71 +460,6 @@ func openCodeMounts(useLocalAuth bool) ([]runtime.Mount, error) { return mounts, nil } -// ensureWorkspaceOpenCodeAssets seeds the OpenCode asset directories (agents/, -// commands/, plugins/, skills/) into the workspace home from the global -// templates the first time, and always refreshes the manager-owned status -// plugin. Existing directories are left intact so workspace-level edits and -// module-written assets are preserved. -func ensureWorkspaceOpenCodeAssets(homeDir string) error { - globalDir, err := config.GlobalDir() - if err != nil { - return err - } - - base := filepath.Join(homeDir, ".config", "opencode") - for _, name := range config.GlobalTemplateDirs { - dst := filepath.Join(base, name) - if _, err := os.Stat(dst); err == nil { - continue - } else if !os.IsNotExist(err) { - return fmt.Errorf("check workspace OpenCode asset directory %q: %w", dst, err) - } - if err := os.MkdirAll(dst, 0o700); err != nil { - return fmt.Errorf("create workspace OpenCode asset directory %q: %w", dst, err) - } - if err := copyDirContents(filepath.Join(globalDir, name), dst); err != nil { - return fmt.Errorf("seed workspace OpenCode asset directory %q: %w", dst, err) - } - } - - return EnsureWorkspaceStatusPlugin(base) -} - -// copyDirContents recursively copies the contents of src into dst. A missing -// src is treated as empty. -func copyDirContents(src, dst string) error { - entries, err := os.ReadDir(src) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("read directory %q: %w", src, err) - } - - for _, entry := range entries { - s := filepath.Join(src, entry.Name()) - d := filepath.Join(dst, entry.Name()) - if entry.IsDir() { - if err := os.MkdirAll(d, 0o700); err != nil { - return err - } - if err := copyDirContents(s, d); err != nil { - return err - } - continue - } - data, err := os.ReadFile(s) - if err != nil { - return err - } - if err := os.WriteFile(d, data, 0o600); err != nil { - return err - } - } - - return nil -} - func localOpenCodeAuthPath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -682,20 +608,10 @@ func (l Lifecycle) AttachCommand(ctx context.Context, summary Summary) (*exec.Cm return l.driver.ExecCommand(summary.Manifest.ContainerName, openCodeSessionCommand()), nil } -// ensureCurrentRunning makes sure the workspace container is running and built -// from the current image before we exec into it. The common hot path (already -// running and current) avoids the image build that EnsureStarted performs. +// ensureCurrentRunning converges the workspace before we exec into it so changes +// to global workspace settings, including host environment references, apply on +// the normal attach path. func (l Lifecycle) ensureCurrentRunning(ctx context.Context, summary Summary) error { - status, err := l.driver.ContainerStatus(ctx, summary.Manifest.ContainerName) - if err != nil { - return err - } - if status == runtime.StatusRunning { - if stale, serr := l.containerImageStale(ctx, summary.Manifest); serr == nil && !stale { - return nil - } - } - return l.EnsureStarted(ctx, summary) } @@ -749,6 +665,17 @@ func (l Lifecycle) containerSpecDrift(ctx context.Context, manifest Manifest, sp return true } + if rc.Env[workspaceEnvKeysEnv] != spec.Env[workspaceEnvKeysEnv] { + slog.Debug("container workspace environment keys differ from desired", "workspace", manifest.Name, "container", manifest.ContainerName) + return true + } + for key := range l.cfg.WorkspaceEnv { + if rc.Env[key] != spec.Env[key] { + slog.Debug("container workspace environment differs from desired", "workspace", manifest.Name, "container", manifest.ContainerName, "key", key) + return true + } + } + return false } @@ -811,16 +738,76 @@ func (l Lifecycle) RunCommand(ctx context.Context, summary Summary, prompt strin } func (l Lifecycle) Shell(ctx context.Context, summary Summary) (tea.Cmd, error) { - cmd, err := l.ShellCommand(ctx, summary) + markerName, err := shellMarkerName() if err != nil { return nil, err } + markerPath := filepath.Join(summary.Manifest.HomeDir, markerName) + rcName := strings.TrimSuffix(markerName, ".done") + ".rc" + rcPath := filepath.Join(summary.Manifest.HomeDir, rcName) + markerContainerPath := openCodeHomeDir + "/" + markerName + if err := writeShellRC(rcPath, markerContainerPath); err != nil { + return nil, err + } + cmd, err := l.shellCommand(ctx, summary, openCodeHomeDir+"/"+rcName) + if err != nil { + _ = os.Remove(rcPath) + return nil, err + } return tea.ExecProcess(cmd, func(err error) tea.Msg { - return ShellResultMsg{Err: err} + completed := consumeShellMarker(markerPath) + _ = os.Remove(rcPath) + // Use a fresh context: the caller's ctx is already cancelled by the time + // the interactive shell exits. + bg, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + status, _ := l.driver.ContainerStatus(bg, summary.Manifest.ContainerName) + return ShellResultMsg{Err: err, StillRunning: status == runtime.StatusRunning, Completed: completed} }), nil } +func shellMarkerName() (string, error) { + token := make([]byte, 16) + if _, err := rand.Read(token); err != nil { + return "", fmt.Errorf("generate shell completion marker: %w", err) + } + return ".ocm-shell-" + hex.EncodeToString(token) + ".done", nil +} + +func (l Lifecycle) shellCommand(ctx context.Context, summary Summary, rcPath string) (*exec.Cmd, error) { + if rcPath == "" { + return l.ShellCommand(ctx, summary) + } + + slog.Info("opening shell in workspace", "workspace", summary.Manifest.Name, "container", summary.Manifest.ContainerName) + if err := l.EnsureStarted(ctx, summary); err != nil { + return nil, err + } + return l.driver.ExecCommand(summary.Manifest.ContainerName, shellCommandArgs(rcPath)), nil +} + +func writeShellRC(rcPath, markerPath string) error { + content := fmt.Sprintf("if [ -f \"$HOME/.bashrc\" ] && ! . \"$HOME/.bashrc\"; then exit 1; fi\n: > %q\n", markerPath) + if err := os.WriteFile(rcPath, []byte(content), 0o600); err != nil { + return fmt.Errorf("write shell startup file: %w", err) + } + return nil +} + +func shellCommandArgs(rcPath string) []string { + return []string{"/bin/bash", "--rcfile", rcPath, "-i"} +} + +func consumeShellMarker(markerPath string) bool { + _, err := os.Stat(markerPath) + if err != nil { + return false + } + _ = os.Remove(markerPath) + return true +} + // openCodeServeCommand is the container's main process: the supervisor // entrypoint, which sources the per-workspace ~/.env and then runs a persistent, // headless OpenCode server. The server keeps the container alive, runs the agent diff --git a/internal/workspace/lifecycle_base_test.go b/internal/workspace/lifecycle_base_test.go index 9265609..00e8bad 100644 --- a/internal/workspace/lifecycle_base_test.go +++ b/internal/workspace/lifecycle_base_test.go @@ -252,3 +252,31 @@ func TestProvisionMountsExtraCACertificate(t *testing.T) { } t.Fatalf("extra CA mount not found in %#v", rec.created.Mounts) } + +func TestProvisionResolvesWorkspaceEnv(t *testing.T) { + rec := &specRecordingDriver{fakeDriver: &fakeDriver{}} + t.Setenv("HOST_TOKEN", "host-secret") + cfg := config.Config{ + WorkspaceRoot: t.TempDir(), + Runtime: config.RuntimeDocker, + WorkspaceEnv: map[string]string{ + "LOG_LEVEL": "debug", + "API_TOKEN": "{env:HOST_TOKEN}", + }, + 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 _, _, err := l.provision(context.Background(), Summary{Manifest: created.Manifest, Path: created.Path}); err != nil { + t.Fatalf("provision: %v", err) + } + if rec.created.Env["API_TOKEN"] != "host-secret" || rec.created.Env["LOG_LEVEL"] != "debug" { + t.Fatalf("workspace environment = %#v", rec.created.Env) + } + if rec.created.Env[workspaceEnvKeysEnv] != "API_TOKEN,LOG_LEVEL" { + t.Fatalf("workspace environment marker = %q", rec.created.Env[workspaceEnvKeysEnv]) + } +} diff --git a/internal/workspace/lifecycle_drift_test.go b/internal/workspace/lifecycle_drift_test.go index 7ecbcab..499450d 100644 --- a/internal/workspace/lifecycle_drift_test.go +++ b/internal/workspace/lifecycle_drift_test.go @@ -76,12 +76,21 @@ func TestContainerSpecDrift(t *testing.T) { spec: runtime.ContainerSpec{Env: map[string]string{extraCACertificateFingerprintEnv: "new"}}, want: true, }, + { + name: "workspace environment changed", + rc: runtime.ContainerRuntimeConfig{NetworkMode: "bridge", Env: map[string]string{OpenCodePortEnv: "4097", workspaceEnvKeysEnv: "API_TOKEN", "API_TOKEN": "old"}}, + spec: runtime.ContainerSpec{Env: map[string]string{workspaceEnvKeysEnv: "API_TOKEN", "API_TOKEN": "new"}}, + want: true, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { d := &driftDriver{fakeDriver: &fakeDriver{}, rc: tc.rc, rcErr: tc.rcErr} l := Lifecycle{driver: d} + if tc.name == "workspace environment changed" { + l.cfg.WorkspaceEnv = map[string]string{"API_TOKEN": "new"} + } if got := l.containerSpecDrift(context.Background(), manifest, tc.spec); got != tc.want { t.Fatalf("containerSpecDrift = %v, want %v", got, tc.want) } diff --git a/internal/workspace/lifecycle_test.go b/internal/workspace/lifecycle_test.go index 340bf98..ff6eb2a 100644 --- a/internal/workspace/lifecycle_test.go +++ b/internal/workspace/lifecycle_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509/pkix" "encoding/pem" "math/big" + "os" "path/filepath" "reflect" "strings" @@ -177,6 +178,55 @@ func TestVerifyStartedRejectsContainerThatExitsDuringStartup(t *testing.T) { } } +func TestShellCommandArgsUseStartupFile(t *testing.T) { + args := shellCommandArgs("/home/debian/.ocm-shell-test.rc") + if !reflect.DeepEqual(args, []string{"/bin/bash", "--rcfile", "/home/debian/.ocm-shell-test.rc", "-i"}) { + t.Fatalf("shell command args = %#v", args) + } +} + +func TestWriteShellRCWritesMarkerAfterBashRC(t *testing.T) { + rcPath := filepath.Join(t.TempDir(), ".ocm-shell.rc") + if err := writeShellRC(rcPath, "/home/debian/.ocm-shell.done"); err != nil { + t.Fatalf("writeShellRC returned error: %v", err) + } + content, err := os.ReadFile(rcPath) + if err != nil { + t.Fatalf("read shell startup file: %v", err) + } + if !strings.Contains(string(content), "! . \"$HOME/.bashrc\"") || !strings.Contains(string(content), "exit 1") || !strings.Contains(string(content), ".ocm-shell.done") { + t.Fatalf("shell startup file = %q", content) + } +} + +func TestConsumeShellMarker(t *testing.T) { + marker := filepath.Join(t.TempDir(), ".ocm-shell.done") + if consumeShellMarker(marker) { + t.Fatal("missing marker should not report shell completion") + } + writeTestFile(t, marker, nil) + if !consumeShellMarker(marker) { + t.Fatal("existing marker should report shell completion") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("marker should be removed, stat error = %v", err) + } +} + +func TestShellMarkerNameIsUnique(t *testing.T) { + first, err := shellMarkerName() + if err != nil { + t.Fatalf("shellMarkerName returned error: %v", err) + } + second, err := shellMarkerName() + if err != nil { + t.Fatalf("shellMarkerName returned error: %v", err) + } + if first == second { + t.Fatalf("shell marker names must differ: %q", first) + } +} + func testCACertificate(t *testing.T) []byte { t.Helper() diff --git a/internal/workspace/registry.go b/internal/workspace/registry.go index f9fdbc9..6de94dd 100644 --- a/internal/workspace/registry.go +++ b/internal/workspace/registry.go @@ -162,12 +162,8 @@ func (r Registry) Delete(summary Summary) error { } func (r Registry) createLayout(workspacePath string) error { - // opencode.json and AGENTS.md are mounted read-only from - // ~/.config/opencode-manager at container creation. The OpenCode asset - // directories (agents/, commands/, plugins/, skills/) are seeded into the - // home by ensureWorkspaceOpenCodeAssets during provisioning so they are - // writable by module install scripts; they are not materialized here. Only - // the writable home layout is created. + // Shared OpenCode configuration is copied into this writable directory during + // provisioning. Only the base home layout is created here. dirs := []string{ "home", filepath.Join("home", "workspace"), diff --git a/internal/workspace/registry_test.go b/internal/workspace/registry_test.go index 008b71d..633d2cd 100644 --- a/internal/workspace/registry_test.go +++ b/internal/workspace/registry_test.go @@ -100,8 +100,7 @@ func TestCreateWorkspaceWritesLayoutAndManifest(t *testing.T) { } } - // The OpenCode templates are mounted read-only from the global config - // directory at container creation, not materialized in the workspace. + // Shared OpenCode config is materialized during provisioning, not creation. for _, path := range []string{ filepath.Join("home", ".config", "opencode", "opencode.json"), filepath.Join("home", ".config", "opencode", "agents"), diff --git a/internal/workspace/statusplugin.go b/internal/workspace/statusplugin.go index ee12bbc..6dd198f 100644 --- a/internal/workspace/statusplugin.go +++ b/internal/workspace/statusplugin.go @@ -13,8 +13,8 @@ import ( ) // statusPluginJS is the opencode plugin that reports per-session activity to a -// status file. It is seeded into the global plugins directory and mounted -// read-only into every workspace container. +// status file. It is seeded into the shared plugins directory and copied into +// every workspace container. // //go:embed assets/opencode-manager-status.js var statusPluginJS string @@ -53,7 +53,7 @@ type statusReport struct { } // SeedStatusPlugin writes the manager-owned status-reporter plugin into the -// global plugins template directory, overwriting any previous copy so the +// shared plugins source directory, overwriting any previous copy so the // shipped version is always current. Unlike user templates it is intentionally // overwritten on every startup because the manager owns and versions it. func SeedStatusPlugin() error { @@ -62,7 +62,7 @@ func SeedStatusPlugin() error { return err } - pluginsDir := filepath.Join(dir, "plugins") + pluginsDir := filepath.Join(dir, "opencode", "plugins") if err := os.MkdirAll(pluginsDir, 0o700); err != nil { return fmt.Errorf("create plugins directory %q: %w", pluginsDir, err) } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 7a24fe5..303549c 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -35,6 +35,7 @@ function ensureConfig() { "runtime: docker", "useLocalOpenCodeAuth: false", "extraCACertificate: []", + "workspaceEnv: {}", "baseImage:", " name: docker.io/mroger78/ocm-base:latest", " packages: []",