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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/opencode-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async with OpenCodeRuntime(project_dir="/path/to/repo") as r:

## runtime_dir

When set, each server gets its own isolated `HOME` and config file (`opencode.json`) under `runtime_dir/servers/<key>/`. Without it, OpenCode uses your real user environment and any `config` or `materials` passed to the runtime are ignored.
When set, each server gets its own isolated environment under `runtime_dir`. Without it, OpenCode uses your real user environment and any `config` or `materials` passed to the runtime are ignored.

```python
async with OpenCodeRuntime(runtime_dir=".opencode-runtime") as r:
Expand Down Expand Up @@ -91,4 +91,4 @@ async with OpenCodeRuntime(
print(response.text)
```

Note: `config` and `materials` affect server identity — different values produce a separate server process. `env` does not; if the server is already running, a different `env` passed to `session()` has no effect.
`config`, `materials`, and `env` are applied when the server starts. Changes take effect only after the server is restarted.
4 changes: 2 additions & 2 deletions docs/users-and-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async with OpenCodeRuntime(runtime_dir=".opencode-runtime") as r:
)
```

Different `config` or `materials` produce different server keys — so `acme/alice` and `hobby/bob` run on entirely separate server processes even if `project_dir` is the same.
`acme/alice` and `hobby/bob` have different workspaces, so they always get separate servers with separate history.

## Point each tenant at their own repo

Expand Down Expand Up @@ -120,6 +120,6 @@ Each server runs as a separate OS process with its own isolated directory under
└── tmp/
```

No server can read another's home, config, or history. The directory name is a hash of `(workspace, user_id, project_dir, materials, config)` — same inputs always produce the same directory, so servers survive restarts and resume where they left off.
No server can read another's home, config, or history. Servers survive restarts and resume where they left off.

Isolation is at the process and filesystem level. If your use case allows users to run arbitrary shell commands via the `bash` tool, run each server in its own container for a stronger boundary.
2 changes: 0 additions & 2 deletions src/opencode_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ async def _serve(args: argparse.Namespace) -> None:
workspace=args.workspace,
user_id=args.user_id,
project_dir=project_dir,
materials=materials,
config={},
)

manager = ServerManager()
Expand Down
6 changes: 1 addition & 5 deletions src/opencode_runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ async def session(
) -> OpenCodeSession:
"""Create a session backed by this runtime.

Each unique combination of workspace, user_id, materials, and config
maps to a dedicated OpenCode instance process. The instance is started
on first use and reused for subsequent sessions with the same key.
The instance is started on first use and reused for subsequent calls.

Args:
workspace: Logical tenant/workspace name, e.g. ``"acme"``.
Expand All @@ -119,8 +117,6 @@ async def session(
workspace,
user_id,
self.project_dir,
effective_materials,
effective_config,
)

server_dir = self.runtime_dir / "servers" / key if self.runtime_dir is not None else None
Expand Down
22 changes: 4 additions & 18 deletions src/opencode_runtime/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,27 +150,13 @@ def _compute_runtime_key(
workspace: str | None,
user_id: str | None,
project_dir: Path,
materials: Materials,
config: dict[str, Any],
) -> str:
"""Compute a stable 16-char key for a unique server configuration.

Same inputs always produce the same key. Different inputs (different
workspace, user, materials, or config) produce different keys and
therefore get separate server processes.
"""
"""Compute a stable key from (workspace, user_id, project_dir)."""
payload = "|".join(
[
workspace or "",
user_id or "",
str(project_dir),
repr(
sorted(
str(m)
for m in (materials if isinstance(materials, list) else [materials or ""])
)
),
json.dumps(config, sort_keys=True, default=str),
]
)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
Expand All @@ -179,9 +165,9 @@ def _compute_runtime_key(
class ServerManager:
"""Manages a pool of OpenCode instance processes.

Each unique combination of workspace, user_id, project_dir, materials,
and config gets its own isolated OpenCode instance. Instances are started
on demand and reused when the same key is requested again.
Each unique combination of workspace, user_id, and project_dir gets its
own isolated OpenCode instance. Instances are started on demand and
reused when the same key is requested again.

The registry is the single source of truth for server state — there is
no in-memory cache of it, so every call consults the registry and
Expand Down
4 changes: 1 addition & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,7 @@ def test_serve_stale_entry_cleaned_and_restarted(tmp_path):
from opencode_runtime.server import _compute_runtime_key

# Compute the same key serve will use
key = _compute_runtime_key(
workspace=None, user_id=None, project_dir=Path(tmp_path), materials=None, config={}
)
key = _compute_runtime_key(workspace=None, user_id=None, project_dir=Path(tmp_path))
registry.write(make_entry(key=key, pid=99999999, project_dir=str(tmp_path)))

args = ns(
Expand Down
17 changes: 9 additions & 8 deletions tests/test_multi_tenant.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""
Multi-tenant tests against the real opencode binary.

Verifies that different workspace/user/config/materials combinations
produce isolated server processes, and that same combinations reuse
the same server.
Verifies that different workspace/user combinations produce isolated server
processes, and that same combinations reuse the same server. Config and
materials are not part of server identity — different values for the same
(workspace, user_id, project_dir) reuse the same server.

No model or agent calls — no API keys required.
"""
Expand Down Expand Up @@ -51,13 +52,13 @@ async def test_same_workspace_and_user_reuses_server(self, runtime):
s2 = await runtime.session(workspace="acme", user_id="u_1")
assert s1.raw_client.base_url == s2.raw_client.base_url

async def test_different_config_gets_different_server(self, runtime):
"""Same workspace, different config → different server."""
async def test_different_config_reuses_server(self, runtime):
"""Same workspace, different config → same server (config is not identity)."""
s1 = await runtime.session(workspace="acme", config={"model": "anthropic/claude-haiku-4-5"})
s2 = await runtime.session(
workspace="acme", config={"model": "anthropic/claude-sonnet-4-5"}
)
assert s1.raw_client.base_url != s2.raw_client.base_url
assert s1.raw_client.base_url == s2.raw_client.base_url


class TestServerDirs:
Expand Down Expand Up @@ -92,8 +93,8 @@ async def test_stop_single_tenant(self, tmp_path, monkeypatch):
await r.session(workspace="acme")
await r.session(workspace="beta")

acme_key = _compute_runtime_key("acme", None, Path(tmp_path), None, {})
beta_key = _compute_runtime_key("beta", None, Path(tmp_path), None, {})
acme_key = _compute_runtime_key("acme", None, Path(tmp_path))
beta_key = _compute_runtime_key("beta", None, Path(tmp_path))

acme_entry = registry.read(acme_key)
beta_entry = registry.read(beta_key)
Expand Down
Loading
Loading