optio-codex 0.4.0__tar.gz → 0.4.2__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 (63) hide show
  1. {optio_codex-0.4.0 → optio_codex-0.4.2}/PKG-INFO +1 -1
  2. {optio_codex-0.4.0 → optio_codex-0.4.2}/pyproject.toml +1 -1
  3. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/conversation.py +3 -1
  4. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/host_actions.py +143 -3
  5. optio_codex-0.4.2/src/optio_codex/prompt.py +48 -0
  6. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/session.py +3 -0
  7. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex.egg-info/PKG-INFO +1 -1
  8. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_codex_cache.py +225 -2
  9. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_config_hooks.py +9 -3
  10. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_prompt.py +10 -0
  11. optio_codex-0.4.0/src/optio_codex/prompt.py +0 -184
  12. {optio_codex-0.4.0 → optio_codex-0.4.2}/README.md +0 -0
  13. {optio_codex-0.4.0 → optio_codex-0.4.2}/setup.cfg +0 -0
  14. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/__init__.py +0 -0
  15. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/account.py +0 -0
  16. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/conversation_listener.py +0 -0
  17. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/cred_watcher.py +0 -0
  18. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/fs_allowlist.py +0 -0
  19. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/info.py +0 -0
  20. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/models.py +0 -0
  21. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/rollout.py +0 -0
  22. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/seed_manifest.py +0 -0
  23. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/snapshots.py +0 -0
  24. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/types.py +0 -0
  25. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex/verify.py +0 -0
  26. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex.egg-info/SOURCES.txt +0 -0
  27. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex.egg-info/dependency_links.txt +0 -0
  28. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex.egg-info/requires.txt +0 -0
  29. {optio_codex-0.4.0 → optio_codex-0.4.2}/src/optio_codex.egg-info/top_level.txt +0 -0
  30. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_account.py +0 -0
  31. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_agent_info.py +0 -0
  32. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_await_codex_gone.py +0 -0
  33. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_claustrum.py +0 -0
  34. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_config.py +0 -0
  35. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_conversation.py +0 -0
  36. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_conversation_controls.py +0 -0
  37. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_conversation_listener.py +0 -0
  38. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_cred_watcher.py +0 -0
  39. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_file_download.py +0 -0
  40. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_file_upload.py +0 -0
  41. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_fs_allowlist.py +0 -0
  42. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_host_actions.py +0 -0
  43. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_import.py +0 -0
  44. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_input_wiring.py +0 -0
  45. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_kill_ttyd_by_socket.py +0 -0
  46. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_models.py +0 -0
  47. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_real_codex_conversation.py +0 -0
  48. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_real_codex_seed_resume.py +0 -0
  49. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_real_codex_session.py +0 -0
  50. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_rollout.py +0 -0
  51. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_sandbox_enforce.py +0 -0
  52. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_seed_manifest.py +0 -0
  53. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_conversation.py +0 -0
  54. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_lease.py +0 -0
  55. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_local.py +0 -0
  56. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_remote.py +0 -0
  57. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_resume.py +0 -0
  58. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_sandbox.py +0 -0
  59. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_session_seed.py +0 -0
  60. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_snapshots.py +0 -0
  61. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_teardown_session_tree.py +0 -0
  62. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_verify.py +0 -0
  63. {optio_codex-0.4.0 → optio_codex-0.4.2}/tests/test_workdir_trust.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-codex
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-codex"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -496,7 +496,9 @@ class CodexConversation:
496
496
 
497
497
  # -- Conversation protocol surface --------------------------------------
498
498
 
499
- async def send(self, text: str) -> None:
499
+ async def send(self, text: str, *, uuid: str | None = None) -> None:
500
+ """``uuid`` is an advisory message id (Fix 13a/Fix 23): this backend
501
+ has no message identity of its own, so it is accepted and ignored."""
500
502
  if self._closed.is_set():
501
503
  raise ConversationClosed(self._close_reason or "conversation closed")
502
504
  if self.thread_id is None:
@@ -63,6 +63,24 @@ _CODEX_RELEASE_BASE = (
63
63
  f"https://github.com/openai/codex/releases/download/rust-v{_CODEX_VERSION}"
64
64
  )
65
65
 
66
+ # Version stamp recording which codex version the cache holds, written by
67
+ # BOTH install tiers (pinned download stamps _CODEX_VERSION; host-copy
68
+ # stamps the copied binary's probed version). The cache-hit path compares
69
+ # it against _CODEX_VERSION so a pin bump actually reaches warm caches —
70
+ # the cache is claustrum-``--rox`` to sessions, so freshness is owned
71
+ # entirely by this unconfined provisioning path (agent-binary-freshness
72
+ # plan, 2026-08-13).
73
+ _CODEX_VERSION_STAMP_NAME = "codex.version"
74
+
75
+ # ``codex --version`` output shape, verified against the REAL binary
76
+ # (2026-08-13, ~/.cache/optio-codex/bin/codex, v0.142.5):
77
+ # $ env HOME=<empty-dir> ~/.cache/optio-codex/bin/codex --version
78
+ # stdout: ``codex-cli 0.142.5`` (exactly; version only on stdout)
79
+ # stderr: PATH-alias WARNING when $HOME/.codex is absent (harmless)
80
+ # exit 0; no files written under HOME.
81
+ # Parse by regex, never by line position — stderr noise must not matter.
82
+ _CODEX_VERSION_OUTPUT_RE = re.compile(r"codex-cli\s+(\S+)")
83
+
66
84
 
67
85
  async def _expand_user_path(host: "Host", path: str) -> str:
