@delorenj/pjangler 1.3.7 → 1.4.2
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.
- package/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +20 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +14320 -7847
- package/dist/mcp-server.js +13482 -8146
- package/package.json +4 -3
- package/templates/commonproject/copier.yml +14 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +11 -7
- package/templates/hermes-agent/template/.scripts/_lib.sh +54 -10
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +18 -2
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest import mock
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
SCRIPT = SKILL_ROOT / "scripts" / "project-hooks.py"
|
|
20
|
+
MASTER_PATH = SKILL_ROOT / "hooks" / "hooks.master.json"
|
|
21
|
+
FRAGMENT_PATH = SKILL_ROOT / "hooks" / "claude.settings.json"
|
|
22
|
+
OWNER_PREFIX = "PJ_HOOK_OWNER=project-notebook.v1 "
|
|
23
|
+
START_COMMAND = (
|
|
24
|
+
"PJ_HOOK_OWNER=project-notebook.v1 "
|
|
25
|
+
'"$HOME/.agents/skills/project-notebook/hooks/session-start.sh"'
|
|
26
|
+
)
|
|
27
|
+
END_COMMAND = (
|
|
28
|
+
'PJ_HOOK_OWNER=project-notebook.v1 "$HOME/.agents/skills/project-notebook/hooks/session-end.sh"'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_projector():
|
|
33
|
+
spec = importlib.util.spec_from_file_location("project_notebook_hooks_tested", SCRIPT)
|
|
34
|
+
if spec is None or spec.loader is None:
|
|
35
|
+
raise RuntimeError("cannot load projector")
|
|
36
|
+
module = importlib.util.module_from_spec(spec)
|
|
37
|
+
sys.modules[spec.name] = module
|
|
38
|
+
spec.loader.exec_module(module)
|
|
39
|
+
return module
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
PROJECTOR = _load_projector()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProjectHooksTests(unittest.TestCase):
|
|
46
|
+
maxDiff = None
|
|
47
|
+
|
|
48
|
+
def setUp(self) -> None:
|
|
49
|
+
self.temporary = tempfile.TemporaryDirectory()
|
|
50
|
+
self.addCleanup(self.temporary.cleanup)
|
|
51
|
+
self.root = Path(self.temporary.name)
|
|
52
|
+
self.claude = self.root / ".claude"
|
|
53
|
+
self.claude.mkdir(mode=0o700)
|
|
54
|
+
self.target = self.claude / "settings.json"
|
|
55
|
+
self.state_home = self.root / "state"
|
|
56
|
+
self.master = json.loads(MASTER_PATH.read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
def run_projector(self, *arguments: str) -> subprocess.CompletedProcess[str]:
|
|
59
|
+
environment = os.environ.copy()
|
|
60
|
+
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
61
|
+
return subprocess.run(
|
|
62
|
+
[sys.executable, "-B", str(SCRIPT), *arguments],
|
|
63
|
+
text=True,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
check=False,
|
|
66
|
+
env=environment,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def write_json(self, value: dict, *, compact: bool = False) -> bytes:
|
|
70
|
+
if compact:
|
|
71
|
+
raw = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode()
|
|
72
|
+
else:
|
|
73
|
+
raw = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
|
|
74
|
+
self.target.write_bytes(raw)
|
|
75
|
+
return raw
|
|
76
|
+
|
|
77
|
+
def mutation_arguments(self, command: str) -> list[str]:
|
|
78
|
+
return [
|
|
79
|
+
command,
|
|
80
|
+
"--master",
|
|
81
|
+
str(MASTER_PATH),
|
|
82
|
+
"--fragment",
|
|
83
|
+
str(FRAGMENT_PATH),
|
|
84
|
+
"--target",
|
|
85
|
+
str(self.target),
|
|
86
|
+
"--state-home",
|
|
87
|
+
str(self.state_home),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def canonical_hook(self, event: str) -> dict:
|
|
91
|
+
return copy.deepcopy(self.master["hooks"][event][0]["hooks"][0])
|
|
92
|
+
|
|
93
|
+
def make_fake_pj(
|
|
94
|
+
self, *, name: str = "trusted-home", exit_code: int = 0, hang: bool = False
|
|
95
|
+
) -> tuple[Path, Path]:
|
|
96
|
+
fake_home = self.root / name
|
|
97
|
+
local = fake_home / ".local"
|
|
98
|
+
binary_directory = local / "bin"
|
|
99
|
+
fake_home.mkdir(mode=0o700)
|
|
100
|
+
local.mkdir(mode=0o700)
|
|
101
|
+
binary_directory.mkdir(mode=0o700)
|
|
102
|
+
resolved_directory = fake_home / "apps" / "pjangler" / "dist"
|
|
103
|
+
resolved_directory.mkdir(parents=True, mode=0o770)
|
|
104
|
+
for directory in (
|
|
105
|
+
fake_home / "apps",
|
|
106
|
+
fake_home / "apps" / "pjangler",
|
|
107
|
+
resolved_directory,
|
|
108
|
+
):
|
|
109
|
+
directory.chmod(0o770)
|
|
110
|
+
resolved_binary = resolved_directory / "pj-real"
|
|
111
|
+
resolved_binary.write_text(
|
|
112
|
+
"const chunks = [];\n"
|
|
113
|
+
"process.stdin.on('data', (chunk) => chunks.push(chunk));\n"
|
|
114
|
+
"process.stdin.on('end', () => {\n"
|
|
115
|
+
" const report = {\n"
|
|
116
|
+
" argv: process.argv.slice(2),\n"
|
|
117
|
+
" bytes: Buffer.concat(chunks).length,\n"
|
|
118
|
+
" env: {\n"
|
|
119
|
+
" HOME: process.env.HOME,\n"
|
|
120
|
+
" USER: process.env.USER,\n"
|
|
121
|
+
" LOGNAME: process.env.LOGNAME,\n"
|
|
122
|
+
" PATH: process.env.PATH,\n"
|
|
123
|
+
" LANG: process.env.LANG,\n"
|
|
124
|
+
" LC_ALL: process.env.LC_ALL,\n"
|
|
125
|
+
" BASH_ENV: process.env.BASH_ENV ?? null,\n"
|
|
126
|
+
" NODE_OPTIONS: process.env.NODE_OPTIONS ?? null,\n"
|
|
127
|
+
" NODE_PATH: process.env.NODE_PATH ?? null,\n"
|
|
128
|
+
" authPresent: Object.hasOwn(process.env, 'OPEN_NOTEBOOK_PASSWORD'),\n"
|
|
129
|
+
" },\n"
|
|
130
|
+
" };\n"
|
|
131
|
+
" console.log(`trusted-pj ${JSON.stringify(report)}`);\n"
|
|
132
|
+
+ (" setInterval(() => {}, 10000);\n" if hang else f" process.exit({exit_code});\n")
|
|
133
|
+
+ "});\n",
|
|
134
|
+
encoding="utf-8",
|
|
135
|
+
)
|
|
136
|
+
resolved_binary.chmod(0o770)
|
|
137
|
+
launcher = binary_directory / "pj"
|
|
138
|
+
launcher.symlink_to(resolved_binary)
|
|
139
|
+
return fake_home, resolved_binary
|
|
140
|
+
|
|
141
|
+
def snapshot_tree(self, root: Path) -> dict[str, tuple[str, int, bytes | str]]:
|
|
142
|
+
snapshot: dict[str, tuple[str, int, bytes | str]] = {}
|
|
143
|
+
for path in sorted([root, *root.rglob("*")]):
|
|
144
|
+
relative = "." if path == root else str(path.relative_to(root))
|
|
145
|
+
mode = stat.S_IMODE(path.lstat().st_mode)
|
|
146
|
+
if path.is_symlink():
|
|
147
|
+
snapshot[relative] = ("symlink", mode, os.readlink(path))
|
|
148
|
+
elif path.is_dir():
|
|
149
|
+
snapshot[relative] = ("directory", mode, b"")
|
|
150
|
+
else:
|
|
151
|
+
snapshot[relative] = ("file", mode, path.read_bytes())
|
|
152
|
+
return snapshot
|
|
153
|
+
|
|
154
|
+
def run_wrapper(
|
|
155
|
+
self,
|
|
156
|
+
wrapper: str,
|
|
157
|
+
fake_home: Path,
|
|
158
|
+
payload: bytes,
|
|
159
|
+
*,
|
|
160
|
+
path_prefix: Path | None = None,
|
|
161
|
+
extra_environment: dict[str, str] | None = None,
|
|
162
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
163
|
+
instrumented_directory = self.root / "instrumented-wrappers"
|
|
164
|
+
instrumented_directory.mkdir(mode=0o700, exist_ok=True)
|
|
165
|
+
source = (SKILL_ROOT / "hooks" / wrapper).read_text(encoding="utf-8")
|
|
166
|
+
identity = pwd.getpwuid(os.getuid())
|
|
167
|
+
original_lookup = "entry = pwd.getpwuid(os.geteuid())"
|
|
168
|
+
replacement_lookup = (
|
|
169
|
+
"entry = pwd.struct_passwd(("
|
|
170
|
+
f"{identity.pw_name!r}, 'x', {identity.pw_uid}, {identity.pw_gid}, '', "
|
|
171
|
+
f"{str(fake_home)!r}, {identity.pw_shell!r}))"
|
|
172
|
+
)
|
|
173
|
+
self.assertIn(original_lookup, source)
|
|
174
|
+
instrumented = instrumented_directory / wrapper
|
|
175
|
+
instrumented.write_text(
|
|
176
|
+
source.replace(original_lookup, replacement_lookup, 1), encoding="utf-8"
|
|
177
|
+
)
|
|
178
|
+
instrumented.chmod(0o700)
|
|
179
|
+
environment = os.environ.copy()
|
|
180
|
+
environment["HOME"] = str(self.root / "attacker-controlled-home")
|
|
181
|
+
environment["USER"] = "attacker-user"
|
|
182
|
+
environment["LOGNAME"] = "attacker-logname"
|
|
183
|
+
environment["PATH"] = (
|
|
184
|
+
f"{path_prefix}:/usr/bin:/bin" if path_prefix is not None else "/usr/bin:/bin"
|
|
185
|
+
)
|
|
186
|
+
if extra_environment:
|
|
187
|
+
environment.update(extra_environment)
|
|
188
|
+
return subprocess.run(
|
|
189
|
+
[str(instrumented)],
|
|
190
|
+
input=payload,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
check=False,
|
|
193
|
+
env=environment,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def test_master_fragment_and_wrappers_are_exact(self) -> None:
|
|
197
|
+
self.assertEqual(MASTER_PATH.read_bytes(), FRAGMENT_PATH.read_bytes())
|
|
198
|
+
self.assertEqual(list(self.master["hooks"]), ["SessionStart", "SessionEnd"])
|
|
199
|
+
self.assertNotIn("Stop", self.master["hooks"])
|
|
200
|
+
self.assertEqual(self.canonical_hook("SessionStart")["command"], START_COMMAND)
|
|
201
|
+
self.assertEqual(self.canonical_hook("SessionStart")["timeout"], 3)
|
|
202
|
+
self.assertEqual(self.canonical_hook("SessionEnd")["command"], END_COMMAND)
|
|
203
|
+
self.assertEqual(self.canonical_hook("SessionEnd")["timeout"], 1)
|
|
204
|
+
|
|
205
|
+
start_wrapper = (SKILL_ROOT / "hooks" / "session-start.sh").read_text()
|
|
206
|
+
end_wrapper = (SKILL_ROOT / "hooks" / "session-end.sh").read_text()
|
|
207
|
+
self.assertTrue(start_wrapper.startswith("#!/usr/bin/python3 -I\n"))
|
|
208
|
+
self.assertTrue(end_wrapper.startswith("#!/usr/bin/python3 -I\n"))
|
|
209
|
+
self.assertIn('HOOK_EVENT = "session-start"', start_wrapper)
|
|
210
|
+
self.assertIn('HOOK_EVENT = "session-close"', end_wrapper)
|
|
211
|
+
self.assertIn("REQUEST_LIMIT_BYTES = 1_048_576", start_wrapper + end_wrapper)
|
|
212
|
+
self.assertIn("STREAM_LIMIT_BYTES = REQUEST_LIMIT_BYTES + 1", start_wrapper + end_wrapper)
|
|
213
|
+
self.assertIn("len(payload) > REQUEST_LIMIT_BYTES", start_wrapper + end_wrapper)
|
|
214
|
+
self.assertIn('NODE_BINARY = Path("/usr/bin/node")', start_wrapper + end_wrapper)
|
|
215
|
+
self.assertIn("pwd.getpwuid(os.geteuid())", start_wrapper + end_wrapper)
|
|
216
|
+
self.assertNotIn("#!/bin/bash", start_wrapper + end_wrapper)
|
|
217
|
+
self.assertNotIn("--payload-file", start_wrapper + end_wrapper)
|
|
218
|
+
self.assertNotIn("mktemp", start_wrapper + end_wrapper)
|
|
219
|
+
self.assertNotIn("XDG_STATE_HOME", start_wrapper + end_wrapper)
|
|
220
|
+
self.assertNotIn("notebook hook Stop", start_wrapper + end_wrapper)
|
|
221
|
+
|
|
222
|
+
def test_wrappers_stream_bounded_stdin_to_only_the_fixed_trusted_binary(self) -> None:
|
|
223
|
+
fake_home, _ = self.make_fake_pj()
|
|
224
|
+
self.assertTrue((fake_home / ".local" / "bin" / "pj").is_symlink())
|
|
225
|
+
malicious_directory = self.root / "malicious-path"
|
|
226
|
+
malicious_directory.mkdir(mode=0o700)
|
|
227
|
+
malicious = malicious_directory / "pj"
|
|
228
|
+
malicious.write_text(
|
|
229
|
+
"#!/usr/bin/python3 -I\nraise SystemExit('MALICIOUS')\n", encoding="utf-8"
|
|
230
|
+
)
|
|
231
|
+
malicious.chmod(0o700)
|
|
232
|
+
malicious_node = malicious_directory / "node"
|
|
233
|
+
malicious_node.write_text(
|
|
234
|
+
"#!/usr/bin/python3 -I\nraise SystemExit('MALICIOUS NODE')\n",
|
|
235
|
+
encoding="utf-8",
|
|
236
|
+
)
|
|
237
|
+
malicious_node.chmod(0o700)
|
|
238
|
+
bash_marker = self.root / "bash-env-executed"
|
|
239
|
+
bash_environment = self.root / "bash-environment"
|
|
240
|
+
bash_environment.write_text(f"/usr/bin/touch {bash_marker}\n", encoding="utf-8")
|
|
241
|
+
node_marker = self.root / "node-options-executed"
|
|
242
|
+
node_preload = self.root / "node-preload.js"
|
|
243
|
+
node_preload.write_text(
|
|
244
|
+
f"require('fs').writeFileSync({str(node_marker)!r}, 'executed');\n",
|
|
245
|
+
encoding="utf-8",
|
|
246
|
+
)
|
|
247
|
+
malicious_modules = self.root / "malicious-node-path"
|
|
248
|
+
malicious_modules.mkdir(mode=0o700)
|
|
249
|
+
injected_environment = {
|
|
250
|
+
"BASH_ENV": str(bash_environment),
|
|
251
|
+
"NODE_OPTIONS": f"--require={node_preload}",
|
|
252
|
+
"NODE_PATH": str(malicious_modules),
|
|
253
|
+
"OPEN_NOTEBOOK_PASSWORD": "test-only-placeholder",
|
|
254
|
+
}
|
|
255
|
+
before = self.snapshot_tree(fake_home)
|
|
256
|
+
payload = b"x" * 1048576
|
|
257
|
+
|
|
258
|
+
start = self.run_wrapper(
|
|
259
|
+
"session-start.sh",
|
|
260
|
+
fake_home,
|
|
261
|
+
payload,
|
|
262
|
+
path_prefix=malicious_directory,
|
|
263
|
+
extra_environment=injected_environment,
|
|
264
|
+
)
|
|
265
|
+
self.assertEqual(start.returncode, 0, start.stderr.decode())
|
|
266
|
+
start_report = json.loads(start.stdout.decode().removeprefix("trusted-pj "))
|
|
267
|
+
self.assertEqual(start_report["argv"], ["notebook", "hook", "session-start"])
|
|
268
|
+
self.assertEqual(start_report["bytes"], 1048576)
|
|
269
|
+
identity = pwd.getpwuid(os.getuid())
|
|
270
|
+
self.assertEqual(
|
|
271
|
+
start_report["env"],
|
|
272
|
+
{
|
|
273
|
+
"HOME": str(fake_home),
|
|
274
|
+
"USER": identity.pw_name,
|
|
275
|
+
"LOGNAME": identity.pw_name,
|
|
276
|
+
"PATH": "/usr/bin:/bin",
|
|
277
|
+
"LANG": "C.UTF-8",
|
|
278
|
+
"LC_ALL": "C.UTF-8",
|
|
279
|
+
"BASH_ENV": None,
|
|
280
|
+
"NODE_OPTIONS": None,
|
|
281
|
+
"NODE_PATH": None,
|
|
282
|
+
"authPresent": True,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
self.assertNotIn(b"MALICIOUS", start.stdout + start.stderr)
|
|
286
|
+
self.assertFalse(bash_marker.exists())
|
|
287
|
+
self.assertFalse(node_marker.exists())
|
|
288
|
+
self.assertFalse((self.root / "attacker-controlled-home").exists())
|
|
289
|
+
self.assertEqual(self.snapshot_tree(fake_home), before)
|
|
290
|
+
|
|
291
|
+
end = self.run_wrapper(
|
|
292
|
+
"session-end.sh",
|
|
293
|
+
fake_home,
|
|
294
|
+
payload,
|
|
295
|
+
path_prefix=malicious_directory,
|
|
296
|
+
extra_environment=injected_environment,
|
|
297
|
+
)
|
|
298
|
+
self.assertEqual(end.returncode, 0, end.stderr.decode())
|
|
299
|
+
end_report = json.loads(end.stdout.decode().removeprefix("trusted-pj "))
|
|
300
|
+
self.assertEqual(end_report["argv"], ["notebook", "hook", "session-close"])
|
|
301
|
+
self.assertEqual(end_report["bytes"], 1048576)
|
|
302
|
+
self.assertEqual(end_report["env"], start_report["env"])
|
|
303
|
+
self.assertNotIn(b"MALICIOUS", end.stdout + end.stderr)
|
|
304
|
+
self.assertFalse(bash_marker.exists())
|
|
305
|
+
self.assertFalse(node_marker.exists())
|
|
306
|
+
self.assertEqual(self.snapshot_tree(fake_home), before)
|
|
307
|
+
|
|
308
|
+
oversize_payload = b"x" * (1048576 + 8192)
|
|
309
|
+
for wrapper in ("session-start.sh", "session-end.sh"):
|
|
310
|
+
with self.subTest(wrapper=wrapper):
|
|
311
|
+
oversize = self.run_wrapper(
|
|
312
|
+
wrapper,
|
|
313
|
+
fake_home,
|
|
314
|
+
oversize_payload,
|
|
315
|
+
path_prefix=malicious_directory,
|
|
316
|
+
extra_environment=injected_environment,
|
|
317
|
+
)
|
|
318
|
+
self.assertEqual(oversize.returncode, 0)
|
|
319
|
+
self.assertEqual(oversize.stdout, b"")
|
|
320
|
+
self.assertIn(b"payload exceeds 1048576 bytes", oversize.stderr)
|
|
321
|
+
self.assertFalse(bash_marker.exists())
|
|
322
|
+
self.assertFalse(node_marker.exists())
|
|
323
|
+
self.assertEqual(self.snapshot_tree(fake_home), before)
|
|
324
|
+
|
|
325
|
+
def test_wrappers_fail_open_without_creating_state(self) -> None:
|
|
326
|
+
missing_home = self.root / "missing-pj-home"
|
|
327
|
+
missing_home.mkdir(mode=0o700)
|
|
328
|
+
before_missing = self.snapshot_tree(missing_home)
|
|
329
|
+
skipped = self.run_wrapper(
|
|
330
|
+
"session-start.sh",
|
|
331
|
+
missing_home,
|
|
332
|
+
b'{"disabled":true}',
|
|
333
|
+
)
|
|
334
|
+
self.assertEqual(skipped.returncode, 0)
|
|
335
|
+
self.assertIn(b"skipped; trusted path is unavailable", skipped.stderr)
|
|
336
|
+
self.assertEqual(self.snapshot_tree(missing_home), before_missing)
|
|
337
|
+
|
|
338
|
+
fake_home, binary = self.make_fake_pj(name="failed-home", exit_code=42)
|
|
339
|
+
before_failure = self.snapshot_tree(fake_home)
|
|
340
|
+
failed = self.run_wrapper(
|
|
341
|
+
"session-end.sh",
|
|
342
|
+
fake_home,
|
|
343
|
+
b'{"capture":false}',
|
|
344
|
+
)
|
|
345
|
+
self.assertEqual(failed.returncode, 0)
|
|
346
|
+
self.assertIn(b"failed open", failed.stderr)
|
|
347
|
+
self.assertEqual(self.snapshot_tree(fake_home), before_failure)
|
|
348
|
+
|
|
349
|
+
binary.chmod(0o772)
|
|
350
|
+
before_unsafe = self.snapshot_tree(fake_home)
|
|
351
|
+
unsafe = self.run_wrapper(
|
|
352
|
+
"session-start.sh",
|
|
353
|
+
fake_home,
|
|
354
|
+
b"ignored",
|
|
355
|
+
)
|
|
356
|
+
self.assertEqual(unsafe.returncode, 0)
|
|
357
|
+
self.assertIn(b"permissions are unsafe", unsafe.stderr)
|
|
358
|
+
self.assertNotIn(b"trusted-pj", unsafe.stdout)
|
|
359
|
+
self.assertEqual(self.snapshot_tree(fake_home), before_unsafe)
|
|
360
|
+
|
|
361
|
+
timeout_home, _ = self.make_fake_pj(name="timeout-home", hang=True)
|
|
362
|
+
before_timeout = self.snapshot_tree(timeout_home)
|
|
363
|
+
started = time.monotonic()
|
|
364
|
+
timed_out = self.run_wrapper("session-end.sh", timeout_home, b'{"policy":"disabled"}')
|
|
365
|
+
elapsed = time.monotonic() - started
|
|
366
|
+
self.assertEqual(timed_out.returncode, 0)
|
|
367
|
+
self.assertIn(b"timed out", timed_out.stderr)
|
|
368
|
+
self.assertLess(elapsed, 2.0)
|
|
369
|
+
self.assertEqual(self.snapshot_tree(timeout_home), before_timeout)
|
|
370
|
+
|
|
371
|
+
def test_render_is_deterministic_and_second_render_changes_zero_bytes(self) -> None:
|
|
372
|
+
rendered = self.root / "generated.json"
|
|
373
|
+
first = self.run_projector(
|
|
374
|
+
"render", "--master", str(MASTER_PATH), "--fragment", str(rendered)
|
|
375
|
+
)
|
|
376
|
+
self.assertEqual(first.returncode, 0, first.stderr)
|
|
377
|
+
first_bytes = rendered.read_bytes()
|
|
378
|
+
self.assertEqual(first_bytes, FRAGMENT_PATH.read_bytes())
|
|
379
|
+
first_stat = rendered.stat()
|
|
380
|
+
|
|
381
|
+
second = self.run_projector(
|
|
382
|
+
"render", "--master", str(MASTER_PATH), "--fragment", str(rendered)
|
|
383
|
+
)
|
|
384
|
+
self.assertEqual(second.returncode, 0, second.stderr)
|
|
385
|
+
self.assertIn("up to date", second.stdout)
|
|
386
|
+
self.assertEqual(rendered.read_bytes(), first_bytes)
|
|
387
|
+
self.assertEqual(rendered.stat().st_ino, first_stat.st_ino)
|
|
388
|
+
|
|
389
|
+
def test_check_is_pure_and_reports_all_required_finding_kinds(self) -> None:
|
|
390
|
+
stale = self.canonical_hook("SessionStart")
|
|
391
|
+
stale["timeout"] = 9
|
|
392
|
+
value = {
|
|
393
|
+
"theme": "dark",
|
|
394
|
+
"hooks": {
|
|
395
|
+
"SessionStart": [
|
|
396
|
+
{"hooks": [stale, self.canonical_hook("SessionStart")]},
|
|
397
|
+
{
|
|
398
|
+
"hooks": [
|
|
399
|
+
{
|
|
400
|
+
"type": "command",
|
|
401
|
+
"command": OWNER_PREFIX
|
|
402
|
+
+ '"$HOME/.agents/skills/project-notebook/hooks/unknown.sh"',
|
|
403
|
+
"timeout": 1,
|
|
404
|
+
}
|
|
405
|
+
]
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
"SessionEnd": [
|
|
409
|
+
{
|
|
410
|
+
"hooks": [
|
|
411
|
+
{
|
|
412
|
+
"type": "command",
|
|
413
|
+
"command": START_COMMAND,
|
|
414
|
+
"timeout": 3,
|
|
415
|
+
}
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
],
|
|
419
|
+
"Stop": [{"hooks": [{"type": "command", "command": "foreign-stop"}]}],
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
before = self.write_json(value, compact=True)
|
|
423
|
+
result = self.run_projector(
|
|
424
|
+
"check",
|
|
425
|
+
"--master",
|
|
426
|
+
str(MASTER_PATH),
|
|
427
|
+
"--fragment",
|
|
428
|
+
str(FRAGMENT_PATH),
|
|
429
|
+
"--target",
|
|
430
|
+
str(self.target),
|
|
431
|
+
"--json",
|
|
432
|
+
)
|
|
433
|
+
self.assertEqual(result.returncode, 1, result.stderr)
|
|
434
|
+
report = json.loads(result.stdout)
|
|
435
|
+
kinds = {finding["kind"] for finding in report["findings"]}
|
|
436
|
+
self.assertTrue({"missing", "duplicate", "stale", "foreign-conflict"} <= kinds)
|
|
437
|
+
self.assertEqual(self.target.read_bytes(), before)
|
|
438
|
+
self.assertFalse(self.state_home.exists(), "pure check created recovery state")
|
|
439
|
+
|
|
440
|
+
def test_install_preserves_foreign_objects_order_and_is_byte_idempotent(self) -> None:
|
|
441
|
+
stale = self.canonical_hook("SessionStart")
|
|
442
|
+
stale["timeout"] = 11
|
|
443
|
+
stale["owned_extra"] = "discard"
|
|
444
|
+
stop = [{"matcher": "foreign", "hooks": [{"type": "command", "command": "foreign-stop"}]}]
|
|
445
|
+
value = {
|
|
446
|
+
"theme": "dark",
|
|
447
|
+
"foreign_top": {"nested": [1, 2, 3]},
|
|
448
|
+
"hooks": {
|
|
449
|
+
"SessionStart": [
|
|
450
|
+
{
|
|
451
|
+
"matcher": "startup",
|
|
452
|
+
"condition": {"mode": "interactive"},
|
|
453
|
+
"hooks": [
|
|
454
|
+
{"type": "command", "command": "foreign-before", "timeout": 7},
|
|
455
|
+
stale,
|
|
456
|
+
{"type": "command", "command": "foreign-after", "extra": True},
|
|
457
|
+
],
|
|
458
|
+
"group_extra": "preserve",
|
|
459
|
+
},
|
|
460
|
+
{"hooks": [self.canonical_hook("SessionStart")]},
|
|
461
|
+
{
|
|
462
|
+
"matcher": "preserve-empty-group",
|
|
463
|
+
"hooks": [self.canonical_hook("SessionStart")],
|
|
464
|
+
"extra": {"preserve": True},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"label": "tail",
|
|
468
|
+
"hooks": [{"type": "command", "command": "foreign-tail"}],
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
"Stop": stop,
|
|
472
|
+
"Notification": [{"hooks": [{"type": "command", "command": "foreign-notify"}]}],
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
before = self.write_json(value, compact=True)
|
|
476
|
+
|
|
477
|
+
first = self.run_projector(*self.mutation_arguments("install"))
|
|
478
|
+
self.assertEqual(first.returncode, 0, first.stderr)
|
|
479
|
+
self.assertIn("changed", first.stdout)
|
|
480
|
+
installed = json.loads(self.target.read_text())
|
|
481
|
+
|
|
482
|
+
self.assertEqual(installed["theme"], value["theme"])
|
|
483
|
+
self.assertEqual(installed["foreign_top"], value["foreign_top"])
|
|
484
|
+
self.assertEqual(installed["hooks"]["Stop"], stop)
|
|
485
|
+
self.assertEqual(installed["hooks"]["Notification"], value["hooks"]["Notification"])
|
|
486
|
+
groups = installed["hooks"]["SessionStart"]
|
|
487
|
+
self.assertEqual(
|
|
488
|
+
[group.get("matcher") for group in groups], ["startup", "preserve-empty-group", None]
|
|
489
|
+
)
|
|
490
|
+
self.assertEqual(groups[0]["condition"], {"mode": "interactive"})
|
|
491
|
+
self.assertEqual(groups[0]["group_extra"], "preserve")
|
|
492
|
+
self.assertEqual(groups[0]["hooks"][0], value["hooks"]["SessionStart"][0]["hooks"][0])
|
|
493
|
+
self.assertEqual(groups[0]["hooks"][1], self.canonical_hook("SessionStart"))
|
|
494
|
+
self.assertEqual(groups[0]["hooks"][2], value["hooks"]["SessionStart"][0]["hooks"][2])
|
|
495
|
+
self.assertEqual(groups[1]["hooks"], [])
|
|
496
|
+
self.assertEqual(groups[1]["extra"], {"preserve": True})
|
|
497
|
+
self.assertEqual(groups[2]["label"], "tail")
|
|
498
|
+
self.assertEqual(installed["hooks"]["SessionEnd"], self.master["hooks"]["SessionEnd"])
|
|
499
|
+
|
|
500
|
+
snapshot = (
|
|
501
|
+
self.state_home
|
|
502
|
+
/ "pjangler"
|
|
503
|
+
/ "notebook"
|
|
504
|
+
/ "v1"
|
|
505
|
+
/ "hook-install"
|
|
506
|
+
/ "snapshots"
|
|
507
|
+
/ f"{hashlib.sha256(before).hexdigest()}.json"
|
|
508
|
+
)
|
|
509
|
+
self.assertEqual(snapshot.read_bytes(), before)
|
|
510
|
+
self.assertEqual(stat.S_IMODE(snapshot.stat().st_mode), 0o600)
|
|
511
|
+
self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o600)
|
|
512
|
+
first_bytes = self.target.read_bytes()
|
|
513
|
+
first_inode = self.target.stat().st_ino
|
|
514
|
+
snapshots_before = sorted(snapshot.parent.iterdir())
|
|
515
|
+
|
|
516
|
+
second = self.run_projector(*self.mutation_arguments("install"))
|
|
517
|
+
self.assertEqual(second.returncode, 0, second.stderr)
|
|
518
|
+
self.assertIn("up to date", second.stdout)
|
|
519
|
+
self.assertEqual(self.target.read_bytes(), first_bytes)
|
|
520
|
+
self.assertEqual(self.target.stat().st_ino, first_inode)
|
|
521
|
+
self.assertEqual(sorted(snapshot.parent.iterdir()), snapshots_before)
|
|
522
|
+
|
|
523
|
+
clean = self.run_projector(
|
|
524
|
+
"check",
|
|
525
|
+
"--master",
|
|
526
|
+
str(MASTER_PATH),
|
|
527
|
+
"--fragment",
|
|
528
|
+
str(FRAGMENT_PATH),
|
|
529
|
+
"--target",
|
|
530
|
+
str(self.target),
|
|
531
|
+
)
|
|
532
|
+
self.assertEqual(clean.returncode, 0, clean.stdout + clean.stderr)
|
|
533
|
+
|
|
534
|
+
def test_install_rereads_target_after_acquiring_lock(self) -> None:
|
|
535
|
+
self.write_json({"foreign": "preflight"})
|
|
536
|
+
late_value = {"foreign": "changed-between-preflight-and-lock", "late": [1, 2]}
|
|
537
|
+
original_read_target = PROJECTOR._read_target
|
|
538
|
+
original_read_target_at = PROJECTOR._read_target_at
|
|
539
|
+
path_calls = 0
|
|
540
|
+
descriptor_calls = 0
|
|
541
|
+
|
|
542
|
+
def mutate_after_preflight(path: Path):
|
|
543
|
+
nonlocal path_calls
|
|
544
|
+
result = original_read_target(path)
|
|
545
|
+
if path == self.target:
|
|
546
|
+
path_calls += 1
|
|
547
|
+
if path_calls == 1:
|
|
548
|
+
self.target.write_text(json.dumps(late_value), encoding="utf-8")
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
def count_descriptor_read(parent_fd: int, name: str):
|
|
552
|
+
nonlocal descriptor_calls
|
|
553
|
+
descriptor_calls += 1
|
|
554
|
+
return original_read_target_at(parent_fd, name)
|
|
555
|
+
|
|
556
|
+
with (
|
|
557
|
+
mock.patch.object(PROJECTOR, "_read_target", side_effect=mutate_after_preflight),
|
|
558
|
+
mock.patch.object(PROJECTOR, "_read_target_at", side_effect=count_descriptor_read),
|
|
559
|
+
):
|
|
560
|
+
changed = PROJECTOR._mutate(
|
|
561
|
+
"install", MASTER_PATH, FRAGMENT_PATH, self.target, self.state_home
|
|
562
|
+
)
|
|
563
|
+
self.assertTrue(changed)
|
|
564
|
+
installed = json.loads(self.target.read_text())
|
|
565
|
+
self.assertEqual(installed["foreign"], late_value["foreign"])
|
|
566
|
+
self.assertEqual(installed["late"], late_value["late"])
|
|
567
|
+
self.assertEqual(path_calls, 1)
|
|
568
|
+
self.assertGreaterEqual(descriptor_calls, 4)
|
|
569
|
+
|
|
570
|
+
def test_descriptor_relative_replace_survives_ancestor_symlink_swap(self) -> None:
|
|
571
|
+
self.write_json({"foreign": "pinned-parent"}, compact=True)
|
|
572
|
+
outside = self.root / "outside"
|
|
573
|
+
outside.mkdir(mode=0o700)
|
|
574
|
+
outside_target = outside / "settings.json"
|
|
575
|
+
outside_bytes = b'{"outside":"must-not-change"}\n'
|
|
576
|
+
outside_target.write_bytes(outside_bytes)
|
|
577
|
+
pinned_parent = self.root / "pinned-claude-parent"
|
|
578
|
+
original_replace = os.replace
|
|
579
|
+
swapped = False
|
|
580
|
+
|
|
581
|
+
def swap_parent_then_replace(
|
|
582
|
+
source: str,
|
|
583
|
+
destination: str,
|
|
584
|
+
*,
|
|
585
|
+
src_dir_fd: int | None = None,
|
|
586
|
+
dst_dir_fd: int | None = None,
|
|
587
|
+
) -> None:
|
|
588
|
+
nonlocal swapped
|
|
589
|
+
if destination == self.target.name and not swapped:
|
|
590
|
+
self.claude.rename(pinned_parent)
|
|
591
|
+
self.claude.symlink_to(outside, target_is_directory=True)
|
|
592
|
+
swapped = True
|
|
593
|
+
original_replace(
|
|
594
|
+
source,
|
|
595
|
+
destination,
|
|
596
|
+
src_dir_fd=src_dir_fd,
|
|
597
|
+
dst_dir_fd=dst_dir_fd,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
with mock.patch.object(PROJECTOR.os, "replace", side_effect=swap_parent_then_replace):
|
|
601
|
+
changed = PROJECTOR._mutate(
|
|
602
|
+
"install", MASTER_PATH, FRAGMENT_PATH, self.target, self.state_home
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
self.assertTrue(changed)
|
|
606
|
+
self.assertTrue(swapped)
|
|
607
|
+
self.assertTrue(self.claude.is_symlink())
|
|
608
|
+
self.assertEqual(outside_target.read_bytes(), outside_bytes)
|
|
609
|
+
self.assertEqual(sorted(path.name for path in outside.iterdir()), ["settings.json"])
|
|
610
|
+
installed = json.loads((pinned_parent / "settings.json").read_text())
|
|
611
|
+
self.assertEqual(installed["foreign"], "pinned-parent")
|
|
612
|
+
self.assertEqual(installed["hooks"], self.master["hooks"])
|
|
613
|
+
self.assertEqual(
|
|
614
|
+
[path.name for path in pinned_parent.iterdir() if path.name.startswith(".")],
|
|
615
|
+
[],
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def test_uninstall_removes_only_recognized_same_event_hooks(self) -> None:
|
|
619
|
+
unknown = {
|
|
620
|
+
"type": "command",
|
|
621
|
+
"command": OWNER_PREFIX + '"$HOME/.agents/skills/project-notebook/hooks/custom.sh"',
|
|
622
|
+
"timeout": 5,
|
|
623
|
+
}
|
|
624
|
+
prefix_similar = {
|
|
625
|
+
"type": "command",
|
|
626
|
+
"command": f"echo {START_COMMAND}",
|
|
627
|
+
"timeout": 5,
|
|
628
|
+
}
|
|
629
|
+
mismatched = {"type": "command", "command": END_COMMAND, "timeout": 1}
|
|
630
|
+
value = {
|
|
631
|
+
"hooks": {
|
|
632
|
+
"SessionStart": [
|
|
633
|
+
{"hooks": [self.canonical_hook("SessionStart")]},
|
|
634
|
+
{
|
|
635
|
+
"matcher": "keep-container",
|
|
636
|
+
"hooks": [self.canonical_hook("SessionStart")],
|
|
637
|
+
"condition": "foreign-extra",
|
|
638
|
+
},
|
|
639
|
+
{"hooks": [unknown]},
|
|
640
|
+
{"hooks": [prefix_similar]},
|
|
641
|
+
],
|
|
642
|
+
"SessionEnd": [{"hooks": [self.canonical_hook("SessionEnd")]}],
|
|
643
|
+
"Stop": [{"matcher": "stop", "hooks": [mismatched]}],
|
|
644
|
+
},
|
|
645
|
+
"other": {"untouched": True},
|
|
646
|
+
}
|
|
647
|
+
self.write_json(value)
|
|
648
|
+
first = self.run_projector(*self.mutation_arguments("uninstall"))
|
|
649
|
+
self.assertEqual(first.returncode, 0, first.stderr)
|
|
650
|
+
uninstalled = json.loads(self.target.read_text())
|
|
651
|
+
self.assertNotIn("SessionEnd", uninstalled["hooks"])
|
|
652
|
+
self.assertEqual(uninstalled["other"], value["other"])
|
|
653
|
+
self.assertEqual(uninstalled["hooks"]["Stop"], value["hooks"]["Stop"])
|
|
654
|
+
remaining = uninstalled["hooks"]["SessionStart"]
|
|
655
|
+
self.assertEqual(remaining[0]["matcher"], "keep-container")
|
|
656
|
+
self.assertEqual(remaining[0]["condition"], "foreign-extra")
|
|
657
|
+
self.assertEqual(remaining[0]["hooks"], [])
|
|
658
|
+
self.assertEqual(remaining[1]["hooks"], [unknown])
|
|
659
|
+
self.assertEqual(remaining[2]["hooks"], [prefix_similar])
|
|
660
|
+
|
|
661
|
+
before_second = self.target.read_bytes()
|
|
662
|
+
inode = self.target.stat().st_ino
|
|
663
|
+
second = self.run_projector(*self.mutation_arguments("uninstall"))
|
|
664
|
+
self.assertEqual(second.returncode, 0, second.stderr)
|
|
665
|
+
self.assertEqual(self.target.read_bytes(), before_second)
|
|
666
|
+
self.assertEqual(self.target.stat().st_ino, inode)
|
|
667
|
+
|
|
668
|
+
def test_invalid_or_symlink_target_is_rejected_before_state_mutation(self) -> None:
|
|
669
|
+
invalid = b'{"hooks": '
|
|
670
|
+
self.target.write_bytes(invalid)
|
|
671
|
+
result = self.run_projector(*self.mutation_arguments("install"))
|
|
672
|
+
self.assertEqual(result.returncode, 2)
|
|
673
|
+
self.assertEqual(self.target.read_bytes(), invalid)
|
|
674
|
+
self.assertFalse(self.state_home.exists())
|
|
675
|
+
|
|
676
|
+
self.target.unlink()
|
|
677
|
+
real_target = self.root / "real-settings.json"
|
|
678
|
+
real_target.write_text("{}\n", encoding="utf-8")
|
|
679
|
+
self.target.symlink_to(real_target)
|
|
680
|
+
result = self.run_projector(*self.mutation_arguments("install"))
|
|
681
|
+
self.assertEqual(result.returncode, 2)
|
|
682
|
+
self.assertEqual(real_target.read_text(), "{}\n")
|
|
683
|
+
self.assertFalse(self.state_home.exists())
|
|
684
|
+
|
|
685
|
+
def test_explicit_null_hook_containers_are_bounded_validation_errors(self) -> None:
|
|
686
|
+
cases = (
|
|
687
|
+
{"theme": "dark", "hooks": None},
|
|
688
|
+
{"hooks": {"SessionStart": [{"matcher": "x", "hooks": None}]}},
|
|
689
|
+
)
|
|
690
|
+
for index, value in enumerate(cases):
|
|
691
|
+
with self.subTest(index=index):
|
|
692
|
+
before = self.write_json(value, compact=True)
|
|
693
|
+
state_home = self.root / f"null-state-{index}"
|
|
694
|
+
result = self.run_projector(
|
|
695
|
+
"install",
|
|
696
|
+
"--master",
|
|
697
|
+
str(MASTER_PATH),
|
|
698
|
+
"--fragment",
|
|
699
|
+
str(FRAGMENT_PATH),
|
|
700
|
+
"--target",
|
|
701
|
+
str(self.target),
|
|
702
|
+
"--state-home",
|
|
703
|
+
str(state_home),
|
|
704
|
+
)
|
|
705
|
+
self.assertEqual(result.returncode, 2)
|
|
706
|
+
self.assertNotIn("Traceback", result.stderr)
|
|
707
|
+
self.assertIn("must be", result.stderr)
|
|
708
|
+
self.assertEqual(self.target.read_bytes(), before)
|
|
709
|
+
self.assertFalse(state_home.exists())
|
|
710
|
+
|
|
711
|
+
def test_all_projector_paths_reject_ancestor_symlinks_before_mutation(self) -> None:
|
|
712
|
+
real_target_parent = self.root / "real-target-parent"
|
|
713
|
+
real_target_parent.mkdir(mode=0o700)
|
|
714
|
+
linked_target_parent = self.root / "linked-target-parent"
|
|
715
|
+
linked_target_parent.symlink_to(real_target_parent, target_is_directory=True)
|
|
716
|
+
linked_target = linked_target_parent / "missing-tail" / "settings.json"
|
|
717
|
+
target_state = self.root / "target-symlink-state"
|
|
718
|
+
target_result = self.run_projector(
|
|
719
|
+
"install",
|
|
720
|
+
"--master",
|
|
721
|
+
str(MASTER_PATH),
|
|
722
|
+
"--fragment",
|
|
723
|
+
str(FRAGMENT_PATH),
|
|
724
|
+
"--target",
|
|
725
|
+
str(linked_target),
|
|
726
|
+
"--state-home",
|
|
727
|
+
str(target_state),
|
|
728
|
+
)
|
|
729
|
+
self.assertEqual(target_result.returncode, 2)
|
|
730
|
+
self.assertNotIn("Traceback", target_result.stderr)
|
|
731
|
+
self.assertFalse((real_target_parent / "missing-tail").exists())
|
|
732
|
+
self.assertFalse(target_state.exists())
|
|
733
|
+
|
|
734
|
+
self.write_json({"foreign": "preserve"}, compact=True)
|
|
735
|
+
target_before = self.target.read_bytes()
|
|
736
|
+
real_state = self.root / "real-state"
|
|
737
|
+
real_state.mkdir(mode=0o700)
|
|
738
|
+
linked_state = self.root / "linked-state"
|
|
739
|
+
linked_state.symlink_to(real_state, target_is_directory=True)
|
|
740
|
+
state_result = self.run_projector(
|
|
741
|
+
"install",
|
|
742
|
+
"--master",
|
|
743
|
+
str(MASTER_PATH),
|
|
744
|
+
"--fragment",
|
|
745
|
+
str(FRAGMENT_PATH),
|
|
746
|
+
"--target",
|
|
747
|
+
str(self.target),
|
|
748
|
+
"--state-home",
|
|
749
|
+
str(linked_state / "missing-tail"),
|
|
750
|
+
)
|
|
751
|
+
self.assertEqual(state_result.returncode, 2)
|
|
752
|
+
self.assertNotIn("Traceback", state_result.stderr)
|
|
753
|
+
self.assertEqual(self.target.read_bytes(), target_before)
|
|
754
|
+
self.assertEqual(list(real_state.iterdir()), [])
|
|
755
|
+
|
|
756
|
+
linked_assets = self.root / "linked-assets"
|
|
757
|
+
linked_assets.symlink_to(SKILL_ROOT / "hooks", target_is_directory=True)
|
|
758
|
+
fragment_state = self.root / "fragment-symlink-state"
|
|
759
|
+
fragment_result = self.run_projector(
|
|
760
|
+
"install",
|
|
761
|
+
"--master",
|
|
762
|
+
str(MASTER_PATH),
|
|
763
|
+
"--fragment",
|
|
764
|
+
str(linked_assets / "claude.settings.json"),
|
|
765
|
+
"--target",
|
|
766
|
+
str(self.target),
|
|
767
|
+
"--state-home",
|
|
768
|
+
str(fragment_state),
|
|
769
|
+
)
|
|
770
|
+
self.assertEqual(fragment_result.returncode, 2)
|
|
771
|
+
self.assertNotIn("Traceback", fragment_result.stderr)
|
|
772
|
+
self.assertEqual(self.target.read_bytes(), target_before)
|
|
773
|
+
self.assertFalse(fragment_state.exists())
|
|
774
|
+
|
|
775
|
+
def make_hook_install(state_home: Path) -> Path:
|
|
776
|
+
current = state_home
|
|
777
|
+
current.mkdir(mode=0o700)
|
|
778
|
+
for name in ("pjangler", "notebook", "v1", "hook-install"):
|
|
779
|
+
current = current / name
|
|
780
|
+
current.mkdir(mode=0o700)
|
|
781
|
+
return current
|
|
782
|
+
|
|
783
|
+
external_lock = self.root / "external-lock"
|
|
784
|
+
external_lock.write_text("foreign", encoding="utf-8")
|
|
785
|
+
lock_state = self.root / "lock-symlink-state"
|
|
786
|
+
lock_install = make_hook_install(lock_state)
|
|
787
|
+
(lock_install / "snapshots").mkdir(mode=0o700)
|
|
788
|
+
(lock_install / "lock").symlink_to(external_lock)
|
|
789
|
+
lock_result = self.run_projector(
|
|
790
|
+
"install",
|
|
791
|
+
"--master",
|
|
792
|
+
str(MASTER_PATH),
|
|
793
|
+
"--fragment",
|
|
794
|
+
str(FRAGMENT_PATH),
|
|
795
|
+
"--target",
|
|
796
|
+
str(self.target),
|
|
797
|
+
"--state-home",
|
|
798
|
+
str(lock_state),
|
|
799
|
+
)
|
|
800
|
+
self.assertEqual(lock_result.returncode, 2)
|
|
801
|
+
self.assertNotIn("Traceback", lock_result.stderr)
|
|
802
|
+
self.assertEqual(external_lock.read_text(), "foreign")
|
|
803
|
+
self.assertEqual(self.target.read_bytes(), target_before)
|
|
804
|
+
|
|
805
|
+
snapshot_state = self.root / "snapshot-symlink-state"
|
|
806
|
+
snapshot_install = make_hook_install(snapshot_state)
|
|
807
|
+
external_snapshots = self.root / "external-snapshots"
|
|
808
|
+
external_snapshots.mkdir(mode=0o700)
|
|
809
|
+
(snapshot_install / "snapshots").symlink_to(external_snapshots, target_is_directory=True)
|
|
810
|
+
snapshot_result = self.run_projector(
|
|
811
|
+
"install",
|
|
812
|
+
"--master",
|
|
813
|
+
str(MASTER_PATH),
|
|
814
|
+
"--fragment",
|
|
815
|
+
str(FRAGMENT_PATH),
|
|
816
|
+
"--target",
|
|
817
|
+
str(self.target),
|
|
818
|
+
"--state-home",
|
|
819
|
+
str(snapshot_state),
|
|
820
|
+
)
|
|
821
|
+
self.assertEqual(snapshot_result.returncode, 2)
|
|
822
|
+
self.assertNotIn("Traceback", snapshot_result.stderr)
|
|
823
|
+
self.assertFalse((snapshot_install / "lock").exists())
|
|
824
|
+
self.assertEqual(list(external_snapshots.iterdir()), [])
|
|
825
|
+
self.assertEqual(self.target.read_bytes(), target_before)
|
|
826
|
+
|
|
827
|
+
def test_absent_target_gets_recoverable_empty_object_snapshot(self) -> None:
|
|
828
|
+
self.claude.rmdir()
|
|
829
|
+
result = self.run_projector(*self.mutation_arguments("install"))
|
|
830
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
831
|
+
self.assertTrue(self.claude.is_dir())
|
|
832
|
+
self.assertEqual(stat.S_IMODE(self.claude.stat().st_mode), 0o700)
|
|
833
|
+
empty_digest = hashlib.sha256(b"{}\n").hexdigest()
|
|
834
|
+
snapshot = (
|
|
835
|
+
self.state_home
|
|
836
|
+
/ "pjangler"
|
|
837
|
+
/ "notebook"
|
|
838
|
+
/ "v1"
|
|
839
|
+
/ "hook-install"
|
|
840
|
+
/ "snapshots"
|
|
841
|
+
/ f"{empty_digest}.json"
|
|
842
|
+
)
|
|
843
|
+
self.assertEqual(snapshot.read_bytes(), b"{}\n")
|
|
844
|
+
self.assertEqual(json.loads(self.target.read_text()), self.master)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
if __name__ == "__main__":
|
|
848
|
+
unittest.main()
|