pactkit-codex 2.25.1__tar.gz → 2.26.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-codex
3
- Version: 2.25.1
3
+ Version: 2.26.0
4
4
  Summary: PactKit adapter for OpenAI Codex CLI
5
5
  Author: Slim
6
6
  License: MIT
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: pactkit<2.26.0,>=2.25.1
24
+ Requires-Dist: pactkit<2.27.0,>=2.26.0
25
25
  Requires-Dist: tomli>=1.1.0; python_version < "3.11"
26
26
  Dynamic: license-file
27
27
 
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-codex"
7
- version = "2.25.1"
7
+ version = "2.26.0"
8
8
  description = "PactKit adapter for OpenAI Codex CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.10"
12
12
  dependencies = [
13
- "pactkit>=2.25.1,<2.26.0",
13
+ "pactkit>=2.26.0,<2.27.0",
14
14
  "tomli>=1.1.0; python_version < '3.11'",
15
15
  ]
16
16
 
@@ -701,6 +701,14 @@ class CodexDeployer(DeployerBase):
701
701
  previous_hashes = files
702
702
  except (OSError, ValueError, TypeError):
703
703
  pass
704
+ # STORY-slim-20260903a24e1ece0d7f: merged ownership view — the deploy
705
+ # check previously read only .pactkit-deployed.json (which never carries
706
+ # reference paths), so every content change produced a .pactkit-new
707
+ # candidate. Same merge as _cleanup_stale_command_references.
708
+ ownership_proofs = {
709
+ **previous_hashes,
710
+ **read_command_references(codex_root / "skills"),
711
+ }
704
712
  rendered: dict[str, str] = {}
705
713
  references_by_command: dict[str, dict[Path, str]] = {}
706
714
 
@@ -844,7 +852,13 @@ class CodexDeployer(DeployerBase):
844
852
  )
845
853
  rendered_bytes = reference_content.encode("utf-8")
846
854
  relative_to_root = reference.relative_to(codex_root).as_posix()
847
- expected_hash = previous_hashes.get(relative_to_root)
855
+ # STORY-slim-20260903a24e1ece0d7f: merge the command-manifest
856
+ # references table into the ownership view — the deploy path
857
+ # previously read only .pactkit-deployed.json, which never
858
+ # carried reference paths, so every content change produced a
859
+ # .pactkit-new candidate (three recurrences on 2026-09-03).
860
+ # Same merged view as _cleanup_stale_command_references.
861
+ expected_hash = ownership_proofs.get(relative_to_root)
848
862
  if reference.is_file():
849
863
  actual_hash = hashlib.sha256(reference.read_bytes()).hexdigest()
850
864
  rendered_hash = hashlib.sha256(rendered_bytes).hexdigest()
@@ -909,15 +923,17 @@ class CodexDeployer(DeployerBase):
909
923
 
910
924
  @staticmethod
911
925
  def generate_codex_config_toml(codex_root):
912
- """Create config.toml for Codex CLI — but NEVER modify an existing one.
926
+ """Suggest Codex CLI settings — but NEVER write config.toml.
913
927
 
914
928
  config.toml carries the user's providers, MCP servers, project trust