68
86
  """Expand a leading ``~``/``~/`` against the HOST's home directory.
@@ -207,6 +225,73 @@ async def claustrum_newer_tag() -> str | None:
207
225
  return newest if key(newest) > key(claustrum.CLAUSTRUM_PINNED_TAG) else None
208
226
 
209
227
 
228
+ async def _probe_codex_version(host: "Host", codex_path: str) -> str | None:
229
+ """Best-effort: the version string a codex binary reports, or ``None``.
230
+
231
+ Runs ``<codex_path> --version`` with ``HOME`` pointed at the cache ROOT
232
+ (grok's ``_grok_update_target`` pattern) so the probe can never touch
233
+ the operator's ``~/.codex``. Real-binary evidence (2026-08-13, v0.142.5):
234
+ the probe writes nothing under HOME and prints ``codex-cli <semver>`` on
235
+ stdout (stderr may carry a PATH-alias warning) — parsed via
236
+ :data:`_CODEX_VERSION_OUTPUT_RE`. Non-zero exit / unmatched output →
237
+ ``None`` (never raises; a stamp-less cache just refreshes to the pin on
238
+ the next launch).
239
+ """
240
+ # HOME = the parent of the cache dir (…/optio-codex for …/optio-codex/bin).
241
+ cache_root = os.path.dirname(os.path.dirname(codex_path.rstrip("/"))) or "/"
242
+ r = await host.run_command(
243
+ f"env HOME={shlex.quote(cache_root)} {shlex.quote(codex_path)} --version"
244
+ )
245
+ if r.exit_code != 0:
246
+ _LOG.warning(
247
+ "codex --version probe failed (exit %s): %s",
248
+ r.exit_code, (r.stderr or "").strip()[:200],
249
+ )
250
+ return None
251
+ m = _CODEX_VERSION_OUTPUT_RE.search(r.stdout or "")
252
+ if m is None:
253
+ _LOG.warning(
254
+ "codex --version output unparseable; no version stamp: %r",
255
+ (r.stdout or "")[:200],
256
+ )
257
+ return None
258
+ return m.group(1)
259
+
260
+
261
+ async def _read_version_stamp(host: "Host", *, cache_dir: str) -> str | None:
262
+ """The cache's ``codex.version`` stamp content, or ``None`` when absent
263
+ (legacy warm cache filled before stamping existed)."""
264
+ stamp = f"{cache_dir}/{_CODEX_VERSION_STAMP_NAME}"
265
+ r = await host.run_command(f"cat {shlex.quote(stamp)} 2>/dev/null || true")
266
+ content = (r.stdout or "").strip()
267
+ return content or None
268
+
269
+
270
+ async def _write_version_stamp(
271
+ host: "Host", *, cache_dir: str, version: str,
272
+ ) -> None:
273
+ """Record ``version`` as ``<cache>/codex.version``, atomically.
274
+
275
+ Written AFTER the binary lands, via a per-invocation temp + ``mv -f``
276
+ (same concurrency discipline as the binary install): a concurrent
277
+ launch never reads a half-written stamp, and racers writing identical
278
+ content are last-writer-wins. Best-effort — a failed stamp write only
279
+ costs a redundant pinned re-download on the next launch, never a launch.
280
+ """
281
+ stamp = f"{cache_dir}/{_CODEX_VERSION_STAMP_NAME}"
282
+ tmp = f"{stamp}.{os.getpid()}-{uuid.uuid4().hex}"
283
+ r = await host.run_command(
284
+ f"printf %s {shlex.quote(version)} > {shlex.quote(tmp)} "
285
+ f"&& mv -f {shlex.quote(tmp)} {shlex.quote(stamp)}"
286
+ )
287
+ if r.exit_code != 0:
288
+ _LOG.warning(
289
+ "writing codex version stamp %r failed (exit %s): %s",
290
+ stamp, r.exit_code, (r.stderr or "").strip()[:200],
291
+ )
292
+ await host.run_command(f"rm -f {shlex.quote(tmp)}")
293
+
294
+
210
295
  async def ensure_codex_installed(
211
296
  hook_ctx: "HookContextProtocol",
212
297
  *,
@@ -219,13 +304,25 @@ async def ensure_codex_installed(
219
304
  any task workdir and never the operator's autoupdating ``~/.codex`` — so
220
305
  it stays shared, evictable, and unsnapshotted. Resolution order:
221
306
 
222
- - **cache hit** — ``<cache>/codex`` is already executable.
307
+ - **cache hit, stamp == pin** — ``<cache>/codex`` is executable and its
308
+ ``codex.version`` stamp matches :data:`_CODEX_VERSION`.
309
+ - **cache hit, stamp missing/stale** — the ``_CODEX_VERSION`` pin is
310
+ authoritative: re-download the pinned release into the cache (never
311
+ host-copy on a refresh) and stamp it. Best-effort: a failed download
312
+ with an executable cached binary logs and uses the cached one (a
313
+ stale pin beats no engine — offline workers must still launch).
314
+ Gated on ``install_if_missing`` (an offline/pinned worker keeps the
315
+ binary it has; mirrors grok's update-probe gate).
223
316
  - **cache miss** — seed the cache from the resolved host ``codex``
224
317
  (login-shell ``command -v codex`` via :func:`resolve_codex`), copying
225
318
  it into ``<cache>/codex`` (``cp -L`` deref + chmod + re-verify).
226
319
  - **no host codex** — download the pinned GitHub-release tarball
227
320
  (:func:`_download_codex_into_cache`) and install it into the cache.
228
321
 
322
+ Both install tiers write the ``codex.version`` stamp, so pin bumps
323
+ become EFFECTIVE on warm caches (the cache is claustrum-``--rox`` to
324
+ confined sessions; freshness is owned here, by unconfined provisioning).
325
+
229
326
  Whatever fills the cache, the RETURNED path is always the per-task
230
327
  ``<workdir>/home/.local/bin/codex`` symlink (via
231
328
  :func:`_provision_task_home`) so teardown's anchored pkill stays scoped
@@ -243,7 +340,38 @@ async def ensure_codex_installed(
243
340
  f"[ -x {shlex.quote(cached)} ] && echo OK || true"
244
341
  )
245
342
  if "OK" in (probe.stdout or ""):
246
- _LOG.info("ensure_codex_installed: cache HIT (%s)", cached)
343
+ stamp = await _read_version_stamp(host, cache_dir=cache_dir)
344
+ if stamp == _CODEX_VERSION:
345
+ _LOG.info("ensure_codex_installed: cache HIT (%s, %s)", cached, stamp)
346
+ return await _provision_task_home(host, shared_codex_path=cached)
347
+ if not install_if_missing:
348
+ _LOG.warning(
349
+ "ensure_codex_installed: cache stamp %r != pin %r but "
350
+ "install_if_missing=False; keeping cached binary (%s)",
351
+ stamp, _CODEX_VERSION, cached,
352
+ )
353
+ return await _provision_task_home(host, shared_codex_path=cached)
354
+ # Stamp missing (legacy warm cache) or != pin: the pin is
355
+ # authoritative on refresh — always the pinned release, never a
356
+ # host-copy (an operator's codex tracks upstream, not the pin).
357
+ _LOG.info(
358
+ "ensure_codex_installed: cache stamp %r != pin %r -> "
359
+ "re-downloading pinned release (%s)",
360
+ stamp, _CODEX_VERSION, cached,
361
+ )
362
+ hook_ctx.report_progress(None, f"Updating codex to {_CODEX_VERSION}…")
363
+ try:
364
+ await _download_codex_into_cache(
365
+ hook_ctx, cache_dir=cache_dir, cached=cached,
366
+ )
367
+ except Exception: # noqa: BLE001
368
+ # Offline fallback: the cached binary is known-executable
369
+ # (probe above), so a failed refresh logs and launches on it.
370
+ _LOG.warning(
371
+ "ensure_codex_installed: pinned refresh to %s failed; "
372
+ "keeping cached binary (%s)",
373
+ _CODEX_VERSION, cached, exc_info=True,
374
+ )
247
375
  return await _provision_task_home(host, shared_codex_path=cached)
248
376
 
249
377
  if not install_if_missing:
@@ -301,6 +429,12 @@ async def _install_into_cache_from_host(
301
429
  f"codex cache seed completed but {cached!r} is still not "
302
430
  f"executable on the host. Check the seed source {source!r}."
303
431
  )
432
+ # Stamp the COPIED binary's own reported version (a host codex tracks
433
+ # upstream, so it need not equal the pin). Unprobeable → no stamp,
434
+ # and the next launch refreshes the cache to the authoritative pin.
435
+ version = await _probe_codex_version(host, cached)
436
+ if version is not None:
437
+ await _write_version_stamp(host, cache_dir=cache_dir, version=version)
304
438
  _LOG.info("ensure_codex_installed: cache MISS -> seeded from %s", source)
305
439
 
306
440
 
@@ -411,8 +545,14 @@ async def _download_codex_into_cache(
411
545
  f"codex download completed but {cached!r} is still not "
412
546
  f"executable on the host."
413
547
  )
548
+ # Stamp AFTER the binary: a stamp must never claim a version the
549
+ # cached binary is not. This tier only ever installs the pin.
550
+ await _write_version_stamp(
551
+ host, cache_dir=cache_dir, version=_CODEX_VERSION,
552
+ )
553
+ # Serves both the cold-cache fill and the stale-stamp pinned refresh.
414
554
  _LOG.info(
415
- "ensure_codex_installed: cache MISS -> downloaded %s", url,
555
+ "ensure_codex_installed: downloaded pinned release %s", url,
416
556
  )
417
557
  finally:
418
558
  await host.run_command(
@@ -0,0 +1,48 @@
1
+ """AGENTS.md composition for optio-codex: the profile, plus a thin wrapper over
2
+ the shared composer (``optio_agents.prompt.compose_instructions_file``), which
3
+ owns all of the text. The exclude list is resolved with the same function the
4
+ snapshot archive uses, so the section's preservation claims never drift."""
5
+
6
+ from optio_agents.prompt import AgentPromptProfile, compose_instructions_file
7
+
8
+ from optio_codex.snapshots import effective_workdir_exclude
9
+
10
+ __all__ = ["PROFILE", "compose_agents_md"]
11
+
12
+ PROFILE = AgentPromptProfile(
13
+ state_dir="home/.codex/",
14
+ state_dir_contents=(
15
+ "the codex session store (rollout files under `home/.codex/sessions`, "
16
+ "auth, config)"
17
+ ),
18
+ )
19
+
20
+
21
+ def compose_agents_md(
22
+ consumer_instructions: str,
23
+ *,
24
+ workdir_exclude: list[str] | None = None,
25
+ documentation: str | None = None,
26
+ supports_resume: bool = True,
27
+ host_protocol: bool = True,
28
+ omit_task_framing: bool = False,
29
+ fs_isolation_dirs: list[tuple[str, str]] | None = None,
30
+ file_download: bool = False,
31
+ check_resume_log_every_message: bool = True,
32
+ ) -> str:
33
+ """Render <workdir>/AGENTS.md for an optio-codex task.
34
+
35
+ ``workdir_exclude=None`` means the codex defaults
36
+ (``snapshots.effective_workdir_exclude``), not the bare framework ones."""
37
+ return compose_instructions_file(
38
+ consumer_instructions,
39
+ profile=PROFILE,
40
+ workdir_exclude=effective_workdir_exclude(workdir_exclude),
41
+ documentation=documentation,
42
+ supports_resume=supports_resume,
43
+ host_protocol=host_protocol,
44
+ omit_task_framing=omit_task_framing,
45
+ fs_isolation_dirs=fs_isolation_dirs,
46
+ file_download=file_download,
47
+ check_resume_log_every_message=check_resume_log_every_message,
48
+ )
@@ -16,6 +16,7 @@ from optio_core.models import BasicAuth, TaskInstance
16
16
 
17
17
  from optio_agents import HookContext, RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX, get_protocol
18
18
  from optio_agents import seeds as _seeds
19
+ from optio_agents.fs_grants import fs_isolation_dirs
19
20
  from optio_agents.input_listener import serialized, start_input_listener
20
21
  from optio_agents.protocol.session import _SessionFailed, run_log_protocol_session
21
22
  from optio_agents.account import EMPTY, accounts_to_metadata
@@ -303,6 +304,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
303
304
  workdir_exclude=config.workdir_exclude,
304
305
  supports_resume=config.supports_resume,
305
306
  file_download=config.file_download,
307
+ fs_isolation_dirs=fs_isolation_dirs(config, host.workdir),
306
308
  ),
