code2okf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code2okf/SPEC.md +1006 -0
- code2okf/__init__.py +8 -0
- code2okf/cli.py +234 -0
- code2okf/clis/inspectmd/pyproject.toml +40 -0
- code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
- code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
- code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
- code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
- code2okf/clis/inspectokf/pyproject.toml +40 -0
- code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
- code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
- code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
- code2okf/clis/merkleokf/pyproject.toml +40 -0
- code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
- code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
- code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
- code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
- code2okf/clis/sizeokf/pyproject.toml +40 -0
- code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
- code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
- code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
- code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
- code2okf/compile.py +267 -0
- code2okf/events.py +86 -0
- code2okf/kit/README.md +128 -0
- code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
- code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
- code2okf/kit/files/home/.pi/agent/models.json +84 -0
- code2okf/kit/files/home/.pi/agent/settings.json +7 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
- code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
- code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
- code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
- code2okf/kit/spec.yaml +312 -0
- code2okf/resources.py +74 -0
- code2okf/sandbox.py +266 -0
- code2okf/workbench.py +572 -0
- code2okf-0.1.0.dist-info/METADATA +391 -0
- code2okf-0.1.0.dist-info/RECORD +48 -0
- code2okf-0.1.0.dist-info/WHEEL +4 -0
- code2okf-0.1.0.dist-info/entry_points.txt +2 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
- code2okf-0.1.0.dist-info/licenses/NOTICE-OKF-SPEC.md +37 -0
code2okf/workbench.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""The workbench: a fixed staging area for one sandbox to serve any run.
|
|
2
|
+
|
|
3
|
+
It reproduces the sibling layout `kits/code2okf`'s agent config assumes for
|
|
4
|
+
whatever -o/inputs a run is given. See .claude/plans/interface-plan.md,
|
|
5
|
+
"The workbench".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from code2okf import resources, sandbox
|
|
21
|
+
|
|
22
|
+
LOCK_PATH_TEMPLATE = "/tmp/code2okf-{uid}.lock" # noqa: S108 -- deliberately outside XDG_STATE_HOME
|
|
23
|
+
SANDBOX_NAME = "code2okf"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorkbenchError(Exception):
|
|
27
|
+
"""A checked failure preparing, staging, or mirroring the workbench."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsafeLockFile(WorkbenchError):
|
|
31
|
+
"""The per-user lock path is not a regular file this user owns."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LockHeld(WorkbenchError):
|
|
35
|
+
"""Another code2okf run already holds the sandbox lock."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UnownedSandboxError(WorkbenchError):
|
|
39
|
+
"""A sandbox called `name` exists but cannot be proven to be ours.
|
|
40
|
+
|
|
41
|
+
Never auto-deleted -- the fix is the removal command in the message.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str) -> None:
|
|
45
|
+
"""Build the message naming the manual removal command."""
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"a sandbox called {name!r} exists but is not recognisably ours; "
|
|
48
|
+
f"remove it yourself first: sbx rm --force {name}"
|
|
49
|
+
)
|
|
50
|
+
self.name = name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KeyNotProxyManagedError(WorkbenchError):
|
|
54
|
+
"""OPENROUTER_API_KEY inside a freshly created sandbox is not proxy-managed."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, name: str) -> None:
|
|
57
|
+
"""Build the message naming the two `sbx secret` remediation commands.
|
|
58
|
+
|
|
59
|
+
Regression: this used to name `sbx secret set github ...` -- the
|
|
60
|
+
wrong provider entirely, copied from an unrelated GitHub-auth
|
|
61
|
+
pattern. The actual two-step OpenRouter setup is README.md's
|
|
62
|
+
"Set up the OpenRouter key" section.
|
|
63
|
+
"""
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"OPENROUTER_API_KEY inside {name!r} is not proxy-managed.\n"
|
|
66
|
+
" Set it via sbx secret (see README.md, \"Set up the OpenRouter key\"):\n"
|
|
67
|
+
' echo "$OPENROUTER_API_KEY" | sbx secret set openrouter\n'
|
|
68
|
+
f" sbx secret set-custom --sandbox {name} --host openrouter.ai "
|
|
69
|
+
'--env OPENROUTER_API_KEY --value "$OPENROUTER_API_KEY"'
|
|
70
|
+
)
|
|
71
|
+
self.name = name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MirrorError(WorkbenchError):
|
|
75
|
+
"""Mirroring the wiki out to -o DIR failed; the good copy stays here."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str, workbench_path: Path) -> None:
|
|
78
|
+
"""Record where the still-good copy of the wiki was left."""
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.workbench_path = workbench_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def state_home() -> Path:
|
|
84
|
+
"""XDG_STATE_HOME with its documented precedence.
|
|
85
|
+
|
|
86
|
+
An exported absolute value wins, a relative value counts as unset, and
|
|
87
|
+
the default is ~/.local/state.
|
|
88
|
+
"""
|
|
89
|
+
raw = os.environ.get("XDG_STATE_HOME", "")
|
|
90
|
+
if raw and Path(raw).is_absolute():
|
|
91
|
+
return Path(raw)
|
|
92
|
+
return Path.home() / ".local" / "state"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@contextlib.contextmanager
|
|
96
|
+
def lock():
|
|
97
|
+
"""Acquire the non-blocking, per-user sandbox lock.
|
|
98
|
+
|
|
99
|
+
Raises LockHeld immediately rather than queueing behind another run.
|
|
100
|
+
Deliberately not under XDG_STATE_HOME: two shells with different
|
|
101
|
+
XDG_STATE_HOME values must still race on the one lock, not take two.
|
|
102
|
+
|
|
103
|
+
That fixed, predictable path sits in a world-writable directory, so the
|
|
104
|
+
file it names is not trusted until it has been checked: O_NOFOLLOW refuses
|
|
105
|
+
a symlink another user planted there, and the fstat that follows refuses a
|
|
106
|
+
FIFO or a file somebody else owns -- which would otherwise let them hold
|
|
107
|
+
this lock for ever, or drop it and let two of our own runs share the one
|
|
108
|
+
sandbox. Both are refusals, never a silent unlink: removing a file we do
|
|
109
|
+
not own is the caller's decision, not ours.
|
|
110
|
+
"""
|
|
111
|
+
path = Path(LOCK_PATH_TEMPLATE.format(uid=os.getuid()))
|
|
112
|
+
try:
|
|
113
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise UnsafeLockFile(f"cannot open the lock file {path}: {exc.strerror}; remove it and retry") from exc
|
|
116
|
+
try:
|
|
117
|
+
info = os.fstat(fd)
|
|
118
|
+
if not stat.S_ISREG(info.st_mode):
|
|
119
|
+
raise UnsafeLockFile(f"the lock path {path} is not a regular file; remove it and retry")
|
|
120
|
+
if info.st_uid != os.getuid():
|
|
121
|
+
raise UnsafeLockFile(f"the lock file {path} is owned by uid {info.st_uid}, not by you; remove it and retry")
|
|
122
|
+
except BaseException:
|
|
123
|
+
os.close(fd)
|
|
124
|
+
raise
|
|
125
|
+
try:
|
|
126
|
+
try:
|
|
127
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise LockHeld(str(path)) from exc
|
|
130
|
+
yield
|
|
131
|
+
finally:
|
|
132
|
+
with contextlib.suppress(OSError):
|
|
133
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
134
|
+
os.close(fd)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Workbench:
|
|
139
|
+
"""The fixed staging layout for one host, rooted at state_home()/code2okf."""
|
|
140
|
+
|
|
141
|
+
root: Path
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def default(cls) -> Workbench:
|
|
145
|
+
"""The workbench rooted at the current XDG_STATE_HOME."""
|
|
146
|
+
return cls(root=state_home() / "code2okf")
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def work(self) -> Path:
|
|
150
|
+
"""Parent of the three workspace-backed mounts; never mounted itself."""
|
|
151
|
+
return self.root / "work"
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def work_okf(self) -> Path:
|
|
155
|
+
"""The primary, read-write mount: the wiki, mirrored to/from -o DIR."""
|
|
156
|
+
return self.work / "okf"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def work_md(self) -> Path:
|
|
160
|
+
"""Read-only mount: this run's staged input documents."""
|
|
161
|
+
return self.work / "md"
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def work_scripts(self) -> Path:
|
|
165
|
+
"""Read-only mount: the four helper CLI projects."""
|
|
166
|
+
return self.work / "scripts"
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def work_spec(self) -> Path:
|
|
170
|
+
"""Read-only mount: the OKF spec for this run."""
|
|
171
|
+
return self.work / "SPEC.md"
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def sessions(self) -> Path:
|
|
175
|
+
"""Read-write mount: Pi's persistent transcripts. The only mounted state path."""
|
|
176
|
+
return self.root / "sessions"
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def fingerprint_path(self) -> Path:
|
|
180
|
+
"""Host-only: the last successful create's configuration fingerprint."""
|
|
181
|
+
return self.root / "sandbox-fingerprint"
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def identity_path(self) -> Path:
|
|
185
|
+
"""Host-only: the last successful create's recorded sandbox identity."""
|
|
186
|
+
return self.root / "sandbox-identity"
|
|
187
|
+
|
|
188
|
+
def mounts(self) -> list[sandbox.Mount]:
|
|
189
|
+
"""The five `sbx run` workspace arguments, in the fixed order."""
|
|
190
|
+
return [
|
|
191
|
+
sandbox.Mount(self.work_okf),
|
|
192
|
+
sandbox.Mount(self.work_md, readonly=True),
|
|
193
|
+
sandbox.Mount(self.work_scripts, readonly=True),
|
|
194
|
+
sandbox.Mount(self.work_spec, readonly=True),
|
|
195
|
+
sandbox.Mount(self.sessions),
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
def ensure_roots(self) -> None:
|
|
199
|
+
"""Create the five mount sources if missing; never replace one that exists.
|
|
200
|
+
|
|
201
|
+
Must run before the first `sbx run` -- sbx cannot mount a path that
|
|
202
|
+
does not exist -- and never again touches these root objects, only
|
|
203
|
+
their contents (see restage()).
|
|
204
|
+
"""
|
|
205
|
+
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
206
|
+
os.chmod(self.root, 0o700)
|
|
207
|
+
self.work.mkdir(mode=0o700, exist_ok=True)
|
|
208
|
+
self.work_okf.mkdir(mode=0o700, exist_ok=True)
|
|
209
|
+
self.work_md.mkdir(mode=0o700, exist_ok=True)
|
|
210
|
+
self.work_scripts.mkdir(mode=0o700, exist_ok=True)
|
|
211
|
+
if not self.work_spec.exists():
|
|
212
|
+
self.work_spec.touch(mode=0o600)
|
|
213
|
+
self.sessions.mkdir(mode=0o700, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def reject_if_unsafe(path: Path, *, what: str) -> None:
|
|
217
|
+
"""Reject a symlink or special file (device, socket, FIFO) at `path`.
|
|
218
|
+
|
|
219
|
+
A missing path is not an error here -- callers that need existence check
|
|
220
|
+
it themselves with a clearer message.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
info = path.lstat()
|
|
224
|
+
except FileNotFoundError:
|
|
225
|
+
return
|
|
226
|
+
if stat.S_ISLNK(info.st_mode):
|
|
227
|
+
raise WorkbenchError(f"{what} may not be a symlink: {path}")
|
|
228
|
+
if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
|
|
229
|
+
raise WorkbenchError(f"{what} may not be a device, socket, or FIFO: {path}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_no_overlap(paths: Iterable[Path]) -> None:
|
|
233
|
+
"""Refuse when any two of `paths`, resolved, are equal or one nests the other.
|
|
234
|
+
|
|
235
|
+
Without this, an output inside an input (or either inside the
|
|
236
|
+
workbench) would become a recursive copy or a run that eats its own
|
|
237
|
+
output.
|
|
238
|
+
"""
|
|
239
|
+
originals = list(paths)
|
|
240
|
+
resolved = [p.resolve() for p in originals]
|
|
241
|
+
for i in range(len(resolved)):
|
|
242
|
+
for j in range(len(resolved)):
|
|
243
|
+
if i != j and (resolved[i] == resolved[j] or resolved[j] in resolved[i].parents):
|
|
244
|
+
raise WorkbenchError(f"paths overlap: {originals[i]} and {originals[j]}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def has_markdown(directory: Path) -> bool:
|
|
248
|
+
"""Whether `directory` contains at least one *.md file, at any depth."""
|
|
249
|
+
return next(directory.rglob("*.md"), None) is not None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _clear_children(root: Path) -> None:
|
|
253
|
+
for child in root.iterdir():
|
|
254
|
+
if child.is_symlink() or child.is_file():
|
|
255
|
+
child.unlink()
|
|
256
|
+
else:
|
|
257
|
+
shutil.rmtree(child)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _copy_tree_children(src: Path, dst: Path) -> None:
|
|
261
|
+
for child in sorted(src.iterdir()):
|
|
262
|
+
reject_if_unsafe(child, what="a mirrored source entry")
|
|
263
|
+
target = dst / child.name
|
|
264
|
+
if child.is_dir():
|
|
265
|
+
target.mkdir()
|
|
266
|
+
_copy_tree_children(child, target)
|
|
267
|
+
else:
|
|
268
|
+
shutil.copy2(child, target)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def sync_children(src: Path | None, dst: Path) -> None:
|
|
272
|
+
"""Make dst's children exactly mirror src's, without replacing dst itself.
|
|
273
|
+
|
|
274
|
+
`src=None` (or a missing path) empties dst. Never creates or follows a
|
|
275
|
+
symlink; a symlink or special file met while clearing dst is removed by
|
|
276
|
+
name, never through it, and one met while copying from src is rejected.
|
|
277
|
+
"""
|
|
278
|
+
_clear_children(dst)
|
|
279
|
+
if src is not None and src.exists():
|
|
280
|
+
reject_if_unsafe(src, what="a mirror source")
|
|
281
|
+
_copy_tree_children(src, dst)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def stage_inputs(work_md: Path, items: Iterable[tuple[str, Path | bytes]]) -> None:
|
|
285
|
+
"""Clear work_md and copy each (basename, source) pair into it.
|
|
286
|
+
|
|
287
|
+
`source` is a filesystem path to copy, or raw bytes (stdin's content,
|
|
288
|
+
staged as stdin.md).
|
|
289
|
+
"""
|
|
290
|
+
_clear_children(work_md)
|
|
291
|
+
for basename, source in items:
|
|
292
|
+
target = work_md / basename
|
|
293
|
+
if isinstance(source, bytes):
|
|
294
|
+
target.write_bytes(source)
|
|
295
|
+
else:
|
|
296
|
+
reject_if_unsafe(source, what="an input document")
|
|
297
|
+
shutil.copy2(source, target)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def stage_clis(clis_root: Path, work_scripts: Path) -> None:
|
|
301
|
+
"""Stage the four helper CLI projects into work_scripts.
|
|
302
|
+
|
|
303
|
+
Only pyproject.toml and src/ per project, mirroring what the installed
|
|
304
|
+
package ships (see "Packaging" in the plan) -- never a project's .venv,
|
|
305
|
+
caches, tests, or lockfile, which the checkout's scripts/ carries but a
|
|
306
|
+
sandboxed `uv tool run --from` never needs. This is what keeps a stray
|
|
307
|
+
.venv symlink from ever reaching sync_children's symlink rejection.
|
|
308
|
+
"""
|
|
309
|
+
_clear_children(work_scripts)
|
|
310
|
+
if not clis_root.exists():
|
|
311
|
+
return
|
|
312
|
+
for project in sorted(clis_root.iterdir()):
|
|
313
|
+
if not project.is_dir() or project.is_symlink():
|
|
314
|
+
continue
|
|
315
|
+
pyproject = project / "pyproject.toml"
|
|
316
|
+
src = project / "src"
|
|
317
|
+
if not pyproject.is_file() or not src.is_dir():
|
|
318
|
+
continue
|
|
319
|
+
reject_if_unsafe(pyproject, what="a helper CLI's pyproject.toml")
|
|
320
|
+
target = work_scripts / project.name
|
|
321
|
+
target.mkdir()
|
|
322
|
+
shutil.copy2(pyproject, target / "pyproject.toml")
|
|
323
|
+
target_src = target / "src"
|
|
324
|
+
target_src.mkdir()
|
|
325
|
+
_copy_tree_children(src, target_src)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def rewrite_spec(work_spec: Path, spec_source: Path) -> None:
|
|
329
|
+
"""Rewrite work_spec's content in place -- truncate and write, never rename over it."""
|
|
330
|
+
reject_if_unsafe(spec_source, what="--spec")
|
|
331
|
+
work_spec.write_bytes(spec_source.read_bytes())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def mirror_in(work_okf: Path, output_dir: Path) -> None:
|
|
335
|
+
"""Mirror -o DIR into work_okf, so an existing wiki is continued, not restarted."""
|
|
336
|
+
sync_children(output_dir if output_dir.exists() else None, work_okf)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def mirror_out(work_okf: Path, output_dir: Path) -> None:
|
|
340
|
+
"""Mirror work_okf back out to -o DIR. A failure here is a failed run."""
|
|
341
|
+
try:
|
|
342
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
sync_children(work_okf, output_dir)
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
# The workbench path is folded into the message itself, not left as
|
|
346
|
+
# an attribute a caller has to know to read: str(exc) is what every
|
|
347
|
+
# caller actually prints, and the whole point of naming it is that
|
|
348
|
+
# the completed work is recoverable from there.
|
|
349
|
+
raise MirrorError(
|
|
350
|
+
f"mirroring the wiki out to {output_dir} failed: {exc}; the completed work is still at {work_okf}",
|
|
351
|
+
work_okf,
|
|
352
|
+
) from exc
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def restage(
|
|
356
|
+
wb: Workbench,
|
|
357
|
+
*,
|
|
358
|
+
inputs: Iterable[tuple[str, Path | bytes]],
|
|
359
|
+
clis_dir: Path,
|
|
360
|
+
spec_source: Path,
|
|
361
|
+
output_dir: Path,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Refill the workbench's children for one run.
|
|
364
|
+
|
|
365
|
+
Never replaces the five mount root objects (see Workbench.ensure_roots);
|
|
366
|
+
only their contents change, which is why one sandbox can serve any number
|
|
367
|
+
of runs against different inputs and outputs. Any OSError along the way
|
|
368
|
+
(disk full, a permission error) becomes a WorkbenchError, so a caller
|
|
369
|
+
that only catches WorkbenchError still gets a clean failure rather than
|
|
370
|
+
a bare traceback.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
stage_inputs(wb.work_md, inputs)
|
|
374
|
+
stage_clis(clis_dir, wb.work_scripts)
|
|
375
|
+
rewrite_spec(wb.work_spec, spec_source)
|
|
376
|
+
mirror_in(wb.work_okf, output_dir)
|
|
377
|
+
except OSError as exc:
|
|
378
|
+
raise WorkbenchError(f"staging the workbench failed: {exc}") from exc
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def is_adoptable_output(output_dir: Path) -> bool:
|
|
382
|
+
"""Whether -o DIR may be adopted: missing, empty, or a recognised OKF bundle root.
|
|
383
|
+
|
|
384
|
+
Mirroring out propagates deletions, so anything else is refused outright
|
|
385
|
+
-- adoption is proved, never guessed. The symlink check must come before
|
|
386
|
+
exists()/is_dir(), which follow a symlink rather than report it: a
|
|
387
|
+
*dangling* symlink named as -o would otherwise read as "missing" and
|
|
388
|
+
slip through as adoptable.
|
|
389
|
+
"""
|
|
390
|
+
if output_dir.is_symlink():
|
|
391
|
+
return False
|
|
392
|
+
if not output_dir.exists():
|
|
393
|
+
return True
|
|
394
|
+
if not output_dir.is_dir():
|
|
395
|
+
return False
|
|
396
|
+
if not any(output_dir.iterdir()):
|
|
397
|
+
return True
|
|
398
|
+
return _is_okf_bundle_root(output_dir)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _is_okf_bundle_root(output_dir: Path) -> bool:
|
|
402
|
+
index = output_dir / "index.md"
|
|
403
|
+
if not index.is_file() or index.is_symlink():
|
|
404
|
+
return False
|
|
405
|
+
frontmatter = _parse_simple_frontmatter(index.read_text(encoding="utf-8"))
|
|
406
|
+
return frontmatter is not None and list(frontmatter) == ["okf_version"]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _parse_simple_frontmatter(text: str) -> dict[str, str] | None:
|
|
410
|
+
"""A minimal, flat `key: value` frontmatter reader.
|
|
411
|
+
|
|
412
|
+
Just enough to check the bundle-root marker (OKF spec §12) without a
|
|
413
|
+
YAML dependency -- code2okf is stdlib-only at runtime.
|
|
414
|
+
"""
|
|
415
|
+
lines = text.splitlines()
|
|
416
|
+
if not lines or lines[0].strip() != "---":
|
|
417
|
+
return None
|
|
418
|
+
result: dict[str, str] = {}
|
|
419
|
+
for line in lines[1:]:
|
|
420
|
+
stripped = line.strip()
|
|
421
|
+
if stripped == "---":
|
|
422
|
+
return result
|
|
423
|
+
if not stripped or stripped.startswith("#"):
|
|
424
|
+
continue
|
|
425
|
+
if ":" not in stripped:
|
|
426
|
+
return None
|
|
427
|
+
key, _, value = stripped.partition(":")
|
|
428
|
+
result[key.strip()] = value.strip()
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _hash_tree(root: Path) -> str:
|
|
433
|
+
"""Hash every regular file under root, keyed by its relative path.
|
|
434
|
+
|
|
435
|
+
Skips dotfiles/dotdirs and __pycache__ at any depth -- editor droppings
|
|
436
|
+
(.DS_Store) or bytecode left by running kits/code2okf's own
|
|
437
|
+
frontmatter-guard.py locally must not move the fingerprint and force an
|
|
438
|
+
unnecessary sandbox rebuild.
|
|
439
|
+
"""
|
|
440
|
+
digest = hashlib.sha256()
|
|
441
|
+
for path in sorted(root.rglob("*")):
|
|
442
|
+
relative = path.relative_to(root)
|
|
443
|
+
if any(part.startswith(".") or part == "__pycache__" for part in relative.parts):
|
|
444
|
+
continue
|
|
445
|
+
if path.is_file() and not path.is_symlink():
|
|
446
|
+
digest.update(str(relative).encode("utf-8"))
|
|
447
|
+
digest.update(path.read_bytes())
|
|
448
|
+
return digest.hexdigest()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def fingerprint(kit_dir: Path, sbx_version: tuple[int, int, int] | None, mounts: Iterable[sandbox.Mount]) -> str:
|
|
452
|
+
"""A configuration fingerprint: kit tree hash + tool version + mount set."""
|
|
453
|
+
parts = [
|
|
454
|
+
_hash_tree(kit_dir),
|
|
455
|
+
".".join(str(part) for part in (sbx_version or (0, 0, 0))),
|
|
456
|
+
"\n".join(mount.as_arg() for mount in mounts),
|
|
457
|
+
]
|
|
458
|
+
return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
462
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
463
|
+
tmp.write_text(content, encoding="utf-8")
|
|
464
|
+
os.replace(tmp, path)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def write_ownership_marker(wb: Workbench, fingerprint_value: str, identity_value: str) -> None:
|
|
468
|
+
"""Record a successful create's fingerprint and identity. Only call this after create() succeeds."""
|
|
469
|
+
_atomic_write(wb.fingerprint_path, fingerprint_value)
|
|
470
|
+
_atomic_write(wb.identity_path, identity_value)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def read_ownership_marker(wb: Workbench) -> tuple[str, str] | None:
|
|
474
|
+
"""The last recorded (fingerprint, identity), or None if never written."""
|
|
475
|
+
if not (wb.fingerprint_path.is_file() and wb.identity_path.is_file()):
|
|
476
|
+
return None
|
|
477
|
+
fingerprint_value = wb.fingerprint_path.read_text(encoding="utf-8").strip()
|
|
478
|
+
identity_value = wb.identity_path.read_text(encoding="utf-8").strip()
|
|
479
|
+
return fingerprint_value, identity_value
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def resolve_sandbox_state(wb: Workbench, name: str, fingerprint_value: str, *, fresh: bool = False) -> str:
|
|
483
|
+
"""Decide "create" or "reuse" for `name`, proving ownership before reuse.
|
|
484
|
+
|
|
485
|
+
Reuse requires the name to resolve to the same identity we recorded,
|
|
486
|
+
the fingerprint to match, and a cheap in-VM probe to pass; anything
|
|
487
|
+
else that still exists under this name raises UnownedSandboxError
|
|
488
|
+
rather than being silently reused or deleted. --fresh recreates a
|
|
489
|
+
sandbox we own; it never widens deletion authority.
|
|
490
|
+
"""
|
|
491
|
+
if not sandbox.exists(name):
|
|
492
|
+
return "create"
|
|
493
|
+
|
|
494
|
+
marker = read_ownership_marker(wb)
|
|
495
|
+
if marker is None:
|
|
496
|
+
raise UnownedSandboxError(name)
|
|
497
|
+
marker_fingerprint, marker_identity = marker
|
|
498
|
+
current_identity = sandbox.identity(name)
|
|
499
|
+
if current_identity is None or current_identity != marker_identity:
|
|
500
|
+
raise UnownedSandboxError(name)
|
|
501
|
+
|
|
502
|
+
if fresh:
|
|
503
|
+
return "create"
|
|
504
|
+
if marker_fingerprint != fingerprint_value:
|
|
505
|
+
return "create"
|
|
506
|
+
if not sandbox.probe(name, wb.work_okf):
|
|
507
|
+
return "create"
|
|
508
|
+
return "reuse"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _clear_ownership_marker(wb: Workbench) -> None:
|
|
512
|
+
"""Remove any existing marker before attempting a (re)creation.
|
|
513
|
+
|
|
514
|
+
create()'s first step is `sbx rm --force`, so the moment we decide to
|
|
515
|
+
(re)create, any marker already on disk describes a sandbox generation
|
|
516
|
+
that is about to be torn down. Clearing it first means a failed
|
|
517
|
+
create() leaves the state directory honestly reflecting "no proven
|
|
518
|
+
sandbox" instead of a stale reference to a generation that no longer
|
|
519
|
+
exists -- even though that staleness was never actually exploitable
|
|
520
|
+
(resolve_sandbox_state re-checks exists() before ever reading the
|
|
521
|
+
marker, and a torn-down sandbox reads as not existing).
|
|
522
|
+
"""
|
|
523
|
+
wb.fingerprint_path.unlink(missing_ok=True)
|
|
524
|
+
wb.identity_path.unlink(missing_ok=True)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def stage_tooling(wb: Workbench) -> None:
|
|
528
|
+
"""Stage the helper CLI projects the kit's shims resolve against.
|
|
529
|
+
|
|
530
|
+
Deliberately not part of restage(): this content does not vary per run.
|
|
531
|
+
It is the packaged CLI sources, identical for every invocation, whereas
|
|
532
|
+
work/md, work/SPEC.md and work/okf are the run's own inputs and output.
|
|
533
|
+
|
|
534
|
+
A sandbox whose work/scripts is empty still starts, and its mounts are
|
|
535
|
+
still correct — but every inspectmd/inspectokf/sizeokf/merkleokf shim in
|
|
536
|
+
it fails, because each one is `uv tool run --from
|
|
537
|
+
$(dirname $WORKDIR)/scripts/<cli>` (kits/code2okf/spec.yaml). That makes
|
|
538
|
+
this part of "the sandbox is usable", which is why ensure_sandbox() does
|
|
539
|
+
it for every caller rather than leaving each one to remember.
|
|
540
|
+
"""
|
|
541
|
+
stage_clis(resources.clis_dir(), wb.work_scripts)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def ensure_sandbox(wb: Workbench, *, fresh: bool = False) -> str:
|
|
545
|
+
"""Reuse or (re)create the sandbox named SANDBOX_NAME. Returns "reuse" or "created".
|
|
546
|
+
|
|
547
|
+
Raises UnownedSandboxError, sandbox.SandboxError, or
|
|
548
|
+
KeyNotProxyManagedError on failure. The ownership marker is written as
|
|
549
|
+
soon as create() itself succeeds -- even if the key check that follows
|
|
550
|
+
it fails -- so a sandbox we really did create is always recognised as
|
|
551
|
+
ours on the next run, rather than forcing a manual `sbx rm --force`
|
|
552
|
+
just because a secret was not yet configured.
|
|
553
|
+
"""
|
|
554
|
+
name = SANDBOX_NAME
|
|
555
|
+
kit_dir = resources.kit_dir()
|
|
556
|
+
fingerprint_value = fingerprint(kit_dir, sandbox.version(), wb.mounts())
|
|
557
|
+
|
|
558
|
+
# Before the sandbox exists, so it never observes an empty scripts mount.
|
|
559
|
+
# Unconditional rather than create-only: a reused sandbox whose staged
|
|
560
|
+
# tooling was wiped must get it back too.
|
|
561
|
+
stage_tooling(wb)
|
|
562
|
+
|
|
563
|
+
state = resolve_sandbox_state(wb, name, fingerprint_value, fresh=fresh)
|
|
564
|
+
if state == "reuse":
|
|
565
|
+
return "reuse"
|
|
566
|
+
|
|
567
|
+
_clear_ownership_marker(wb)
|
|
568
|
+
token = sandbox.create(name, kit_dir, wb.mounts(), {"CODE2OKF_STATE_DIR": str(wb.root)})
|
|
569
|
+
write_ownership_marker(wb, fingerprint_value, token)
|
|
570
|
+
if not sandbox.key_is_proxy_managed(name):
|
|
571
|
+
raise KeyNotProxyManagedError(name)
|
|
572
|
+
return "created"
|