py-opencode-wrapper 0.1.5__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/PKG-INFO +19 -1
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/README.md +18 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/opencode_wrapper/client.py +125 -9
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/opencode_wrapper/config.py +102 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/PKG-INFO +19 -1
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/SOURCES.txt +2 -1
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/pyproject.toml +1 -1
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_client_async.py +17 -0
- py_opencode_wrapper-0.2.1/tests/test_user_config_isolation.py +442 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/opencode_wrapper/__init__.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/opencode_wrapper/errors.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/opencode_wrapper/events.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/requires.txt +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/setup.cfg +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_config_instructions.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_config_permission.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_event_parser.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_external_directory.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_instructions.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_multi_agent_weather.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_opencode.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_parallel.py +0 -0
- {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_run_result_fuzzy_text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-opencode-wrapper
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
|
|
5
5
|
Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
|
|
6
6
|
Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
|
|
@@ -104,11 +104,29 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
|
|
|
104
104
|
| `permission` | `permission` map (`allow` / `deny`, patterns) |
|
|
105
105
|
| `mcp` | MCP server definitions |
|
|
106
106
|
| `tools` | Enable/disable tools (including MCP globs) |
|
|
107
|
+
| `instructions` | Instruction file paths / glob patterns to inject |
|
|
107
108
|
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
108
109
|
|
|
109
110
|
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
110
111
|
Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
|
|
111
112
|
|
|
113
|
+
### User config isolation
|
|
114
|
+
|
|
115
|
+
By default, `RunConfig.inherit_user_config=False` makes each child `opencode`
|
|
116
|
+
process see a sanitized copy of the host's global OpenCode config. The wrapper
|
|
117
|
+
keeps only provider-selection keys (`$schema`, `provider`,
|
|
118
|
+
`disabled_providers`, `enabled_providers`) and drops capability/configuration
|
|
119
|
+
keys such as `mcp`, `agent`, `command`, `tools`, `plugin`, `skills`,
|
|
120
|
+
`instructions`, `permission`, and `model`.
|
|
121
|
+
|
|
122
|
+
This keeps benchmark and orchestration runs reproducible while still allowing
|
|
123
|
+
provider configuration and `opencode auth` credentials to work. Project-level
|
|
124
|
+
config discovered from the workspace is not suppressed.
|
|
125
|
+
|
|
126
|
+
Set `inherit_user_config=True` to restore the legacy behavior of inheriting the
|
|
127
|
+
host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
128
|
+
`mcp`, `tools`, and `instructions` explicitly through `RunConfig`.
|
|
129
|
+
|
|
112
130
|
## CLI arguments
|
|
113
131
|
|
|
114
132
|
`RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
|
|
@@ -91,11 +91,29 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
|
|
|
91
91
|
| `permission` | `permission` map (`allow` / `deny`, patterns) |
|
|
92
92
|
| `mcp` | MCP server definitions |
|
|
93
93
|
| `tools` | Enable/disable tools (including MCP globs) |
|
|
94
|
+
| `instructions` | Instruction file paths / glob patterns to inject |
|
|
94
95
|
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
95
96
|
|
|
96
97
|
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
97
98
|
Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
|
|
98
99
|
|
|
100
|
+
### User config isolation
|
|
101
|
+
|
|
102
|
+
By default, `RunConfig.inherit_user_config=False` makes each child `opencode`
|
|
103
|
+
process see a sanitized copy of the host's global OpenCode config. The wrapper
|
|
104
|
+
keeps only provider-selection keys (`$schema`, `provider`,
|
|
105
|
+
`disabled_providers`, `enabled_providers`) and drops capability/configuration
|
|
106
|
+
keys such as `mcp`, `agent`, `command`, `tools`, `plugin`, `skills`,
|
|
107
|
+
`instructions`, `permission`, and `model`.
|
|
108
|
+
|
|
109
|
+
This keeps benchmark and orchestration runs reproducible while still allowing
|
|
110
|
+
provider configuration and `opencode auth` credentials to work. Project-level
|
|
111
|
+
config discovered from the workspace is not suppressed.
|
|
112
|
+
|
|
113
|
+
Set `inherit_user_config=True` to restore the legacy behavior of inheriting the
|
|
114
|
+
host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
115
|
+
`mcp`, `tools`, and `instructions` explicitly through `RunConfig`.
|
|
116
|
+
|
|
99
117
|
## CLI arguments
|
|
100
118
|
|
|
101
119
|
`RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
7
8
|
import os
|
|
8
9
|
import shutil
|
|
9
10
|
import tempfile
|
|
@@ -12,6 +13,7 @@ from pathlib import Path
|
|
|
12
13
|
from typing import Any, AsyncIterator, Mapping
|
|
13
14
|
|
|
14
15
|
from opencode_wrapper.config import RunConfig, validate_config_for_run
|
|
16
|
+
from opencode_wrapper.config import loads_jsonc, sanitize_user_config_json
|
|
15
17
|
from opencode_wrapper.errors import (
|
|
16
18
|
OpenCodeBinaryNotFoundError,
|
|
17
19
|
OpenCodeProcessError,
|
|
@@ -79,7 +81,12 @@ def build_argv(
|
|
|
79
81
|
return cmd
|
|
80
82
|
|
|
81
83
|
|
|
82
|
-
def build_env(
|
|
84
|
+
def build_env(
|
|
85
|
+
run_cfg: RunConfig,
|
|
86
|
+
base: Mapping[str, str] | None = None,
|
|
87
|
+
*,
|
|
88
|
+
cwd: str | Path | None = None,
|
|
89
|
+
) -> dict[str, str]:
|
|
83
90
|
env = dict(base if base is not None else os.environ)
|
|
84
91
|
if run_cfg.extra_env:
|
|
85
92
|
env.update(dict(run_cfg.extra_env))
|
|
@@ -88,6 +95,14 @@ def build_env(run_cfg: RunConfig, base: Mapping[str, str] | None = None) -> dict
|
|
|
88
95
|
env["OPENCODE_CONFIG_CONTENT"] = content
|
|
89
96
|
if run_cfg.disable_autoupdate:
|
|
90
97
|
env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"
|
|
98
|
+
# opencode's `run` cmd resolves the project root as
|
|
99
|
+
# `process.env.PWD ?? process.cwd()` (run.ts:276), and the bash builtin
|
|
100
|
+
# `pwd` reads $PWD too. asyncio.create_subprocess_exec(cwd=...) only
|
|
101
|
+
# chdirs the child; it leaves PWD inherited from the parent shell, which
|
|
102
|
+
# makes opencode operate against the wrong directory. Pin PWD to the
|
|
103
|
+
# resolved workspace so the child sees a consistent cwd.
|
|
104
|
+
if cwd is not None:
|
|
105
|
+
env["PWD"] = str(cwd)
|
|
91
106
|
return env
|
|
92
107
|
|
|
93
108
|
|
|
@@ -158,6 +173,96 @@ def _is_sqlite_startup_error(stderr: str) -> bool:
|
|
|
158
173
|
return any(pat in lower for pat in _SQLITE_STARTUP_PATTERNS)
|
|
159
174
|
|
|
160
175
|
|
|
176
|
+
_LOG = logging.getLogger(__name__)
|
|
177
|
+
|
|
178
|
+
# Filenames opencode reads from its global config dir + ~/.opencode.
|
|
179
|
+
# Legacy TOML ("config" without extension) is intentionally skipped — it's rare
|
|
180
|
+
# and would require a TOML parser dep; the wrapper aims for stdlib-only.
|
|
181
|
+
_GLOBAL_CONFIG_FILENAMES: tuple[str, ...] = (
|
|
182
|
+
"config.json",
|
|
183
|
+
"opencode.json",
|
|
184
|
+
"opencode.jsonc",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_real_xdg_config_opencode_dir(env: Mapping[str, str]) -> Path:
|
|
189
|
+
xdg = env.get("XDG_CONFIG_HOME")
|
|
190
|
+
base = Path(xdg).expanduser() if xdg else Path(env.get("HOME", str(Path.home()))).expanduser() / ".config"
|
|
191
|
+
return base / "opencode"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _resolve_real_home_opencode_dir(env: Mapping[str, str]) -> Path:
|
|
195
|
+
return Path(env.get("HOME", str(Path.home()))).expanduser() / ".opencode"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _sanitize_and_copy(src_dir: Path, dst_dir: Path) -> None:
|
|
199
|
+
"""Read each known opencode config file in *src_dir*, sanitize, write to *dst_dir*.
|
|
200
|
+
|
|
201
|
+
Strict JSON files parse on the fast path; JSONC (comments, trailing commas)
|
|
202
|
+
falls back to ``loads_jsonc``. Files that aren't valid in either form are
|
|
203
|
+
skipped with a warning — for benchmark reproducibility the wrapper would
|
|
204
|
+
rather hide a file it can't safely strip than risk leaking capability keys.
|
|
205
|
+
Missing source files are silently skipped. ``dst_dir`` is always created
|
|
206
|
+
so opencode finds the directory (even if empty).
|
|
207
|
+
"""
|
|
208
|
+
from opencode_wrapper.config import sanitize_user_config_json
|
|
209
|
+
|
|
210
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
if not src_dir.is_dir():
|
|
212
|
+
return
|
|
213
|
+
for fname in _GLOBAL_CONFIG_FILENAMES:
|
|
214
|
+
src = src_dir / fname
|
|
215
|
+
if not src.is_file():
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
text = src.read_text(encoding="utf-8")
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
_LOG.warning("user-config isolation: cannot read %s: %s", src, exc)
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
raw = loads_jsonc(text)
|
|
224
|
+
except json.JSONDecodeError as exc:
|
|
225
|
+
_LOG.warning(
|
|
226
|
+
"user-config isolation: skipping %s (not parseable as JSON or JSONC): %s",
|
|
227
|
+
src, exc,
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
if not isinstance(raw, dict):
|
|
231
|
+
_LOG.warning(
|
|
232
|
+
"user-config isolation: skipping %s (root is not a JSON object)", src
|
|
233
|
+
)
|
|
234
|
+
continue
|
|
235
|
+
sanitized = sanitize_user_config_json(raw)
|
|
236
|
+
(dst_dir / fname).write_text(
|
|
237
|
+
json.dumps(sanitized, ensure_ascii=False), encoding="utf-8"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _isolate_user_config(env: dict[str, str], tmp_root: Path) -> dict[str, str]:
|
|
242
|
+
"""Mutate *env* so opencode sees a sanitized copy of the user's global config.
|
|
243
|
+
|
|
244
|
+
Reads the real ``$XDG_CONFIG_HOME/opencode`` and ``$HOME/.opencode``,
|
|
245
|
+
filters each file through ``sanitize_user_config_json`` (keeping only
|
|
246
|
+
``provider`` / ``disabled_providers`` / ``enabled_providers`` / ``$schema``),
|
|
247
|
+
writes the results under *tmp_root*, and points ``XDG_CONFIG_HOME`` /
|
|
248
|
+
``OPENCODE_TEST_HOME`` at those tmpdir locations. Strips
|
|
249
|
+
``OPENCODE_CONFIG`` / ``OPENCODE_CONFIG_DIR`` so the parent shell can't
|
|
250
|
+
re-introduce extras. Project-level config (cwd walk + ``.opencode/``)
|
|
251
|
+
is untouched.
|
|
252
|
+
"""
|
|
253
|
+
iso_xdg = tmp_root / "xdg"
|
|
254
|
+
iso_home = tmp_root / "home"
|
|
255
|
+
|
|
256
|
+
_sanitize_and_copy(_resolve_real_xdg_config_opencode_dir(env), iso_xdg / "opencode")
|
|
257
|
+
_sanitize_and_copy(_resolve_real_home_opencode_dir(env), iso_home / ".opencode")
|
|
258
|
+
|
|
259
|
+
env["XDG_CONFIG_HOME"] = str(iso_xdg)
|
|
260
|
+
env["OPENCODE_TEST_HOME"] = str(iso_home)
|
|
261
|
+
env.pop("OPENCODE_CONFIG", None)
|
|
262
|
+
env.pop("OPENCODE_CONFIG_DIR", None)
|
|
263
|
+
return env
|
|
264
|
+
|
|
265
|
+
|
|
161
266
|
class AsyncOpenCodeClient:
|
|
162
267
|
"""
|
|
163
268
|
One-shot async wrapper around the OpenCode CLI.
|
|
@@ -207,13 +312,16 @@ class AsyncOpenCodeClient:
|
|
|
207
312
|
argv: list[str],
|
|
208
313
|
cwd: str,
|
|
209
314
|
env: dict[str, str],
|
|
315
|
+
run_cfg: RunConfig,
|
|
210
316
|
) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
|
|
211
317
|
stderr_lines: list[str] = []
|
|
318
|
+
cleanup_tmpdirs: list[str] = []
|
|
212
319
|
# Give each process its own XDG_DATA_HOME so opencode.db is isolated.
|
|
213
320
|
# Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
|
|
214
321
|
# and SQLite write locks during tool execution serialize the runs (37–46s delays).
|
|
215
322
|
if self._isolate_db:
|
|
216
323
|
xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
|
|
324
|
+
cleanup_tmpdirs.append(xdg_tmpdir)
|
|
217
325
|
# Symlink auth.json so provider API keys (stored by `opencode auth`)
|
|
218
326
|
# are visible in the isolated data dir. Without this, providers
|
|
219
327
|
# that rely on auth.json (rather than env-var keys) fail with
|
|
@@ -225,8 +333,16 @@ class AsyncOpenCodeClient:
|
|
|
225
333
|
iso_oc_dir.mkdir(parents=True, exist_ok=True)
|
|
226
334
|
(iso_oc_dir / "auth.json").symlink_to(real_auth)
|
|
227
335
|
env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
|
|
228
|
-
|
|
229
|
-
|
|
336
|
+
# When the caller has not opted into host-config inheritance (the
|
|
337
|
+
# default), redirect XDG_CONFIG_HOME / OPENCODE_TEST_HOME at a sanitized
|
|
338
|
+
# tmpdir copy of the user's global config — only provider settings are
|
|
339
|
+
# carried over, all capability keys (mcp / agent / command / tools /
|
|
340
|
+
# plugin / skills / instructions / permission / model / ...) are stripped.
|
|
341
|
+
# Project-level config (cwd walk + .opencode/) is untouched.
|
|
342
|
+
if not run_cfg.inherit_user_config:
|
|
343
|
+
cfg_tmpdir = tempfile.mkdtemp(prefix="oc_cfg_")
|
|
344
|
+
cleanup_tmpdirs.append(cfg_tmpdir)
|
|
345
|
+
env = _isolate_user_config(dict(env), Path(cfg_tmpdir))
|
|
230
346
|
# Serialise process startup to avoid the SQLite WAL-pragma race.
|
|
231
347
|
# The semaphore is released as soon as the startup window has elapsed,
|
|
232
348
|
# so all processes run concurrently after their individual delay.
|
|
@@ -257,8 +373,8 @@ class AsyncOpenCodeClient:
|
|
|
257
373
|
await stderr_task
|
|
258
374
|
except asyncio.CancelledError:
|
|
259
375
|
pass
|
|
260
|
-
|
|
261
|
-
shutil.rmtree(
|
|
376
|
+
for path in cleanup_tmpdirs:
|
|
377
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
262
378
|
|
|
263
379
|
async def async_stream(
|
|
264
380
|
self,
|
|
@@ -277,13 +393,13 @@ class AsyncOpenCodeClient:
|
|
|
277
393
|
validate_config_for_run(run_cfg)
|
|
278
394
|
bin_path = self.resolved_binary()
|
|
279
395
|
argv = build_argv(bin_path, prompt, run_cfg)
|
|
280
|
-
env = build_env(run_cfg)
|
|
281
396
|
cwd = str(Path(workspace).expanduser().resolve())
|
|
397
|
+
env = build_env(run_cfg, cwd=cwd)
|
|
282
398
|
|
|
283
399
|
events_acc: list[dict[str, Any]] = []
|
|
284
400
|
raw_acc: list[str] = []
|
|
285
401
|
|
|
286
|
-
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
402
|
+
async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
|
|
287
403
|
async for line, ev in _stdout_line_event_iter(proc):
|
|
288
404
|
raw_acc.append(line)
|
|
289
405
|
events_acc.append(ev)
|
|
@@ -333,15 +449,15 @@ class AsyncOpenCodeClient:
|
|
|
333
449
|
validate_config_for_run(run_cfg)
|
|
334
450
|
bin_path = self.resolved_binary()
|
|
335
451
|
argv = build_argv(bin_path, prompt, run_cfg)
|
|
336
|
-
env = build_env(run_cfg)
|
|
337
452
|
cwd = str(Path(workspace).expanduser().resolve())
|
|
453
|
+
env = build_env(run_cfg, cwd=cwd)
|
|
338
454
|
|
|
339
455
|
events_acc: list[dict[str, Any]] = []
|
|
340
456
|
raw_acc: list[str] = []
|
|
341
457
|
|
|
342
458
|
log_fh = open(log_file, "w") if log_file is not None else None
|
|
343
459
|
try:
|
|
344
|
-
async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
|
|
460
|
+
async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
|
|
345
461
|
async for line, ev in _stdout_line_event_iter(proc):
|
|
346
462
|
raw_acc.append(line)
|
|
347
463
|
events_acc.append(ev)
|
|
@@ -13,6 +13,107 @@ PermissionAction = str # "allow" | "ask" | "deny"
|
|
|
13
13
|
# Nested permission maps: tool name -> action or pattern -> action
|
|
14
14
|
PermissionMap = Dict[str, Any]
|
|
15
15
|
|
|
16
|
+
# Top-level keys retained from the host's global opencode config when
|
|
17
|
+
# inherit_user_config=False. Everything else (mcp / agent / command / skills /
|
|
18
|
+
# plugin / tools / instructions / permission / model / ...) is dropped to make
|
|
19
|
+
# benchmark runs reproducible.
|
|
20
|
+
PRESERVE_KEYS: frozenset[str] = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"$schema",
|
|
23
|
+
"provider",
|
|
24
|
+
"disabled_providers",
|
|
25
|
+
"enabled_providers",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sanitize_user_config_json(raw: Mapping[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""Filter a parsed opencode config dict to the benchmark-safe preserve set.
|
|
32
|
+
|
|
33
|
+
Only ``PRESERVE_KEYS`` survive; everything else — including capability keys
|
|
34
|
+
like ``mcp`` / ``agent`` / ``command`` / ``tools`` / ``plugin`` / ``skills``
|
|
35
|
+
and the default ``model`` — is removed. Benchmark callers re-inject the
|
|
36
|
+
bits they need via ``RunConfig``.
|
|
37
|
+
"""
|
|
38
|
+
return {k: v for k, v in raw.items() if k in PRESERVE_KEYS}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strip_jsonc(source: str) -> str:
|
|
42
|
+
"""Convert a JSONC source string to strict JSON.
|
|
43
|
+
|
|
44
|
+
Supports the JSONC superset accepted by opencode (and VS Code's
|
|
45
|
+
jsonc-parser): ``//`` line comments, ``/* ... */`` block comments, and
|
|
46
|
+
trailing commas in objects/arrays. Strings — including ones that contain
|
|
47
|
+
``//`` or ``/*`` — are preserved verbatim, with backslash escape sequences
|
|
48
|
+
handled. Unterminated block comments swallow the rest of the input.
|
|
49
|
+
|
|
50
|
+
Stdlib-only — keeps the wrapper's zero-runtime-deps invariant.
|
|
51
|
+
"""
|
|
52
|
+
out: list[str] = []
|
|
53
|
+
i = 0
|
|
54
|
+
n = len(source)
|
|
55
|
+
while i < n:
|
|
56
|
+
c = source[i]
|
|
57
|
+
# JSON string: copy verbatim, handle escapes
|
|
58
|
+
if c == '"':
|
|
59
|
+
out.append(c)
|
|
60
|
+
i += 1
|
|
61
|
+
while i < n:
|
|
62
|
+
cc = source[i]
|
|
63
|
+
out.append(cc)
|
|
64
|
+
if cc == "\\" and i + 1 < n:
|
|
65
|
+
out.append(source[i + 1])
|
|
66
|
+
i += 2
|
|
67
|
+
continue
|
|
68
|
+
i += 1
|
|
69
|
+
if cc == '"':
|
|
70
|
+
break
|
|
71
|
+
continue
|
|
72
|
+
# Line comment: drop through newline (keep newline for line accuracy)
|
|
73
|
+
if c == "/" and i + 1 < n and source[i + 1] == "/":
|
|
74
|
+
nl = source.find("\n", i + 2)
|
|
75
|
+
i = n if nl == -1 else nl
|
|
76
|
+
continue
|
|
77
|
+
# Block comment: drop through closing */
|
|
78
|
+
if c == "/" and i + 1 < n and source[i + 1] == "*":
|
|
79
|
+
end = source.find("*/", i + 2)
|
|
80
|
+
i = n if end == -1 else end + 2
|
|
81
|
+
continue
|
|
82
|
+
# Possible trailing comma: peek ahead past whitespace/comments
|
|
83
|
+
if c == ",":
|
|
84
|
+
j = i + 1
|
|
85
|
+
while j < n:
|
|
86
|
+
cj = source[j]
|
|
87
|
+
if cj.isspace():
|
|
88
|
+
j += 1
|
|
89
|
+
elif cj == "/" and j + 1 < n and source[j + 1] == "/":
|
|
90
|
+
nl = source.find("\n", j + 2)
|
|
91
|
+
j = n if nl == -1 else nl
|
|
92
|
+
elif cj == "/" and j + 1 < n and source[j + 1] == "*":
|
|
93
|
+
end = source.find("*/", j + 2)
|
|
94
|
+
j = n if end == -1 else end + 2
|
|
95
|
+
else:
|
|
96
|
+
break
|
|
97
|
+
if j < n and source[j] in "}]":
|
|
98
|
+
i += 1 # drop the trailing comma
|
|
99
|
+
continue
|
|
100
|
+
out.append(c)
|
|
101
|
+
i += 1
|
|
102
|
+
return "".join(out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def loads_jsonc(source: str) -> Any:
|
|
106
|
+
"""Parse a JSON or JSONC string, falling back to JSONC stripping on failure.
|
|
107
|
+
|
|
108
|
+
The fast path is plain ``json.loads`` — most files are strict JSON.
|
|
109
|
+
On failure we strip JSONC syntax and retry once. The caller catches
|
|
110
|
+
``json.JSONDecodeError`` if even the stripped form is invalid.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(source)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return json.loads(strip_jsonc(source))
|
|
116
|
+
|
|
16
117
|
|
|
17
118
|
def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
18
119
|
out = dict(base)
|
|
@@ -50,6 +151,7 @@ class RunConfig:
|
|
|
50
151
|
print_logs: bool | None = None
|
|
51
152
|
log_level: str | None = None
|
|
52
153
|
disable_autoupdate: bool = True
|
|
154
|
+
inherit_user_config: bool = False
|
|
53
155
|
extra_env: Mapping[str, str] | None = None
|
|
54
156
|
# Injected as JSON via OPENCODE_CONFIG_CONTENT (merged with config_overrides)
|
|
55
157
|
permission: PermissionMap | None = None
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-opencode-wrapper
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
|
|
5
5
|
Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
|
|
6
6
|
Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
|
|
@@ -104,11 +104,29 @@ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode c
|
|
|
104
104
|
| `permission` | `permission` map (`allow` / `deny`, patterns) |
|
|
105
105
|
| `mcp` | MCP server definitions |
|
|
106
106
|
| `tools` | Enable/disable tools (including MCP globs) |
|
|
107
|
+
| `instructions` | Instruction file paths / glob patterns to inject |
|
|
107
108
|
| `config_overrides` | Any extra top-level config keys to deep-merge |
|
|
108
109
|
|
|
109
110
|
Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
|
110
111
|
Note: `ask` is intentionally rejected in subprocess mode (no interactive terminal); use `allow` or `deny`.
|
|
111
112
|
|
|
113
|
+
### User config isolation
|
|
114
|
+
|
|
115
|
+
By default, `RunConfig.inherit_user_config=False` makes each child `opencode`
|
|
116
|
+
process see a sanitized copy of the host's global OpenCode config. The wrapper
|
|
117
|
+
keeps only provider-selection keys (`$schema`, `provider`,
|
|
118
|
+
`disabled_providers`, `enabled_providers`) and drops capability/configuration
|
|
119
|
+
keys such as `mcp`, `agent`, `command`, `tools`, `plugin`, `skills`,
|
|
120
|
+
`instructions`, `permission`, and `model`.
|
|
121
|
+
|
|
122
|
+
This keeps benchmark and orchestration runs reproducible while still allowing
|
|
123
|
+
provider configuration and `opencode auth` credentials to work. Project-level
|
|
124
|
+
config discovered from the workspace is not suppressed.
|
|
125
|
+
|
|
126
|
+
Set `inherit_user_config=True` to restore the legacy behavior of inheriting the
|
|
127
|
+
host OpenCode config as-is. For reproducible runs, pass `model`, `permission`,
|
|
128
|
+
`mcp`, `tools`, and `instructions` explicitly through `RunConfig`.
|
|
129
|
+
|
|
112
130
|
## CLI arguments
|
|
113
131
|
|
|
114
132
|
`RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/SOURCES.txt
RENAMED
|
@@ -19,4 +19,5 @@ tests/test_integration_instructions.py
|
|
|
19
19
|
tests/test_integration_multi_agent_weather.py
|
|
20
20
|
tests/test_integration_opencode.py
|
|
21
21
|
tests/test_integration_parallel.py
|
|
22
|
-
tests/test_run_result_fuzzy_text.py
|
|
22
|
+
tests/test_run_result_fuzzy_text.py
|
|
23
|
+
tests/test_user_config_isolation.py
|
|
@@ -57,6 +57,23 @@ def test_build_env_config_content_and_autoupdate() -> None:
|
|
|
57
57
|
assert env.get("OPENCODE_DISABLE_AUTOUPDATE") == "1"
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_build_env_sets_pwd_to_cwd() -> None:
|
|
61
|
+
"""opencode's run.ts:276 reads `process.env.PWD ?? process.cwd()` to
|
|
62
|
+
resolve the project root, and the bash builtin pwd reads $PWD.
|
|
63
|
+
asyncio.create_subprocess_exec(cwd=...) only chdirs the child; PWD stays
|
|
64
|
+
inherited from the parent shell. build_env must pin PWD to the workspace
|
|
65
|
+
so opencode and bash both see a consistent cwd."""
|
|
66
|
+
cfg = RunConfig()
|
|
67
|
+
env = build_env(cfg, base={"HOME": "/tmp", "PWD": "/some/other/dir"}, cwd="/ws/x")
|
|
68
|
+
assert env["PWD"] == "/ws/x"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_build_env_without_cwd_leaves_pwd_untouched() -> None:
|
|
72
|
+
cfg = RunConfig()
|
|
73
|
+
env = build_env(cfg, base={"HOME": "/tmp", "PWD": "/parent"})
|
|
74
|
+
assert env["PWD"] == "/parent"
|
|
75
|
+
|
|
76
|
+
|
|
60
77
|
@pytest.mark.asyncio
|
|
61
78
|
async def test_readline_unlimited_normal_line() -> None:
|
|
62
79
|
"""Lines within the default 64 KiB limit are returned as-is."""
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Tests for the ``inherit_user_config`` flag and global-config sanitisation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from opencode_wrapper.client import (
|
|
14
|
+
AsyncOpenCodeClient,
|
|
15
|
+
_isolate_user_config,
|
|
16
|
+
_sanitize_and_copy,
|
|
17
|
+
)
|
|
18
|
+
from opencode_wrapper.config import (
|
|
19
|
+
PRESERVE_KEYS,
|
|
20
|
+
RunConfig,
|
|
21
|
+
loads_jsonc,
|
|
22
|
+
sanitize_user_config_json,
|
|
23
|
+
strip_jsonc,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# sanitize_user_config_json
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_sanitize_user_config_json_drops_capability_keys() -> None:
|
|
33
|
+
raw = {
|
|
34
|
+
"$schema": "https://opencode.ai/config.json",
|
|
35
|
+
"provider": {"openai": {"api_base": "https://example/v1"}},
|
|
36
|
+
"disabled_providers": ["anthropic"],
|
|
37
|
+
"enabled_providers": ["openai"],
|
|
38
|
+
"model": "anthropic/claude-haiku-4-5",
|
|
39
|
+
"small_model": "anthropic/claude-haiku-4-5",
|
|
40
|
+
"default_agent": "build",
|
|
41
|
+
"mcp": {"my_server": {"command": "uvx", "args": ["my-mcp"]}},
|
|
42
|
+
"agent": {"my_agent": {"description": "..."}},
|
|
43
|
+
"mode": {"plan": {"tools": {"bash": False}}},
|
|
44
|
+
"command": {"my_cmd": {"description": "..."}},
|
|
45
|
+
"skills": ["~/.opencode/skills"],
|
|
46
|
+
"plugin": ["my_plugin"],
|
|
47
|
+
"tools": {"bash": True},
|
|
48
|
+
"formatter": {"py": "ruff format"},
|
|
49
|
+
"lsp": {"go": {"command": ["gopls"]}},
|
|
50
|
+
"instructions": ["AGENTS.md"],
|
|
51
|
+
"permission": {"bash": "deny"},
|
|
52
|
+
"experimental": {"primary_tools": ["bash"]},
|
|
53
|
+
"watcher": {"ignore": ["dist"]},
|
|
54
|
+
"share": "disabled",
|
|
55
|
+
"autoupdate": False,
|
|
56
|
+
"username": "alice",
|
|
57
|
+
"shell": "bash",
|
|
58
|
+
"logLevel": "DEBUG",
|
|
59
|
+
}
|
|
60
|
+
out = sanitize_user_config_json(raw)
|
|
61
|
+
assert set(out.keys()) == {
|
|
62
|
+
"$schema",
|
|
63
|
+
"provider",
|
|
64
|
+
"disabled_providers",
|
|
65
|
+
"enabled_providers",
|
|
66
|
+
}
|
|
67
|
+
assert out["provider"] == {"openai": {"api_base": "https://example/v1"}}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_sanitize_user_config_json_passthrough_when_empty() -> None:
|
|
71
|
+
assert sanitize_user_config_json({}) == {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_preserve_keys_contains_only_provider_set() -> None:
|
|
75
|
+
"""Guard against accidental scope creep in PRESERVE_KEYS."""
|
|
76
|
+
assert PRESERVE_KEYS == frozenset(
|
|
77
|
+
{"$schema", "provider", "disabled_providers", "enabled_providers"}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# strip_jsonc / loads_jsonc
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_strip_jsonc_strips_line_comments() -> None:
|
|
87
|
+
out = strip_jsonc('// header\n{"a": 1} // tail\n')
|
|
88
|
+
assert json.loads(out) == {"a": 1}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_strip_jsonc_strips_block_comments() -> None:
|
|
92
|
+
out = strip_jsonc('/* leading */ {"a": /* inline */ 1}')
|
|
93
|
+
assert json.loads(out) == {"a": 1}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_strip_jsonc_strips_trailing_commas_in_objects() -> None:
|
|
97
|
+
out = strip_jsonc('{"a": 1, "b": 2,}')
|
|
98
|
+
assert json.loads(out) == {"a": 1, "b": 2}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_strip_jsonc_strips_trailing_commas_in_arrays() -> None:
|
|
102
|
+
out = strip_jsonc('[1, 2, 3,]')
|
|
103
|
+
assert json.loads(out) == [1, 2, 3]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_strip_jsonc_preserves_comment_syntax_inside_strings() -> None:
|
|
107
|
+
src = '{"url": "https://x.com/path", "note": "/* not a comment */"}'
|
|
108
|
+
out = strip_jsonc(src)
|
|
109
|
+
parsed = json.loads(out)
|
|
110
|
+
assert parsed == {"url": "https://x.com/path", "note": "/* not a comment */"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_strip_jsonc_handles_escaped_quotes_in_strings() -> None:
|
|
114
|
+
src = r'{"s": "she said \"hi // there\""}'
|
|
115
|
+
out = strip_jsonc(src)
|
|
116
|
+
assert json.loads(out) == {"s": 'she said "hi // there"'}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_strip_jsonc_preserves_commas_inside_values() -> None:
|
|
120
|
+
"""Commas that aren't immediately before } or ] are left alone."""
|
|
121
|
+
src = '{"a": 1, "b": 2}'
|
|
122
|
+
assert strip_jsonc(src) == '{"a": 1, "b": 2}'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_strip_jsonc_handles_trailing_comma_before_close_after_comment() -> None:
|
|
126
|
+
"""Trailing comma followed by a comment then } still recognised as trailing."""
|
|
127
|
+
out = strip_jsonc('{"a": 1, /* x */ }')
|
|
128
|
+
assert json.loads(out) == {"a": 1}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_loads_jsonc_fast_path_for_strict_json() -> None:
|
|
132
|
+
"""Strict JSON parses on the fast path (no JSONC stripping)."""
|
|
133
|
+
assert loads_jsonc('{"a": 1}') == {"a": 1}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_loads_jsonc_handles_real_jsonc() -> None:
|
|
137
|
+
assert loads_jsonc('// hi\n{"a": 1,}') == {"a": 1}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_loads_jsonc_raises_on_garbage() -> None:
|
|
141
|
+
with pytest.raises(json.JSONDecodeError):
|
|
142
|
+
loads_jsonc("this is not json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# _sanitize_and_copy
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_sanitize_and_copy_writes_filtered_json(tmp_path: Path) -> None:
|
|
151
|
+
src = tmp_path / "src"
|
|
152
|
+
src.mkdir()
|
|
153
|
+
(src / "opencode.json").write_text(
|
|
154
|
+
json.dumps({"provider": {"openai": {}}, "mcp": {"x": {}}, "model": "m"}),
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
dst = tmp_path / "dst"
|
|
158
|
+
|
|
159
|
+
_sanitize_and_copy(src, dst)
|
|
160
|
+
|
|
161
|
+
out = json.loads((dst / "opencode.json").read_text(encoding="utf-8"))
|
|
162
|
+
assert out == {"provider": {"openai": {}}}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_sanitize_and_copy_missing_source_dir_creates_empty_dst(tmp_path: Path) -> None:
|
|
166
|
+
dst = tmp_path / "dst"
|
|
167
|
+
_sanitize_and_copy(tmp_path / "does_not_exist", dst)
|
|
168
|
+
assert dst.is_dir()
|
|
169
|
+
assert list(dst.iterdir()) == []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_sanitize_and_copy_jsonc_with_comments_and_trailing_commas(
|
|
173
|
+
tmp_path: Path,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""JSONC superset (line/block comments + trailing commas) is supported."""
|
|
176
|
+
src = tmp_path / "src"
|
|
177
|
+
src.mkdir()
|
|
178
|
+
(src / "opencode.jsonc").write_text(
|
|
179
|
+
"""// header
|
|
180
|
+
{
|
|
181
|
+
"provider": {
|
|
182
|
+
"openai": {
|
|
183
|
+
"api_base": "X", /* inline */
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
"mcp": {"x": {}},
|
|
187
|
+
}
|
|
188
|
+
""",
|
|
189
|
+
encoding="utf-8",
|
|
190
|
+
)
|
|
191
|
+
dst = tmp_path / "dst"
|
|
192
|
+
_sanitize_and_copy(src, dst)
|
|
193
|
+
out = json.loads((dst / "opencode.jsonc").read_text(encoding="utf-8"))
|
|
194
|
+
assert out == {"provider": {"openai": {"api_base": "X"}}}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_sanitize_and_copy_truly_unparseable_skipped(
|
|
198
|
+
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Files that are neither JSON nor JSONC are skipped with a warning."""
|
|
201
|
+
src = tmp_path / "src"
|
|
202
|
+
src.mkdir()
|
|
203
|
+
(src / "opencode.jsonc").write_text("this is not json at all", encoding="utf-8")
|
|
204
|
+
dst = tmp_path / "dst"
|
|
205
|
+
with caplog.at_level(logging.WARNING, logger="opencode_wrapper.client"):
|
|
206
|
+
_sanitize_and_copy(src, dst)
|
|
207
|
+
assert not (dst / "opencode.jsonc").exists()
|
|
208
|
+
assert any("not parseable" in r.message for r in caplog.records)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_sanitize_and_copy_jsonc_without_comments_succeeds(tmp_path: Path) -> None:
|
|
212
|
+
"""Pure-JSON .jsonc files are sanitised and preserved by filename."""
|
|
213
|
+
src = tmp_path / "src"
|
|
214
|
+
src.mkdir()
|
|
215
|
+
(src / "opencode.jsonc").write_text(
|
|
216
|
+
json.dumps({"provider": {"openai": {}}, "mcp": {"x": {}}}),
|
|
217
|
+
encoding="utf-8",
|
|
218
|
+
)
|
|
219
|
+
dst = tmp_path / "dst"
|
|
220
|
+
_sanitize_and_copy(src, dst)
|
|
221
|
+
out = json.loads((dst / "opencode.jsonc").read_text(encoding="utf-8"))
|
|
222
|
+
assert out == {"provider": {"openai": {}}}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_sanitize_and_copy_non_object_root_skipped(
|
|
226
|
+
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
227
|
+
) -> None:
|
|
228
|
+
src = tmp_path / "src"
|
|
229
|
+
src.mkdir()
|
|
230
|
+
(src / "opencode.json").write_text("[]", encoding="utf-8")
|
|
231
|
+
dst = tmp_path / "dst"
|
|
232
|
+
with caplog.at_level(logging.WARNING, logger="opencode_wrapper.client"):
|
|
233
|
+
_sanitize_and_copy(src, dst)
|
|
234
|
+
assert not (dst / "opencode.json").exists()
|
|
235
|
+
assert any("root is not a JSON object" in r.message for r in caplog.records)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# _isolate_user_config
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_isolate_user_config_sets_xdg_and_test_home(tmp_path: Path) -> None:
|
|
244
|
+
fake_home = tmp_path / "real_home"
|
|
245
|
+
(fake_home / ".config" / "opencode").mkdir(parents=True)
|
|
246
|
+
(fake_home / ".config" / "opencode" / "opencode.json").write_text(
|
|
247
|
+
json.dumps({"provider": {"openai": {}}, "mcp": {"x": {}}}),
|
|
248
|
+
encoding="utf-8",
|
|
249
|
+
)
|
|
250
|
+
(fake_home / ".opencode").mkdir(parents=True)
|
|
251
|
+
(fake_home / ".opencode" / "opencode.json").write_text(
|
|
252
|
+
json.dumps({"provider": {"anthropic": {}}, "agent": {"a": {}}}),
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
)
|
|
255
|
+
tmp_root = tmp_path / "iso"
|
|
256
|
+
tmp_root.mkdir()
|
|
257
|
+
|
|
258
|
+
env = {"HOME": str(fake_home), "OPENCODE_CONFIG": "/leak", "OPENCODE_CONFIG_DIR": "/leak"}
|
|
259
|
+
out = _isolate_user_config(env, tmp_root)
|
|
260
|
+
|
|
261
|
+
assert out["XDG_CONFIG_HOME"] == str(tmp_root / "xdg")
|
|
262
|
+
assert out["OPENCODE_TEST_HOME"] == str(tmp_root / "home")
|
|
263
|
+
assert "OPENCODE_CONFIG" not in out
|
|
264
|
+
assert "OPENCODE_CONFIG_DIR" not in out
|
|
265
|
+
|
|
266
|
+
xdg_cfg = json.loads(
|
|
267
|
+
(tmp_root / "xdg" / "opencode" / "opencode.json").read_text(encoding="utf-8")
|
|
268
|
+
)
|
|
269
|
+
assert xdg_cfg == {"provider": {"openai": {}}}
|
|
270
|
+
home_cfg = json.loads(
|
|
271
|
+
(tmp_root / "home" / ".opencode" / "opencode.json").read_text(encoding="utf-8")
|
|
272
|
+
)
|
|
273
|
+
assert home_cfg == {"provider": {"anthropic": {}}}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_isolate_user_config_honours_explicit_xdg_config_home(tmp_path: Path) -> None:
|
|
277
|
+
custom_xdg = tmp_path / "custom_xdg"
|
|
278
|
+
(custom_xdg / "opencode").mkdir(parents=True)
|
|
279
|
+
(custom_xdg / "opencode" / "opencode.json").write_text(
|
|
280
|
+
json.dumps({"provider": {"p": {}}, "mcp": {"x": {}}}),
|
|
281
|
+
encoding="utf-8",
|
|
282
|
+
)
|
|
283
|
+
tmp_root = tmp_path / "iso"
|
|
284
|
+
tmp_root.mkdir()
|
|
285
|
+
|
|
286
|
+
env = {"HOME": "/nonexistent", "XDG_CONFIG_HOME": str(custom_xdg)}
|
|
287
|
+
out = _isolate_user_config(env, tmp_root)
|
|
288
|
+
|
|
289
|
+
assert out["XDG_CONFIG_HOME"] == str(tmp_root / "xdg")
|
|
290
|
+
cfg = json.loads(
|
|
291
|
+
(tmp_root / "xdg" / "opencode" / "opencode.json").read_text(encoding="utf-8")
|
|
292
|
+
)
|
|
293
|
+
assert cfg == {"provider": {"p": {}}}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Default flag + end-to-end env wiring through async_run
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_run_config_default_inherit_user_config_is_false() -> None:
|
|
302
|
+
assert RunConfig().inherit_user_config is False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class _CapturedEnvProc:
|
|
306
|
+
"""Fake subprocess that records the env it was spawned with."""
|
|
307
|
+
|
|
308
|
+
def __init__(self) -> None:
|
|
309
|
+
self.stdout = _OneShotStdout(b'{"type":"text","content":"ok"}\n')
|
|
310
|
+
self.stderr = _EmptyStream()
|
|
311
|
+
self.returncode: int | None = 0
|
|
312
|
+
|
|
313
|
+
async def wait(self) -> int:
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class _OneShotStdout:
|
|
318
|
+
def __init__(self, line: bytes) -> None:
|
|
319
|
+
self._line: bytes | None = line
|
|
320
|
+
|
|
321
|
+
async def readline(self) -> bytes:
|
|
322
|
+
await asyncio.sleep(0)
|
|
323
|
+
if self._line is None:
|
|
324
|
+
return b""
|
|
325
|
+
out, self._line = self._line, None
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
async def readuntil(self, sep: bytes = b"\n") -> bytes:
|
|
329
|
+
return await self.readline()
|
|
330
|
+
|
|
331
|
+
async def readexactly(self, n: int) -> bytes:
|
|
332
|
+
raise AssertionError("unused")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _EmptyStream:
|
|
336
|
+
async def readline(self) -> bytes:
|
|
337
|
+
await asyncio.sleep(0)
|
|
338
|
+
return b""
|
|
339
|
+
|
|
340
|
+
async def readuntil(self, sep: bytes = b"\n") -> bytes:
|
|
341
|
+
await asyncio.sleep(0)
|
|
342
|
+
return b""
|
|
343
|
+
|
|
344
|
+
async def readexactly(self, n: int) -> bytes:
|
|
345
|
+
raise AssertionError("unused")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.mark.asyncio
|
|
349
|
+
async def test_async_run_default_isolates_user_config(
|
|
350
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
351
|
+
) -> None:
|
|
352
|
+
"""By default, the env passed to opencode redirects XDG_CONFIG_HOME and OPENCODE_TEST_HOME."""
|
|
353
|
+
fake_home = tmp_path / "real_home"
|
|
354
|
+
(fake_home / ".config" / "opencode").mkdir(parents=True)
|
|
355
|
+
(fake_home / ".config" / "opencode" / "opencode.json").write_text(
|
|
356
|
+
json.dumps({"provider": {"o": {}}, "mcp": {"x": {}}}), encoding="utf-8"
|
|
357
|
+
)
|
|
358
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
359
|
+
monkeypatch.setenv("OPENCODE_CONFIG", "/leak/path")
|
|
360
|
+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
361
|
+
|
|
362
|
+
captured: dict[str, dict[str, str]] = {}
|
|
363
|
+
|
|
364
|
+
async def fake_exec(*args, **kwargs):
|
|
365
|
+
captured["env"] = dict(kwargs["env"])
|
|
366
|
+
return _CapturedEnvProc()
|
|
367
|
+
|
|
368
|
+
client = AsyncOpenCodeClient(
|
|
369
|
+
binary="opencode", isolate_db=False, startup_delay_s=0
|
|
370
|
+
)
|
|
371
|
+
monkeypatch.setattr(client, "resolved_binary", lambda: "/fake/opencode")
|
|
372
|
+
|
|
373
|
+
with patch("asyncio.create_subprocess_exec", new=fake_exec):
|
|
374
|
+
await client.async_run("hi", tmp_path, run_cfg=RunConfig())
|
|
375
|
+
|
|
376
|
+
env = captured["env"]
|
|
377
|
+
assert "XDG_CONFIG_HOME" in env
|
|
378
|
+
assert env["XDG_CONFIG_HOME"] != str(fake_home / ".config")
|
|
379
|
+
assert "oc_cfg_" in env["XDG_CONFIG_HOME"]
|
|
380
|
+
assert "OPENCODE_TEST_HOME" in env
|
|
381
|
+
assert "oc_cfg_" in env["OPENCODE_TEST_HOME"]
|
|
382
|
+
assert "OPENCODE_CONFIG" not in env
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@pytest.mark.asyncio
|
|
386
|
+
async def test_async_run_default_writes_sanitised_global_config(
|
|
387
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
388
|
+
) -> None:
|
|
389
|
+
"""The sanitised opencode.json under the isolated XDG is visible to the child env."""
|
|
390
|
+
fake_home = tmp_path / "real_home"
|
|
391
|
+
(fake_home / ".config" / "opencode").mkdir(parents=True)
|
|
392
|
+
(fake_home / ".config" / "opencode" / "opencode.json").write_text(
|
|
393
|
+
json.dumps({"provider": {"openai": {"api_base": "X"}}, "mcp": {"x": {}}}),
|
|
394
|
+
encoding="utf-8",
|
|
395
|
+
)
|
|
396
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
397
|
+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
398
|
+
|
|
399
|
+
seen: dict[str, dict] = {}
|
|
400
|
+
|
|
401
|
+
async def fake_exec(*args, **kwargs):
|
|
402
|
+
env = kwargs["env"]
|
|
403
|
+
cfg_path = Path(env["XDG_CONFIG_HOME"]) / "opencode" / "opencode.json"
|
|
404
|
+
seen["cfg"] = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
405
|
+
return _CapturedEnvProc()
|
|
406
|
+
|
|
407
|
+
client = AsyncOpenCodeClient(binary="opencode", isolate_db=False, startup_delay_s=0)
|
|
408
|
+
monkeypatch.setattr(client, "resolved_binary", lambda: "/fake/opencode")
|
|
409
|
+
|
|
410
|
+
with patch("asyncio.create_subprocess_exec", new=fake_exec):
|
|
411
|
+
await client.async_run("hi", tmp_path, run_cfg=RunConfig())
|
|
412
|
+
|
|
413
|
+
assert seen["cfg"] == {"provider": {"openai": {"api_base": "X"}}}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@pytest.mark.asyncio
|
|
417
|
+
async def test_async_run_inherit_true_does_not_mutate_xdg_config_home(
|
|
418
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Setting inherit_user_config=True keeps the inherited XDG_CONFIG_HOME / HOME."""
|
|
421
|
+
fake_home = tmp_path / "real_home"
|
|
422
|
+
fake_home.mkdir()
|
|
423
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
424
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_home / ".config"))
|
|
425
|
+
|
|
426
|
+
captured: dict[str, dict[str, str]] = {}
|
|
427
|
+
|
|
428
|
+
async def fake_exec(*args, **kwargs):
|
|
429
|
+
captured["env"] = dict(kwargs["env"])
|
|
430
|
+
return _CapturedEnvProc()
|
|
431
|
+
|
|
432
|
+
client = AsyncOpenCodeClient(binary="opencode", isolate_db=False, startup_delay_s=0)
|
|
433
|
+
monkeypatch.setattr(client, "resolved_binary", lambda: "/fake/opencode")
|
|
434
|
+
|
|
435
|
+
with patch("asyncio.create_subprocess_exec", new=fake_exec):
|
|
436
|
+
await client.async_run(
|
|
437
|
+
"hi", tmp_path, run_cfg=RunConfig(inherit_user_config=True)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
env = captured["env"]
|
|
441
|
+
assert env.get("XDG_CONFIG_HOME") == str(fake_home / ".config")
|
|
442
|
+
assert "OPENCODE_TEST_HOME" not in env
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/requires.txt
RENAMED
|
File without changes
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/py_opencode_wrapper.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_external_directory.py
RENAMED
|
File without changes
|
{py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.1}/tests/test_integration_instructions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|