py-opencode-wrapper 0.1.5__tar.gz → 0.2.0__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.
Files changed (25) hide show
  1. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/PKG-INFO +1 -1
  2. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/opencode_wrapper/client.py +109 -6
  3. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/opencode_wrapper/config.py +102 -0
  4. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/py_opencode_wrapper.egg-info/PKG-INFO +1 -1
  5. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/py_opencode_wrapper.egg-info/SOURCES.txt +2 -1
  6. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/pyproject.toml +1 -1
  7. py_opencode_wrapper-0.2.0/tests/test_user_config_isolation.py +442 -0
  8. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/README.md +0 -0
  9. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/opencode_wrapper/__init__.py +0 -0
  10. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/opencode_wrapper/errors.py +0 -0
  11. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/opencode_wrapper/events.py +0 -0
  12. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/py_opencode_wrapper.egg-info/dependency_links.txt +0 -0
  13. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/py_opencode_wrapper.egg-info/requires.txt +0 -0
  14. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/py_opencode_wrapper.egg-info/top_level.txt +0 -0
  15. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/setup.cfg +0 -0
  16. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_client_async.py +0 -0
  17. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_config_instructions.py +0 -0
  18. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_config_permission.py +0 -0
  19. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_event_parser.py +0 -0
  20. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_integration_external_directory.py +0 -0
  21. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_integration_instructions.py +0 -0
  22. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_integration_multi_agent_weather.py +0 -0
  23. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_integration_opencode.py +0 -0
  24. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/tests/test_integration_parallel.py +0 -0
  25. {py_opencode_wrapper-0.1.5 → py_opencode_wrapper-0.2.0}/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.5
3
+ Version: 0.2.0
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
@@ -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,
@@ -158,6 +160,96 @@ def _is_sqlite_startup_error(stderr: str) -> bool:
158
160
  return any(pat in lower for pat in _SQLITE_STARTUP_PATTERNS)
159
161
 
160
162
 
163
+ _LOG = logging.getLogger(__name__)
164
+
165
+ # Filenames opencode reads from its global config dir + ~/.opencode.
166
+ # Legacy TOML ("config" without extension) is intentionally skipped — it's rare
167
+ # and would require a TOML parser dep; the wrapper aims for stdlib-only.
168
+ _GLOBAL_CONFIG_FILENAMES: tuple[str, ...] = (
169
+ "config.json",
170
+ "opencode.json",
171
+ "opencode.jsonc",
172
+ )
173
+
174
+
175
+ def _resolve_real_xdg_config_opencode_dir(env: Mapping[str, str]) -> Path:
176
+ xdg = env.get("XDG_CONFIG_HOME")
177
+ base = Path(xdg).expanduser() if xdg else Path(env.get("HOME", str(Path.home()))).expanduser() / ".config"
178
+ return base / "opencode"
179
+
180
+
181
+ def _resolve_real_home_opencode_dir(env: Mapping[str, str]) -> Path:
182
+ return Path(env.get("HOME", str(Path.home()))).expanduser() / ".opencode"
183
+
184
+
185
+ def _sanitize_and_copy(src_dir: Path, dst_dir: Path) -> None:
186
+ """Read each known opencode config file in *src_dir*, sanitize, write to *dst_dir*.
187
+
188
+ Strict JSON files parse on the fast path; JSONC (comments, trailing commas)
189
+ falls back to ``loads_jsonc``. Files that aren't valid in either form are
190
+ skipped with a warning — for benchmark reproducibility the wrapper would
191
+ rather hide a file it can't safely strip than risk leaking capability keys.
192
+ Missing source files are silently skipped. ``dst_dir`` is always created
193
+ so opencode finds the directory (even if empty).
194
+ """
195
+ from opencode_wrapper.config import sanitize_user_config_json
196
+
197
+ dst_dir.mkdir(parents=True, exist_ok=True)
198
+ if not src_dir.is_dir():
199
+ return
200
+ for fname in _GLOBAL_CONFIG_FILENAMES:
201
+ src = src_dir / fname
202
+ if not src.is_file():
203
+ continue
204
+ try:
205
+ text = src.read_text(encoding="utf-8")
206
+ except OSError as exc:
207
+ _LOG.warning("user-config isolation: cannot read %s: %s", src, exc)
208
+ continue
209
+ try:
210
+ raw = loads_jsonc(text)
211
+ except json.JSONDecodeError as exc:
212
+ _LOG.warning(
213
+ "user-config isolation: skipping %s (not parseable as JSON or JSONC): %s",
214
+ src, exc,
215
+ )
216
+ continue
217
+ if not isinstance(raw, dict):
218
+ _LOG.warning(
219
+ "user-config isolation: skipping %s (root is not a JSON object)", src
220
+ )
221
+ continue
222
+ sanitized = sanitize_user_config_json(raw)
223
+ (dst_dir / fname).write_text(
224
+ json.dumps(sanitized, ensure_ascii=False), encoding="utf-8"
225
+ )
226
+
227
+
228
+ def _isolate_user_config(env: dict[str, str], tmp_root: Path) -> dict[str, str]:
229
+ """Mutate *env* so opencode sees a sanitized copy of the user's global config.
230
+
231
+ Reads the real ``$XDG_CONFIG_HOME/opencode`` and ``$HOME/.opencode``,
232
+ filters each file through ``sanitize_user_config_json`` (keeping only
233
+ ``provider`` / ``disabled_providers`` / ``enabled_providers`` / ``$schema``),
234
+ writes the results under *tmp_root*, and points ``XDG_CONFIG_HOME`` /
235
+ ``OPENCODE_TEST_HOME`` at those tmpdir locations. Strips
236
+ ``OPENCODE_CONFIG`` / ``OPENCODE_CONFIG_DIR`` so the parent shell can't
237
+ re-introduce extras. Project-level config (cwd walk + ``.opencode/``)
238
+ is untouched.
239
+ """
240
+ iso_xdg = tmp_root / "xdg"
241
+ iso_home = tmp_root / "home"
242
+
243
+ _sanitize_and_copy(_resolve_real_xdg_config_opencode_dir(env), iso_xdg / "opencode")
244
+ _sanitize_and_copy(_resolve_real_home_opencode_dir(env), iso_home / ".opencode")
245
+
246
+ env["XDG_CONFIG_HOME"] = str(iso_xdg)
247
+ env["OPENCODE_TEST_HOME"] = str(iso_home)
248
+ env.pop("OPENCODE_CONFIG", None)
249
+ env.pop("OPENCODE_CONFIG_DIR", None)
250
+ return env
251
+
252
+
161
253
  class AsyncOpenCodeClient:
