Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- A module.yml may declare `key: <prompt>` to become multi-instance: it can be installed several times per workspace (e.g. one `ssh` entry per host, one `aws` entry per profile), each distinguished by that prompt's value. The instance identity is `name:keyvalue` (`ModuleInstance.InstanceID`); the key prompt must be required and non-secret. Without `key` a module is a singleton and a second install replaces the first. The install/uninstall scripts must be instance-scoped by the key (e.g. ssh writes `~/.ssh/config.d/$OCM_HOST.conf`) so multiple entries coexist and each uninstall removes only its own.
- 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`.
- A module.yml may declare `mounts` (`source` absolute or `~/`-relative on the host, absolute `target`, `readOnly`, `optional`). They are bind-mounted only into the containers of workspaces that have the module installed: `provision` collects them from the manifest's modules (`installedModuleMounts`), creates their mount points inside the workspace home on the host, and records a fingerprint in `OCM_MODULE_MOUNTS_SHA256`, so `containerSpecDrift` recreates the container when the set changes. `AddModule` records such a module in the manifest before starting the container, so its install runs with the mounts in place; on failure the manifest entry is rolled back and the container recreated without the mounts; `RemoveModule` recreates the container after the uninstall. A module with mounts cannot be `restartServer: false`, since recreation interrupts a running task. An `optional` mount with a missing source is skipped; a required one fails the start.
- Workspace containers are long-lived and tied to the workspace lifecycle.
- 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.
- Opt-in `selfImprovement.enabled` creates an OpenCode-only internal instance under `<workspaceRoot>/internal/self-improvement`, outside `Registry.List`. `ocm improve` or dashboard `i` launches a fresh local OpenCode TUI with an analysis prompt. It mounts the entire manager configuration read-write, ordinary workspace homes read-only, and named `selfImprovement.directories` roots. Its port still participates in allocation. Agents/commands/helpers are manager-owned; personal `self-improvement/AGENTS.md` extends or replaces the built-in protocol. `sessions.ts` enforces a persistent initial baseline, incremental chunk budgets and checkpoints; `memory.ts` retains cumulative evidence and decisions and applies accepted proposals with hash checks/backups, independently of Git. See `docs/self-improvement.md`; regressions run with `bun test internal/workspace/improvement_sessions.test.ts` (also part of `npm test`).
Expand Down
39 changes: 39 additions & 0 deletions docs/writing-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ prompts:
| `restartServer` | `true` (default) if the module touches `~/.env`; `false` if it only writes its own config files. |
| `key` | Name of a prompt that makes the module **multi-instance** (one install per distinct value). |
| `prompts` | The values to collect from the user. |
| `mounts` | Optional host paths bind-mounted into the containers of the workspaces that have the module (see [Mounts](#mounts)). |

### Prompt types

Expand Down Expand Up @@ -101,6 +102,42 @@ and the editor blocks edits while a task is running. If your module only writes
its own config files that tools read live (like `~/.aws` or `~/.kube/config`),
set `restartServer: false` — it is never bounced and can be edited mid-task.

## Mounts

A module can bind-mount host paths into the container of each workspace it is
installed in — and only those. Unlike the global
[`extraMounts`](configuration.md#extramounts), this lets you pick per workspace
which ones get access to a host file, such as a login shared with the host
instead of copied:

```yaml
name: claude-auth
version: 1
description: Share this host's Claude Code login with the workspace.
mounts:
- source: ~/.claude/.credentials.json
target: /home/debian/.claude/.credentials.json
optional: true
```

| Field | Meaning |
| --- | --- |
| `source` | Host path: absolute, or starting with `~/` for the host user's home. |
| `target` | Absolute path inside the container. Cannot be `/` or the workspace home itself. |
| `readOnly` | `true` to prevent container writes. Default `false`. |
| `optional` | `true` to skip the mount while `source` does not exist instead of failing the workspace start; it is added on the first start after it appears. |

The mounts are part of the container's spec, so adding or removing such a
module **recreates the workspace container** (the home and the other modules
are preserved and reinstalled). On add, the module is recorded and the container
recreated before `install` runs, so the script already sees the mounted paths; on
removal the container is recreated after `uninstall`, so access ends right away.
Because recreation interrupts a running task, a module with mounts cannot set
`restartServer: false`.

Mount points inside the workspace home are created on the host first (an empty
file or a directory, owned by you), so the runtime never creates them as root.

## Host hooks (optional)

Two optional hooks run **on the host** (with full host access), as part of an
Expand Down Expand Up @@ -138,5 +175,7 @@ leaving any manually-typed values in place.
- [ ] `uninstall` fully reverses `install` for that instance.
- [ ] Secrets resolved on the host via `resolve` rather than stored in the
manifest, where applicable.
- [ ] `mounts` only when the container must use the host path itself (live,
shared state) rather than a copy made by `resolve`.
- [ ] Tested by adding/removing the module on a running workspace from the
editor.
59 changes: 59 additions & 0 deletions internal/module/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@ type Module struct {
// lives under (e.g. "cloud" for modules/cloud/aws). It organizes the module
// browser in the TUI and is mirrored in the container mount path.
Category string
// Mounts are host paths bind-mounted into the container of every workspace
// that has this module installed, and only those. Adding or removing such a
// module recreates the workspace container, so it always restarts the server.
Mounts []Mount
}

// Mount is a host path a module bind-mounts into the workspace container.
type Mount struct {
// Source is the host path: absolute, or relative to the host user's home
// when it starts with "~/".
Source string `yaml:"source"`
// Target is the absolute path inside the container.
Target string `yaml:"target"`
ReadOnly bool `yaml:"readOnly"`
// Optional skips the mount while Source does not exist on the host instead
// of failing the workspace start. The mount is added on the first start
// after it appears.
Optional bool `yaml:"optional"`
}

// HostSource returns Source with a leading "~/" expanded to the host user's
// home directory.
func (m Mount) HostSource() (string, error) {
if !strings.HasPrefix(m.Source, "~/") {
return m.Source, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find user home directory: %w", err)
}
return filepath.Join(home, m.Source[2:]), nil
}

// Multi reports whether the module can be installed as multiple independent
Expand Down Expand Up @@ -136,6 +167,7 @@ type definition struct {
Key string `yaml:"key"`
RestartServer *bool `yaml:"restartServer"`
Prompts []Prompt `yaml:"prompts"`
Mounts []Mount `yaml:"mounts"`
}

// HasResolveHook reports whether the module ships a host-side resolve script.
Expand Down Expand Up @@ -163,6 +195,7 @@ func Load(dir string) (Module, error) {
Prompts: def.Prompts,
Key: def.Key,
RestartServer: def.RestartServer == nil || *def.RestartServer,
Mounts: def.Mounts,
Dir: dir,
// The category is the name of the directory the module lives under, e.g.
// modules/cloud/aws -> "cloud".
Expand All @@ -172,6 +205,11 @@ func Load(dir string) (Module, error) {
if err := mod.validate(); err != nil {
return Module{}, fmt.Errorf("invalid module %q: %w", path, err)
}
// A module with mounts recreates the container on add and remove, which
// interrupts a running task just like a server bounce does.
if len(mod.Mounts) > 0 && !mod.RestartServer {
return Module{}, fmt.Errorf("invalid module %q: a module with mounts cannot set restartServer: false (adding or removing it recreates the container)", path)
}

return mod, nil
}
Expand Down Expand Up @@ -253,6 +291,27 @@ func (m Module) validate() error {
}
}

targets := map[string]bool{}
for _, mount := range m.Mounts {
if mount.Source == "" || mount.Target == "" {
return fmt.Errorf("mount source and target are required")
}
if !filepath.IsAbs(mount.Source) && !strings.HasPrefix(mount.Source, "~/") {
return fmt.Errorf("mount source %q must be absolute or start with ~/", mount.Source)
}
if !filepath.IsAbs(mount.Target) {
return fmt.Errorf("mount target %q must be absolute", mount.Target)
}
target := filepath.Clean(mount.Target)
if target == "/" || target == "/home/debian" {
return fmt.Errorf("mount target %q cannot replace %s", mount.Target, target)
}
if targets[target] {
return fmt.Errorf("mount target %q is duplicated", mount.Target)
}
targets[target] = true
}

if err := checkExecutable(filepath.Join(m.Dir, InstallScript)); err != nil {
return err
}
Expand Down
54 changes: 54 additions & 0 deletions internal/module/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,57 @@ func TestBuiltinModulesAreValid(t *testing.T) {
t.Fatal("kubernetes context prompt should expose an optionsCommand for the import picker")
}
}

func TestLoadMounts(t *testing.T) {
root := t.TempDir()
dir := writeModule(t, root, "claude", `name: claude
version: 1
mounts:
- source: ~/.claude/.credentials.json
target: /home/debian/.claude/.credentials.json
optional: true
- { source: /etc/hosts, target: /etc/host-hosts, readOnly: true }
`)

mod, err := Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(mod.Mounts) != 2 || !mod.Mounts[0].Optional || !mod.Mounts[1].ReadOnly {
t.Fatalf("unexpected mounts: %+v", mod.Mounts)
}
if !mod.RestartServer {
t.Fatal("a module with mounts must restart the server")
}

home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
source, err := mod.Mounts[0].HostSource()
if err != nil {
t.Fatalf("HostSource: %v", err)
}
if want := filepath.Join(home, ".claude/.credentials.json"); source != want {
t.Fatalf("HostSource = %q, want %q", source, want)
}
}

func TestLoadRejectsInvalidMounts(t *testing.T) {
cases := map[string]string{
"relative source": "mounts: [{ source: foo, target: /a }]",
"relative target": "mounts: [{ source: /foo, target: a }]",
"missing target": "mounts: [{ source: /foo }]",
"home target": "mounts: [{ source: /foo, target: /home/debian/ }]",
"duplicate target": "mounts: [{ source: /foo, target: /a }, { source: /bar, target: /a }]",
"no server restart": "restartServer: false\nmounts: [{ source: /foo, target: /a }]",
}
for name, extra := range cases {
t.Run(name, func(t *testing.T) {
dir := writeModule(t, t.TempDir(), "m", "name: m\nversion: 1\n"+extra+"\n")
if _, err := Load(dir); err == nil {
t.Fatal("expected an error")
}
})
}
}
15 changes: 15 additions & 0 deletions internal/workspace/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,12 @@ func (l Lifecycle) provisionWithOptions(ctx context.Context, summary Summary, re
// scripts are runnable inside the container.
mounts = append(mounts, moduleMounts(l.cfg)...)
mounts = append(mounts, extraMounts(l.cfg)...)
// Mounts declared by the modules installed in this workspace only.
installedMounts, installedMountsFingerprint, err := l.installedModuleMounts(manifest)
if err != nil {
return runtime.StatusUnknown, runtime.ContainerSpec{}, err
}
mounts = append(mounts, installedMounts...)
if l.isImprovement(summary) {
internalMounts, err := l.improvementMounts()
if err != nil {
Expand Down Expand Up @@ -417,6 +423,9 @@ func (l Lifecycle) provisionWithOptions(ctx context.Context, summary Summary, re
if fingerprint := extraMountsFingerprint(l.cfg.ExtraMounts); fingerprint != "" {
env[extraMountsFingerprintEnv] = fingerprint
}
if installedMountsFingerprint != "" {
env[moduleMountsFingerprintEnv] = installedMountsFingerprint
}
if l.isImprovement(summary) {
privateMounts := make([]config.ExtraMount, 0, len(mounts))
for _, mount := range mounts {
Expand Down Expand Up @@ -557,6 +566,7 @@ const openCodeAuthRelPath = ".local/share/opencode/auth.json"
const (
extraCACertificateFingerprintEnv = "OCM_EXTRA_CA_CERTIFICATE_SHA256"
extraMountsFingerprintEnv = "OCM_EXTRA_MOUNTS_SHA256"
moduleMountsFingerprintEnv = "OCM_MODULE_MOUNTS_SHA256"
workspaceEnvKeysEnv = "OCM_WORKSPACE_ENV_KEYS"
)

Expand Down Expand Up @@ -892,6 +902,11 @@ func (l Lifecycle) containerSpecDrift(ctx context.Context, manifest Manifest, sp
return true
}

if rc.Env[moduleMountsFingerprintEnv] != spec.Env[moduleMountsFingerprintEnv] {
slog.Debug("container module mounts differ from desired", "workspace", manifest.Name, "container", manifest.ContainerName)
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
Expand Down
12 changes: 12 additions & 0 deletions internal/workspace/lifecycle_drift_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ func TestContainerSpecDrift(t *testing.T) {
spec: runtime.ContainerSpec{Env: map[string]string{extraMountsFingerprintEnv: "new"}},
want: true,
},
{
name: "module mount added",
rc: runtime.ContainerRuntimeConfig{NetworkMode: "bridge", Env: map[string]string{OpenCodePortEnv: "4097"}},
spec: runtime.ContainerSpec{Env: map[string]string{moduleMountsFingerprintEnv: "new"}},
want: true,
},
{
name: "module mount removed",
rc: runtime.ContainerRuntimeConfig{NetworkMode: "bridge", Env: map[string]string{OpenCodePortEnv: "4097", moduleMountsFingerprintEnv: "old"}},
spec: runtime.ContainerSpec{Env: map[string]string{}},
want: true,
},
{
name: "workspace environment changed",
rc: runtime.ContainerRuntimeConfig{NetworkMode: "bridge", Env: map[string]string{OpenCodePortEnv: "4097", workspaceEnvKeysEnv: "API_TOKEN", "API_TOKEN": "old"}},
Expand Down
Loading
Loading