915
- and other sensitive state. PactKit's share is two scalar defaults and
916
- one MCP entry — not worth any write risk. Policy (2026-08-13, user
917
- directive after two wipe incidents):
918
- - File exists -> leave it BYTE-IDENTICAL, print the recommended
919
- settings for the user to add by hand.
920
- - File missing -> create it with the PactKit-managed sections.
929
+ and other sensitive state. Policy (2026-08-13, user directive after
930
+ two wipe incidents; extended 2026-09-11 by
931
+ STORY-slim-20260911b2bbd79889e0 A2/R3+R4):
932
+ - File exists -> leave it BYTE-IDENTICAL.
933
+ - File missing -> ALSO leave it missing. Creating it seeded with
934
+ host-permission scalars and an MCP server was injection, not
935
+ convenience — the user starts their own config, and PactKit
936
+ prints what it would have suggested instead.
921
937
  """
922
938
  config_path = codex_root / "config.toml"
923
939
 
@@ -925,16 +941,12 @@ class CodexDeployer(DeployerBase):
925
941
  print(" ℹ️ config.toml exists — left untouched (PactKit never modifies it)")
926
942
  return
927
943
 
928
- lines = [
929
- "# [pactkit:managed]",
930
- 'sandbox_mode = "workspace-write"',
931
- 'approval_policy = "on-request"',
932
- "",
933
- "# [pactkit:managed]",
934
- "[mcp_servers.context7]",
935
- 'url = "https://mcp.context7.com/mcp"',
936
- ]
937
- atomic_write(config_path, chr(10).join(lines) + chr(10))
944
+ print(
945
+ " ℹ️ no config.toml — none created (PactKit never writes it).\n"
946
+ " If you want the settings PactKit was designed around, add by hand:\n"
947
+ ' sandbox_mode = "workspace-write"\n'
948
+ ' approval_policy = "on-request"'
949
+ )
938
950
 
939
951
  @staticmethod
940
952
  def generate_codex_project_files(project_root):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-codex
3
- Version: 2.25.1
3
+ Version: 2.26.0
4
4
  Summary: PactKit adapter for OpenAI Codex CLI
5
5
  Author: Slim
6
6
  License: MIT
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: pactkit<2.26.0,>=2.25.1
24
+ Requires-Dist: pactkit<2.27.0,>=2.26.0
25
25
  Requires-Dist: tomli>=1.1.0; python_version < "3.11"
26
26
  Dynamic: license-file
27
27
 
@@ -8,4 +8,5 @@ src/pactkit_codex.egg-info/SOURCES.txt
8
8
  src/pactkit_codex.egg-info/dependency_links.txt
9
9
  src/pactkit_codex.egg-info/entry_points.txt
10
10
  src/pactkit_codex.egg-info/requires.txt
11
- src/pactkit_codex.egg-info/top_level.txt
11
+ src/pactkit_codex.egg-info/top_level.txt
12
+ tests/test_host_body_snapshot.py
@@ -1,4 +1,4 @@
1
- pactkit<2.26.0,>=2.25.1
1
+ pactkit<2.27.0,>=2.26.0
2
2
 
3
3
  [:python_version < "3.11"]
4
4
  tomli>=1.1.0
@@ -0,0 +1,126 @@
1
+ """This host's deployed prompt bodies, pinned against a golden (R7/AC10).
2
+
3
+ 2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
4
+ left adapter-host body comparison out of CI — the core workflow compares
5
+ classic only. This suite pins the CODEX deployment's prompt bodies:
6
+
7
+ - digest drift (a rule edited or deleted inside a prompt body) fails,
8
+ naming the file;
9
+ - a core version mismatch fails with an explicit action — regenerate the
10
+ golden against the new core, or publish the matching core release
11
+ (PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
12
+ - a probe test deploys, edits one body in place, and proves the SAME
13
+ comparison logic flags it (删改必被检出, not vacuous).
14
+
15
+ Regenerate with: tests/scripts/refresh_host_body_golden.py
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ HOST = "codex"
29
+ GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
30
+ DEPLOY_SUFFIXES = (".md", ".json", ".toml")
31
+
32
+
33
+ def _core_version() -> str:
34
+ import importlib.metadata as m
35
+
36
+ return m.version("pactkit")
37
+
38
+
39
+ def _normalize(content: str, deploy_root: str) -> str:
40
+ """Strip only the deterministic environment difference: the deploy root
41
+ path baked into rendered prompts. Rule text stays — normalizing it would
42
+ swallow the drift this suite exists to catch."""
43
+ return content.replace(deploy_root, "<DEPLOY_ROOT>")
44
+
45
+
46
+ def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
47
+ home = tmp_path / "home"
48
+ target = tmp_path / "deploy"
49
+ home.mkdir()
50
+ proc = subprocess.run(
51
+ [sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
52
+ capture_output=True, text=True, timeout=180,
53
+ env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
54
+ )
55
+ if proc.returncode != 0:
56
+ return None, proc
57
+ digests: dict[str, str] = {}
58
+ for path in sorted(target.rglob("*")):
59
+ if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
60
+ continue
61
+ body = _normalize(path.read_text(encoding="utf-8"), str(target))
62
+ digests[path.relative_to(target).as_posix()] = hashlib.sha256(
63
+ body.encode("utf-8")
64
+ ).hexdigest()
65
+ return digests, proc
66
+
67
+
68
+ def _assert_no_drift(golden_digests: dict[str, str], live: dict[str, str]) -> None:
69
+ """The one comparison both the golden test and the tamper probe use."""
70
+ changed = sorted(
71
+ k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
72
+ )
73
+ removed = sorted(set(golden_digests) - set(live))
74
+ added = sorted(set(live) - set(golden_digests))
75
+ assert not (changed or removed or added), (
76
+ f"prompt body drift: changed={changed} removed={removed} added={added} "
77
+ f"— if the core prompts changed, regenerate the golden "
78
+ f"(tests/scripts/refresh_host_body_golden.py) or publish the matching "
79
+ f"core release first (PENDING-COMPATIBLE-CORE)"
80
+ )
81
+
82
+
83
+ class TestHostBodySnapshot:
84
+ def test_bodies_match_the_golden(self, tmp_path):
85
+ golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
86
+ core_v = _core_version()
87
+ if golden["core_version"] != core_v:
88
+ pytest.fail(
89
+ f"PENDING-COMPATIBLE-CORE: the {HOST} golden was generated "
90
+ f"against core {golden['core_version']}, this environment has "
91
+ f"{core_v}. Either regenerate it after the core change "
92
+ f"(tests/scripts/refresh_host_body_golden.py) or publish the "
93
+ f"matching core release first (spec R8 release-window semantics)."
94
+ )
95
+ live, proc = _deployed_digests(tmp_path)
96
+ assert live is not None, (
97
+ f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
98
+ )
99
+ _assert_no_drift(golden["body_digests"], live)
100
+
101
+ def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
102
+ """Non-vacuity, end to end: deploy, edit one prompt body in place,
103
+ and the SAME comparison that guards the golden must flag it."""
104
+ golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
105
+ live, proc = _deployed_digests(tmp_path)
106
+ assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
107
+ target = tmp_path / "deploy"
108
+ # Pick a real deployed prompt body and delete one rule line from it.
109
+ victims = sorted(
110
+ p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
111
+ )
112
+ assert victims, "no deployed .md body found to tamper with"
113
+ victim = victims[0]
114
+ rel = victim.relative_to(target).as_posix()
115
+ body = victim.read_text(encoding="utf-8")
116
+ victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
117
+
118
+ tampered = {
119
+ p.relative_to(target).as_posix(): hashlib.sha256(
120
+ _normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
121
+ ).hexdigest()
122
+ for p in sorted(target.rglob("*"))
123
+ if p.is_file() and p.suffix in DEPLOY_SUFFIXES
124
+ }
125
+ with pytest.raises(AssertionError, match=rel):
126
+ _assert_no_drift(golden["body_digests"], tampered)
File without changes
File without changes
File without changes