307
309
  )
308
310
  else:
@@ -891,6 +893,7 @@ async def _maybe_refresh_on_resume(
891
893
  workdir_exclude=new_config.workdir_exclude,
892
894
  supports_resume=new_config.supports_resume,
893
895
  file_download=new_config.file_download,
896
+ fs_isolation_dirs=fs_isolation_dirs(new_config, host.workdir),
894
897
  )
895
898
  try:
896
899
  existing = await hook_ctx.read_text_from_host("AGENTS.md", silent=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-codex
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -53,10 +53,17 @@ def _per_task_path(ctx: _FakeHookCtx) -> str:
53
53
  return f"{ctx._host.workdir}/home/.local/bin/codex"
54
54
 
55
55
 
56
+ def _stamp(cache: pathlib.Path, version: str | None = None) -> None:
57
+ """Plant a ``codex.version`` stamp (defaults to the current pin)."""
58
+ cache.mkdir(parents=True, exist_ok=True)
59
+ (cache / "codex.version").write_text(version or host_actions._CODEX_VERSION)
60
+
61
+
56
62
  @pytest.mark.asyncio
57
63
  async def test_cache_hit_returns_per_task_symlink_into_cache(tmp_path, monkeypatch):
58
64
  cache = tmp_path / "cache"
59
65
  _write_exe(cache / "codex")
66
+ _stamp(cache) # stamp == pin → plain hit
60
67
 
61
68
  # A cache hit must not consult the host codex at all.
62
69
  async def _boom(*a, **k): # noqa: ANN002, ANN003
@@ -117,6 +124,7 @@ async def test_default_cache_dir_from_env(tmp_path, monkeypatch):
117
124
  cache dir — never the workdir, never the operator's ~/.codex."""
118
125
  cache = tmp_path / "oai-cache" / "bin"
119
126
  _write_exe(cache / "codex")
127
+ _stamp(cache)
120
128
  monkeypatch.setenv("OPTIO_CODEX_CACHE_DIR", str(cache))
121
129
  ctx = await _local_ctx(tmp_path)
122
130
 
@@ -181,10 +189,15 @@ async def test_cache_miss_no_host_codex_downloads_release(tmp_path, monkeypatch)
181
189
  assert (cache / "codex").is_file()
182
190
  assert os.access(cache / "codex", os.X_OK)
183
191
  assert (cache / "codex").read_bytes().startswith(b"#!/bin/bash")
192
+ # …the pinned version was stamped alongside it…
193
+ assert (cache / "codex.version").read_text() == host_actions._CODEX_VERSION
184
194
  # …behind the per-task launch symlink, and no temp litter remains.
185
195
  assert result == _per_task_path(ctx)
186
196
  assert os.path.realpath(result) == os.path.realpath(str(cache / "codex"))
187
- leftovers = [p.name for p in cache.iterdir() if p.name != "codex"]
197
+ leftovers = [
198
+ p.name for p in cache.iterdir()
199
+ if p.name not in {"codex", "codex.version"}
200
+ ]
188
201
  assert leftovers == [], leftovers
189
202
 
190
203
 
@@ -258,7 +271,11 @@ async def test_concurrent_cold_cache_downloads_do_not_corrupt(
258
271
  assert (cache / "codex").is_file()
259
272
  assert os.access(cache / "codex", os.X_OK)
260
273
  assert (cache / "codex").read_bytes().startswith(b"#!/bin/bash")
261
- leftovers = [p.name for p in cache.iterdir() if p.name != "codex"]
274
+ assert (cache / "codex.version").read_text() == host_actions._CODEX_VERSION
275
+ leftovers = [
276
+ p.name for p in cache.iterdir()
277
+ if p.name not in {"codex", "codex.version"}
278
+ ]
262
279
  assert leftovers == [], leftovers
263
280
 
264
281
 
@@ -322,3 +339,209 @@ async def test_download_multi_member_tarball_rejected(tmp_path, monkeypatch):
322
339
 
323
340
  with pytest.raises(RuntimeError, match="exactly one"):
324
341
  await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
342
+
343
+
344
+ # --- pin-stamp freshness (agent-binary-freshness plan Task 4) ---------------
345
+ #
346
+ # The cache is claustrum---rox to confined sessions, so freshness is owned by
347
+ # this unconfined provisioning path: both install tiers stamp
348
+ # <cache>/codex.version, and a cache hit compares the stamp against the
349
+ # _CODEX_VERSION pin — a pin bump becomes effective on warm caches.
350
+
351
+
352
+ class _NoDownloadCtx(_FakeHookCtx):
353
+ """Fake hook_ctx that must never be asked to download."""
354
+
355
+ async def download_file(self, url: str, dest: str) -> None:
356
+ raise AssertionError(f"download_file must not be called (url={url})")
357
+
358
+
359
+ class _FailingDownloadCtx(_FakeHookCtx):
360
+ """Fake hook_ctx whose download always fails (offline worker)."""
361
+
362
+ def __init__(self, host) -> None: # noqa: ANN001
363
+ super().__init__(host)
364
+ self.attempts = 0
365
+
366
+ async def download_file(self, url: str, dest: str) -> None:
367
+ self.attempts += 1
368
+ raise RuntimeError("network down")
369
+
370
+
371
+ @pytest.mark.asyncio
372
+ async def test_cache_hit_matching_stamp_skips_download(tmp_path, monkeypatch):
373
+ """Warm cache whose stamp equals the pin → no download, no host-copy."""
374
+ cache = tmp_path / "cache"
375
+ _write_exe(cache / "codex")
376
+ _stamp(cache)
377
+
378
+ async def _boom(*a, **k): # noqa: ANN002, ANN003
379
+ raise AssertionError("resolve_codex must not be called on a stamped hit")
380
+
381
+ monkeypatch.setattr(host_actions, "resolve_codex", _boom)
382
+ host = LocalHost(taskdir=str(tmp_path / "task"))
383
+ await host.setup_workdir()
384
+ ctx = _NoDownloadCtx(host)
385
+
386
+ result = await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
387
+ assert result == _per_task_path(ctx)
388
+ assert os.path.realpath(result) == os.path.realpath(str(cache / "codex"))
389
+
390
+
391
+ @pytest.mark.asyncio
392
+ @pytest.mark.parametrize("stale_stamp", [None, "0.1.0"])
393
+ async def test_cache_hit_stale_or_missing_stamp_redownloads_pin(
394
+ tmp_path, monkeypatch, stale_stamp,
395
+ ):
396
+ """Stamp missing (legacy warm cache) or != pin → the pinned release is
397
+ re-downloaded into the cache (never host-copied: the pin is
398
+ authoritative on refresh) and the stamp is rewritten to the pin."""
399
+ cache = tmp_path / "cache"
400
+ _write_exe(cache / "codex", body="#!/bin/bash\necho stale-codex\n")
401
+ if stale_stamp is not None:
402
+ _stamp(cache, stale_stamp)
403
+
404
+ async def _boom(*a, **k): # noqa: ANN002, ANN003
405
+ raise AssertionError("refresh must never host-copy; pin is authoritative")
406
+
407
+ monkeypatch.setattr(host_actions, "resolve_codex", _boom)
408
+ host = LocalHost(taskdir=str(tmp_path / "task"))
409
+ await host.setup_workdir()
410
+ ctx = _DownloadingHookCtx(host, _fake_release_tarball())
411
+
412
+ result = await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
413
+
414
+ # The pinned release was fetched and installed over the stale binary…
415
+ assert len(ctx.urls) == 1
416
+ assert host_actions._CODEX_VERSION in ctx.urls[0]
417
+ assert (cache / "codex").read_bytes().startswith(b"#!/bin/bash")
418
+ assert b"downloaded-codex" in (cache / "codex").read_bytes()
419
+ # …the stamp now records the pin…
420
+ assert (cache / "codex.version").read_text() == host_actions._CODEX_VERSION
421
+ # …and the per-task symlink resolves into the refreshed cache.
422
+ assert result == _per_task_path(ctx)
423
+ assert os.path.realpath(result) == os.path.realpath(str(cache / "codex"))
424
+
425
+
426
+ @pytest.mark.asyncio
427
+ async def test_cache_hit_refresh_download_failure_falls_back_to_cached(
428
+ tmp_path, monkeypatch,
429
+ ):
430
+ """Offline fallback: a failed pinned refresh with an executable cached
431
+ binary logs and launches on the cached one (stale pin beats no engine)."""
432
+ cache = tmp_path / "cache"
433
+ _write_exe(cache / "codex", body="#!/bin/bash\necho stale-codex\n")
434
+ _stamp(cache, "0.1.0")
435
+
436
+ async def _boom(*a, **k): # noqa: ANN002, ANN003
437
+ raise AssertionError("refresh must never host-copy")
438
+
439
+ monkeypatch.setattr(host_actions, "resolve_codex", _boom)
440
+ host = LocalHost(taskdir=str(tmp_path / "task"))
441
+ await host.setup_workdir()
442
+ ctx = _FailingDownloadCtx(host)
443
+
444
+ result = await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
445
+
446
+ assert ctx.attempts == 1 # the refresh WAS attempted…
447
+ # …but the launch proceeds on the untouched cached binary,
448
+ assert result == _per_task_path(ctx)
449
+ assert b"stale-codex" in (cache / "codex").read_bytes()
450
+ # and the stamp stays stale so the NEXT launch retries the refresh.
451
+ assert (cache / "codex.version").read_text() == "0.1.0"
452
+
453
+
454
+ @pytest.mark.asyncio
455
+ async def test_cache_hit_stale_stamp_install_if_missing_false_keeps_cached(
456
+ tmp_path, monkeypatch,
457
+ ):
458
+ """install_if_missing=False means no network ever: a stale stamp keeps
459
+ the cached binary without attempting the pinned refresh (mirrors grok's
460
+ install_if_missing gate on its update probe)."""
461
+ cache = tmp_path / "cache"
462
+ _write_exe(cache / "codex", body="#!/bin/bash\necho stale-codex\n")
463
+ _stamp(cache, "0.1.0")
464
+
465
+ async def _boom(*a, **k): # noqa: ANN002, ANN003
466
+ raise AssertionError("must not resolve host codex")
467
+
468
+ monkeypatch.setattr(host_actions, "resolve_codex", _boom)
469
+ host = LocalHost(taskdir=str(tmp_path / "task"))
470
+ await host.setup_workdir()
471
+ ctx = _NoDownloadCtx(host)
472
+
473
+ result = await host_actions.ensure_codex_installed(
474
+ ctx, install_dir=str(cache), install_if_missing=False,
475
+ )
476
+ assert result == _per_task_path(ctx)
477
+ assert b"stale-codex" in (cache / "codex").read_bytes()
478
+ assert (cache / "codex.version").read_text() == "0.1.0"
479
+
480
+
481
+ # A fake host codex whose --version mimics the REAL binary's output shape
482
+ # (verified 2026-08-13 against ~/.cache/optio-codex/bin/codex: stdout is
483
+ # exactly ``codex-cli <semver>``; stderr may carry a PATH-alias warning).
484
+ _VERSIONED_FAKE = (
485
+ "#!/bin/bash\n"
486
+ 'if [ "$1" = "--version" ]; then\n'
487
+ ' echo "WARNING: could not create PATH aliases" >&2\n'
488
+ ' echo "codex-cli 9.9.9"\n'
489
+ " exit 0\n"
490
+ "fi\n"
491
+ "echo fake-codex\n"
492
+ )
493
+
494
+
495
+ @pytest.mark.asyncio
496
+ async def test_host_copy_seed_stamps_probed_version(tmp_path, monkeypatch):
497
+ """The host-copy tier stamps the COPIED binary's own reported version
498
+ (which need not equal the pin — a host codex tracks upstream)."""
499
+ cache = tmp_path / "cache"
500
+ cache.mkdir() # empty → miss
501
+ source = _write_exe(tmp_path / "hostbin" / "codex", body=_VERSIONED_FAKE)
502
+
503
+ async def _resolve(host, *, install_dir=None, install_if_missing=True): # noqa: ANN001
504
+ return str(source)
505
+
506
+ monkeypatch.setattr(host_actions, "resolve_codex", _resolve)
507
+ ctx = await _local_ctx(tmp_path)
508
+
509
+ result = await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
510
+ assert result == _per_task_path(ctx)
511
+ assert (cache / "codex.version").read_text() == "9.9.9"
512
+
513
+
514
+ @pytest.mark.asyncio
515
+ async def test_host_copy_unprobeable_version_writes_no_stamp(
516
+ tmp_path, monkeypatch,
517
+ ):
518
+ """A copied binary whose --version output does not parse gets NO stamp —
519
+ the next launch then refreshes the cache to the authoritative pin."""
520
+ cache = tmp_path / "cache"
521
+ cache.mkdir()
522
+ source = _write_exe(tmp_path / "hostbin" / "codex") # echoes fake-codex
523
+
524
+ async def _resolve(host, *, install_dir=None, install_if_missing=True): # noqa: ANN001
525
+ return str(source)
526
+
527
+ monkeypatch.setattr(host_actions, "resolve_codex", _resolve)
528
+ ctx = await _local_ctx(tmp_path)
529
+
530
+ result = await host_actions.ensure_codex_installed(ctx, install_dir=str(cache))
531
+ assert result == _per_task_path(ctx)
532
+ assert not (cache / "codex.version").exists()
533
+
534
+
535
+ def test_version_output_regex_matches_real_binary_shape():
536
+ """Anchor the parse to the REAL ``codex --version`` stdout (v0.142.5)."""
537
+ m = host_actions._CODEX_VERSION_OUTPUT_RE.search("codex-cli 0.142.5\n")
538
+ assert m is not None and m.group(1) == "0.142.5"
539
+ assert host_actions._CODEX_VERSION_OUTPUT_RE.search("fake-codex\n") is None
540
+
541
+
542
+ def test_sshd_mount_stamp_tracks_pin():
543
+ """tests/codex.version is compose-mounted beside the remote shim as its
544
+ cache stamp; it must track the _CODEX_VERSION pin, or the remote suite
545
+ would attempt a real pinned re-download. Update it on every pin bump."""
546
+ stamp = pathlib.Path(__file__).parent / "codex.version"
547
+ assert stamp.read_text().strip() == host_actions._CODEX_VERSION
@@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock
16
16
  import pytest
17
17
 
18
18
  from optio_agents import get_protocol
19
+ from optio_agents.fs_grants import fs_isolation_dirs
19
20
  from optio_codex.prompt import compose_agents_md
20
21
  from optio_codex.types import CodexTaskConfig
21
22
 
@@ -239,7 +240,7 @@ async def test_restore_workdir_blob_decrypts(monkeypatch):
239
240
  # --- P2 unit: _maybe_refresh_on_resume -------------------------------------
240
241
 
241
242
 
242
- def _resume_fakes(existing_agents_md: str):
243
+ def _resume_fakes(existing_agents_md: str, workdir: str = "/workdir"):
243
244
  """Build (host, hook_ctx) fakes whose read returns ``existing_agents_md``
244
245
  and whose write records the rewritten body."""
245
246
  written: dict[str, str] = {}
@@ -249,6 +250,7 @@ def _resume_fakes(existing_agents_md: str):
249
250
 
250
251
  fake_host = MagicMock()
251
252
  fake_host.write_text = _write_text
253
+ fake_host.workdir = workdir
252
254
 
253
255
  fake_hook = MagicMock()
254
256
  fake_hook.read_text_from_host = AsyncMock(return_value=existing_agents_md)
@@ -260,6 +262,7 @@ async def test_maybe_refresh_identity_unchanged_is_noop():
260
262
 
261
263
  protocol = get_protocol(browser="redirect")
262
264
  cfg = _cfg(consumer_instructions="orig")
265
+ workdir = "/workdir"
263
266
  existing = compose_agents_md(
264
267
  cfg.consumer_instructions,
265
268
  documentation=protocol.documentation if cfg.host_protocol else None,
@@ -267,8 +270,9 @@ async def test_maybe_refresh_identity_unchanged_is_noop():
267
270
  workdir_exclude=cfg.workdir_exclude,
268
271
  supports_resume=cfg.supports_resume,
269
272
  file_download=cfg.file_download,
273
+ fs_isolation_dirs=fs_isolation_dirs(cfg, workdir),
270
274
  )
271
- host, hook, written = _resume_fakes(existing)
275
+ host, hook, written = _resume_fakes(existing, workdir=workdir)
272
276
 
273
277
  refreshed = await _maybe_refresh_on_resume(host, hook, cfg, protocol)
274
278
  assert refreshed == []
@@ -285,12 +289,14 @@ async def test_maybe_refresh_mutating_hook_rewrites_agents_md():
285
289
  return dataclasses.replace(c, consumer_instructions="UPDATED INSTRUCTIONS")
286
290
 
287
291
  cfg = _cfg(consumer_instructions="orig", on_resume_refresh=_bump)
292
+ workdir = "/workdir"
288
293
  stale = compose_agents_md(
289
294
  "orig",
290
295
  documentation=protocol.documentation,
291
296
  host_protocol=True,
297
+ fs_isolation_dirs=fs_isolation_dirs(cfg, workdir),
292
298
  )
293
- host, hook, written = _resume_fakes(stale)
299
+ host, hook, written = _resume_fakes(stale, workdir=workdir)
294
300
 
295
301
  refreshed = await _maybe_refresh_on_resume(host, hook, cfg, protocol)
296
302
  assert refreshed == ["AGENTS.md"]
@@ -84,3 +84,13 @@ def test_host_protocol_false_keeps_resume_section_and_explainer():
84
84
  # passing; same caveat as test_host_protocol_false_adds_system_explainer.)
85
85
  assert get_protocol(browser="redirect").documentation not in md
86
86
  assert "## Log channel" not in md
87
+
88
+
89
+ def test_sandbox_note_and_no_prompt_text_of_its_own():
90
+ import inspect
91
+ import optio_codex.prompt as m
92
+ out = compose_agents_md("x", fs_isolation_dirs=[("/wd", "rwx")])
93
+ assert "**Filesystem access:**" in out
94
+ src = inspect.getsource(m)
95
+ assert "This harness may pause your session" not in src
96
+ assert "originate from the harness" not in src
@@ -1,184 +0,0 @@
1
- """AGENTS.md composition for optio-codex.
2
-
3
- Codex reads an ``AGENTS.md`` file in its workdir. The shared framing and
4
- the keyword-protocol documentation are owned by ``optio-agents`` (the
5
- prompt SSOT); this module threads codex's protocol mode through and owns
6
- the codex-specific resume-awareness section (Stage 2), rendered from the
7
- EFFECTIVE snapshot exclude list so its preservation claims never drift
8
- from what the snapshot actually keeps.
9
- """
10
-
11
- from optio_agents.prompt import compose_agents_md as _compose_agents_md_host
12
- from optio_agents.prompt import downloadables_block
13
- from optio_agents.protocol import ProtocolFeatures, build_log_channel_prompt
14
-
15
- from optio_codex.snapshots import effective_workdir_exclude
16
-
17
-
18
- # Self-contained System: explainer for sessions without the keyword-protocol
19
- # docs (which normally explain the convention). Per-wrapper copy is the
20
- # established pattern (claudecode/opencode/grok each carry their own).
21
- _SYSTEM_PREFIX_EXPLAINER = """\
22
- (Messages prefixed `System:` on your input channel originate from the
23
- harness coordinating this session, not from the human user.)
24
- """
25
-
26
-
27
- RESUME_SECTION_TEMPLATE = """## Resumes
28
-
29
- This harness may pause your session, save your context to a database,
30
- terminate the underlying process, and later rehydrate it. From your
31
- point of view the conversation is fully continuous — you keep your
32
- prior context and will not "notice" the resume.
33
-
34
- **A resume can happen at any point, not only at the start.** The host
35
- environment may have changed across a resume — different host,
36
- different running processes, files outside this workdir gone — even
37
- though your context remembers everything as alive and well.
38
-
39
- **The workdir (this directory) is preserved across resumes, with two
40
- caveats:**
41
-
42
- - {excludes_clause}
43
- - **Anything outside the workdir is not preserved.**
44
-
45
- - **Your `home/.codex/` directory — the codex session store (rollout
46
- files under `home/.codex/sessions`, auth, config) — IS preserved
47
- across resumes** (minus the excluded paths above), so your history
48
- travels with you even when the underlying process and host change.
49
-
50
- {outside_clause}
51
-
52
- ### Detecting a resume: `resume.log`
53
-
54
- Each session start (fresh or resumed) appends one line to
55
- `./resume.log`. Line format:
56
-
57
- ```
58
- <ISO 8601 UTC timestamp>[ REFRESHED:<comma-separated filenames>]
59
- ```
60
-
61
- The very first line is the original launch timestamp; each subsequent
62
- line is a resume. The optional `REFRESHED:` suffix signals that the
63
- harness rewrote the listed files on that resume (e.g.
64
- `2026-05-28T13:15:42Z REFRESHED:AGENTS.md`) — your in-memory copy of
65
- those files is stale and must be re-read before continuing.
66
-
67
- **At the start of every new incoming user message, read
68
- `./resume.log` first.** Compare the latest line to the value you
69
- remembered last time you checked. If a new line has appeared, treat
70
- the situation as a resume:
71
-
72
- - Verify any tools, processes, or files you previously gathered
73
- outside the workdir are still where you left them.
74
- - Re-establish anything that's gone (re-launch a server, re-fetch a
75
- file, etc.) before continuing.
76
- - **If the latest line carries a `REFRESHED:` suffix, re-read each
77
- listed file** (e.g. `cat ./AGENTS.md`) — the harness updated it
78
- since your last context snapshot and the version you remember is
79
- out of date.
80
- - Then resume the work you were doing.
81
-
82
- If a resume slips past unnoticed, a failing tool call is the
83
- next-best signal — re-check `./resume.log` then.
84
-
85
- You may also be notified of a resume by a `System:` message on your input
86
- channel; when you see one, follow the `resume.log` procedure above.
87
- """
88
-
89
-
90
- def _render_resume_section(workdir_exclude: list[str] | None) -> str:
91
- """Render RESUME_SECTION_TEMPLATE with the EFFECTIVE exclude list.
92
-
93
- ``effective_workdir_exclude`` is the same resolver the snapshot archive
94
- uses (``None`` → the codex defaults), so what this section claims is
95
- preserved is exactly what the snapshot preserves.
96
- """
97
- effective = effective_workdir_exclude(workdir_exclude)
98
- if not effective:
99
- excludes_clause = (
100
- "**No paths are excluded** — every file in the workdir is preserved."
101
- )
102
- outside_clause = (
103
- "If you need to stash large data, place it outside the workdir "
104
- "(e.g. `/tmp/`) — but remember it may be missing when you next look."
105
- )
106
- else:
107
- excludes_str = ", ".join(f"`{p}`" for p in effective)
108
- excludes_clause = (
109
- f"**Paths matching the snapshot exclude list are NOT preserved**, "
110
- f"even inside the workdir. The current exclude list is: {excludes_str}."
111
- )
112
- outside_clause = (
113
- "If you need to stash large data, place it outside the workdir "
114
- "(e.g. `/tmp/`) or inside an excluded subdirectory — but remember "
115
- "any such location may be missing when you next look."
116
- )
117
- return RESUME_SECTION_TEMPLATE.format(
118
- excludes_clause=excludes_clause,
119
- outside_clause=outside_clause,
120
- )
121
-
122
-
123
- def compose_agents_md(
124
- consumer_instructions: str,
125
- *,
126
- documentation: str | None = None,
127
- host_protocol: bool = True,
128
- workdir_exclude: list[str] | None = None,
129
- supports_resume: bool = True,
130
- file_download: bool = False,
131
- ) -> str:
132
- """Build the full AGENTS.md body.
133
-
134
- ``documentation`` is the keyword-protocol block; the session passes
135
- ``get_protocol(browser="redirect").documentation``. Defaults (for unit
136
- tests / standalone callers) to codex's ``redirect`` docs. It must
137
- always come from the session's ``Protocol`` where one exists — never
138
- rebuild features at a second site.
139
-
140
- ``host_protocol=False`` omits the keyword-protocol documentation and
141
- instead includes a self-contained ``System:`` message explainer
142
- (guide Part 2D); iframe mode always runs with ``host_protocol=True``
143
- (validated in ``CodexTaskConfig``), the False branch serves
144
- conversation mode in a later stage.
145
-
146
- ``supports_resume=True`` (default) appends the resume-awareness section
147
- so the agent watches ``resume.log`` and knows ``home/.codex`` (minus
148
- ``workdir_exclude``) survives across resumes. ``workdir_exclude`` is
149
- this task's snapshot exclude list (None → the codex defaults), used to
150
- keep the section's claims in sync with what is actually preserved.
151
-
152
- ``file_download=True`` appends the downloadables instruction block so
153
- codex offers files to the human via the ``optio-file:`` sentinel link
154
- (conversation_ui file-download feature); the wording is comparative when
155
- the keyword protocol is active (``host_protocol``).
156
- """
157
- if file_download:
158
- consumer_instructions = (
159
- consumer_instructions.rstrip()
160
- + downloadables_block(comparative=host_protocol)
161
- )
162
- if host_protocol:
163
- if documentation is None:
164
- documentation = build_log_channel_prompt(
165
- ProtocolFeatures(browser="redirect")
166
- )
167
- else:
168
- documentation = None
169
- resume_section: str | None = (
170
- _render_resume_section(workdir_exclude) if supports_resume else None
171
- )
172
- if not host_protocol:
173
- # The protocol docs normally explain the `System:` convention;
174
- # without them the composed prompt carries its own explainer.
175
- resume_section = (
176
- resume_section + _SYSTEM_PREFIX_EXPLAINER
177
- if resume_section
178
- else _SYSTEM_PREFIX_EXPLAINER
179
- )
180
- return _compose_agents_md_host(
181
- consumer_instructions,
182
- documentation=documentation,
183
- resume_section=resume_section,
184
- )
File without changes
File without changes