162
254
  """
163
255
  One-shot async wrapper around the OpenCode CLI.
@@ -207,13 +299,16 @@ class AsyncOpenCodeClient:
207
299
  argv: list[str],
208
300
  cwd: str,
209
301
  env: dict[str, str],
302
+ run_cfg: RunConfig,
210
303
  ) -> AsyncIterator[tuple[asyncio.subprocess.Process, list[str]]]:
211
304
  stderr_lines: list[str] = []
305
+ cleanup_tmpdirs: list[str] = []
212
306
  # Give each process its own XDG_DATA_HOME so opencode.db is isolated.
213
307
  # Without this, all concurrent processes share ~/.local/share/opencode/opencode.db
214
308
  # and SQLite write locks during tool execution serialize the runs (37–46s delays).
215
309
  if self._isolate_db:
216
310
  xdg_tmpdir = tempfile.mkdtemp(prefix="oc_xdg_")
311
+ cleanup_tmpdirs.append(xdg_tmpdir)
217
312
  # Symlink auth.json so provider API keys (stored by `opencode auth`)
218
313
  # are visible in the isolated data dir. Without this, providers
219
314
  # that rely on auth.json (rather than env-var keys) fail with
@@ -225,8 +320,16 @@ class AsyncOpenCodeClient:
225
320
  iso_oc_dir.mkdir(parents=True, exist_ok=True)
226
321
  (iso_oc_dir / "auth.json").symlink_to(real_auth)
227
322
  env = {**env, "XDG_DATA_HOME": xdg_tmpdir}
228
- else:
229
- xdg_tmpdir = None
323
+ # When the caller has not opted into host-config inheritance (the
324
+ # default), redirect XDG_CONFIG_HOME / OPENCODE_TEST_HOME at a sanitized
325
+ # tmpdir copy of the user's global config — only provider settings are
326
+ # carried over, all capability keys (mcp / agent / command / tools /
327
+ # plugin / skills / instructions / permission / model / ...) are stripped.
328
+ # Project-level config (cwd walk + .opencode/) is untouched.
329
+ if not run_cfg.inherit_user_config:
330
+ cfg_tmpdir = tempfile.mkdtemp(prefix="oc_cfg_")
331
+ cleanup_tmpdirs.append(cfg_tmpdir)
332
+ env = _isolate_user_config(dict(env), Path(cfg_tmpdir))
230
333
  # Serialise process startup to avoid the SQLite WAL-pragma race.
231
334
  # The semaphore is released as soon as the startup window has elapsed,
232
335
  # so all processes run concurrently after their individual delay.
@@ -257,8 +360,8 @@ class AsyncOpenCodeClient:
257
360
  await stderr_task
258
361
  except asyncio.CancelledError:
259
362
  pass
260
- if xdg_tmpdir is not None:
261
- shutil.rmtree(xdg_tmpdir, ignore_errors=True)
363
+ for path in cleanup_tmpdirs:
364
+ shutil.rmtree(path, ignore_errors=True)
262
365
 
263
366
  async def async_stream(
264
367
  self,
@@ -283,7 +386,7 @@ class AsyncOpenCodeClient:
283
386
  events_acc: list[dict[str, Any]] = []
284
387
  raw_acc: list[str] = []
285
388
 
286
- async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
389
+ async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
287
390
  async for line, ev in _stdout_line_event_iter(proc):
288
391
  raw_acc.append(line)
289
392
  events_acc.append(ev)
@@ -341,7 +444,7 @@ class AsyncOpenCodeClient:
341
444
 
342
445
  log_fh = open(log_file, "w") if log_file is not None else None
343
446
  try:
344
- async with self._managed_process(argv, cwd, env) as (proc, stderr_lines):
447
+ async with self._managed_process(argv, cwd, env, run_cfg) as (proc, stderr_lines):
345
448
  async for line, ev in _stdout_line_event_iter(proc):
346
449
  raw_acc.append(line)
347
450
  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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-opencode-wrapper
3
- Version: 0.1.5
3
+ Version: 0.2.0
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-opencode-wrapper"
3
- version = "0.1.5"
3
+ version = "0.2.0"
4
4
  description = "Async Python wrapper for OpenCode CLI (opencode run --format json)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
@@ -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