py-opencode-wrapper 0.1.5__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
@@ -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(run_cfg: RunConfig, base: Mapping[str, str] | None = None) -> dict[str, str]:
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
- else:
229
- xdg_tmpdir = None
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
- if xdg_tmpdir is not None:
261
- shutil.rmtree(xdg_tmpdir, ignore_errors=True)
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
@@ -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.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.
@@ -0,0 +1,9 @@
1
+ opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
2
+ opencode_wrapper/client.py,sha256=Ny4pDBV6cToIOn2W7BgCFL1w12KdlVQVCInhddf_Df4,19546
3
+ opencode_wrapper/config.py,sha256=JraBPkX95GXFoOvn181BRv_8YPvsUDXiZMN1hZWhSf0,7823
4
+ opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
5
+ opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
6
+ py_opencode_wrapper-0.2.1.dist-info/METADATA,sha256=W56IyS4yoBlsSFIIrtsPx0-Ck6AoCCuhmdHiZslRgnE,6884
7
+ py_opencode_wrapper-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ py_opencode_wrapper-0.2.1.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
9
+ py_opencode_wrapper-0.2.1.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- opencode_wrapper/__init__.py,sha256=iCkMcrh7P35jHFq8gH-GKjaKqwnvmOkGCLRfgnb0moE,1013
2
- opencode_wrapper/client.py,sha256=wVVxBHyYey-uqwRn4Q6QKazWNOJ3gXrN-ltO5W66CqM,14523
3
- opencode_wrapper/config.py,sha256=7vWlWpkewWIxzQnAHtetgw4YN6xPj9Bw0IkwuO4CjaY,4046
4
- opencode_wrapper/errors.py,sha256=zaXzzFb6ObdrNlm-PJE_7tbgvMEhoZcbeQVWNHNTkUQ,1168
5
- opencode_wrapper/events.py,sha256=PHz04DcB0K0JfIRxgV8GQ3psl7VjQXZ25gIrDCOgAHQ,6478
6
- py_opencode_wrapper-0.1.5.dist-info/METADATA,sha256=eYf9t9WvqVAN3eHmhdU7W-Rvi5O0OyMZ1DQKFU6suEI,5940
7
- py_opencode_wrapper-0.1.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
- py_opencode_wrapper-0.1.5.dist-info/top_level.txt,sha256=8LETj5bPgl1YnB83iOiueuQvGryj3RzaeEQecPVS9Q8,17
9
- py_opencode_wrapper-0.1.5.dist-info/RECORD,,