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.
Files changed (48) hide show
  1. code2okf/SPEC.md +1006 -0
  2. code2okf/__init__.py +8 -0
  3. code2okf/cli.py +234 -0
  4. code2okf/clis/inspectmd/pyproject.toml +40 -0
  5. code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
  6. code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
  7. code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
  8. code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
  9. code2okf/clis/inspectokf/pyproject.toml +40 -0
  10. code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
  11. code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
  12. code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
  13. code2okf/clis/merkleokf/pyproject.toml +40 -0
  14. code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
  15. code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
  16. code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
  17. code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
  18. code2okf/clis/sizeokf/pyproject.toml +40 -0
  19. code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
  20. code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
  21. code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
  22. code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
  23. code2okf/compile.py +267 -0
  24. code2okf/events.py +86 -0
  25. code2okf/kit/README.md +128 -0
  26. code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
  27. code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
  28. code2okf/kit/files/home/.pi/agent/models.json +84 -0
  29. code2okf/kit/files/home/.pi/agent/settings.json +7 -0
  30. code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
  31. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
  32. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
  33. code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
  34. code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
  35. code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
  36. code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
  37. code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
  38. code2okf/kit/spec.yaml +312 -0
  39. code2okf/resources.py +74 -0
  40. code2okf/sandbox.py +266 -0
  41. code2okf/workbench.py +572 -0
  42. code2okf-0.1.0.dist-info/METADATA +391 -0
  43. code2okf-0.1.0.dist-info/RECORD +48 -0
  44. code2okf-0.1.0.dist-info/WHEEL +4 -0
  45. code2okf-0.1.0.dist-info/entry_points.txt +2 -0
  46. code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
  48. code2okf-0.1.0.dist-info/licenses/NOTICE-OKF-SPEC.md +37 -0
code2okf/kit/spec.yaml ADDED
@@ -0,0 +1,312 @@
1
+ ---
2
+ schemaVersion: "2"
3
+ kind: sandbox
4
+ name: code2okf
5
+ displayName: Pi kit
6
+ description: >
7
+ Runs the Pi terminal coding agent in a Docker Sandbox microVM, configured for
8
+ OpenRouter and proxy-managed API credentials.
9
+
10
+ sandbox:
11
+ image: "docker/sandbox-templates:shell-docker"
12
+ # Model and provider come from the kit's own config (files/home/.pi/agent/
13
+ # settings.json + models.json), delivered to ~/.pi/agent/ in the VM. No
14
+ # --provider/--model flags here — Pi resolves defaults from settings.json.
15
+ entrypoint:
16
+ - sh
17
+ - -c
18
+ - |
19
+ set -eu
20
+ mount_state_status=0
21
+ sh "$HOME/.local/lib/code2okf/mount-state.sh" \
22
+ "$HOME/.pi/agent/sessions" sessions || mount_state_status=$?
23
+ if [ "${mount_state_status}" -ne 0 ]; then
24
+ echo "code2okf: could not relocate Pi sessions onto state; refusing to start pi" >&2
25
+ exit "${mount_state_status}"
26
+ fi
27
+ exec pi "$@"
28
+ - code2okf-entrypoint
29
+
30
+ # The AI profile Pi reads, and the text appended to it.
31
+ agentInstructions:
32
+ filename: AGENTS.md
33
+ content: |
34
+ ## Sandbox environment
35
+ You are Pi running inside a Docker Sandbox microVM. Your workspace is the
36
+ OKF wiki itself, mounted read-write, and edits apply directly to the host
37
+ working tree. Four more host paths are mounted:
38
+
39
+ - `../md/` — read-only source documents
40
+ - `../scripts/` — read-only, the projects behind the CLIs listed below
41
+ - `../SPEC.md` — read-only, the OKF specification
42
+ - `$CODE2OKF_STATE_DIR/sessions` — writable persistent state for Pi sessions
43
+
44
+ Nothing else from the user's project is mounted, so no other file of theirs
45
+ is visible or reachable — not the repository, not its history, not its
46
+ configuration. Pi writes sessions to its native `~/.pi/agent/sessions`
47
+ path, which is bind-mounted onto `$CODE2OKF_STATE_DIR/sessions`. A write
48
+ anywhere outside the wiki and state directory either fails or lands in the
49
+ VM's own filesystem and is lost when the sandbox is removed.
50
+
51
+ `sudo` is passwordless. If a host is blocked by policy, ask the user to allow
52
+ it with `sbx policy allow network "<host>"`.
53
+
54
+ ## Installed tools
55
+ These commands are on PATH. Use them; do not reinstall them.
56
+
57
+ - `curl`, `fdfind` (fd; Ubuntu package `fd-find`), `jq`, `python3`,
58
+ `rg` (ripgrep), `shellcheck`, `tree`
59
+ - `okfctl` — OKF bundle tool: validate, lint, analyze, and the authoring
60
+ verbs (the curate-okf skill covers it)
61
+ - `markdownlint-cli2`, `cspell` — Markdown and spelling
62
+ - `ruff`, `yamllint` — Python and YAML
63
+ - `inspectmd` — Markdown heading map (line ranges and word counts)
64
+ - `inspectokf` — wiki directory tree (via `tree`)
65
+ - `sizeokf` — Markdown content word counts, excluding frontmatter
66
+ - `merkleokf` — wiki Merkle hash tree, for change detection
67
+ - `mq` — jq-like query/filter tool for Markdown (https://mqlang.org)
68
+
69
+ # Outbound egress allowlist. Default-deny: every host the kit reaches must be
70
+ # listed. Pointing Pi at a different provider means adding its host here too —
71
+ # see "Using another provider" in the README.
72
+ permissions:
73
+ network:
74
+ allow:
75
+ # Model API. Must match credentials[].apiKey.inject[].domain exactly.
76
+ - "openrouter.ai"
77
+ # Pi service bootstrap.
78
+ - "pi.dev"
79
+ # npm: pi-coding-agent, markdownlint-cli2, cspell.
80
+ - "registry.npmjs.org:443"
81
+ # uv tool install: ruff, yamllint (index + wheels).
82
+ - "pypi.org:443"
83
+ - "files.pythonhosted.org:443"
84
+ # apt-get update refreshes every template source, so all of them must
85
+ # be reachable: Ubuntu amd64 (archive/security), arm64 (ports), and
86
+ # Docker's apt repo (pre-added by shell-docker).
87
+ - "archive.ubuntu.com:80"
88
+ - "security.ubuntu.com:80"
89
+ - "ports.ubuntu.com:80"
90
+ - "download.docker.com:443"
91
+ # mq and okfctl release binaries: github.com redirects to a GitHub CDN
92
+ # host. Today that is objects.githubusercontent.com; release-assets is
93
+ # also used for release downloads and must stay allowlisted.
94
+ - "github.com:443"
95
+ - "objects.githubusercontent.com:443"
96
+ - "release-assets.githubusercontent.com:443"
97
+ # mq docs and language reference, for the agent to consult at runtime.
98
+ - "mqlang.org/book/"
99
+ # Context7 docs API (native @upstash/context7-pi extension).
100
+ - "context7.com:443"
101
+
102
+ # Credentials declare WHAT the kit needs and how the proxy injects it — not
103
+ # where it lives on the host (that comes from the user bindings / host env).
104
+ # proxyManaged: true sets OPENROUTER_API_KEY inside the container as a
105
+ # proxy-managed sentinel (replaces the v1 environment.proxyManaged list).
106
+ credentials:
107
+ - service: openrouter
108
+ apiKey:
109
+ name: OPENROUTER_API_KEY
110
+ proxyManaged: true
111
+ inject:
112
+ - domain: openrouter.ai
113
+ header: Authorization
114
+ format: "Bearer %s"
115
+
116
+ # Runs once at sandbox create, in order, via `sh -c`. `user` defaults to root
117
+ # ("0"); "1000" is the agent.
118
+ setup:
119
+ install:
120
+ # Distro CLI tools the agent shells out to.
121
+ - command: |
122
+ set -eu
123
+ apt-get update
124
+ apt-get install -y \
125
+ curl \
126
+ fd-find \
127
+ jq \
128
+ python3 \
129
+ ripgrep \
130
+ shellcheck \
131
+ tree
132
+ description: Install distro CLI tools
133
+
134
+ # npm through the sandbox proxy is flaky: pin, one socket, 10-minute fetch,
135
+ # five attempts. `npm config` writes ~/.npmrc, so later npm steps inherit it.
136
+ - command: |
137
+ set -eu
138
+ PI_VERSION=0.85.1
139
+
140
+ npm config set proxy "$HTTP_PROXY"
141
+ npm config set https-proxy "$HTTP_PROXY"
142
+
143
+ npm_install_retry() {
144
+ pkg=$1
145
+ attempt=1
146
+ until npm install -g --ignore-scripts --maxsockets=1 \
147
+ --fetch-timeout=600000 "$pkg"
148
+ do
149
+ if [ "$attempt" -ge 5 ]; then
150
+ echo "${pkg} install failed after ${attempt} attempts" >&2
151
+ exit 1
152
+ fi
153
+ echo "${pkg} install failed (attempt ${attempt}/5), retrying..."
154
+ attempt=$((attempt + 1))
155
+ sleep 5
156
+ done
157
+ }
158
+
159
+ npm_install_retry "@earendil-works/pi-coding-agent@${PI_VERSION}"
160
+ user: "1000"
161
+ description: Configure npm proxy and install Pi
162
+
163
+ - command: npm install --global markdownlint-cli2@0.23.2 cspell@10.0.1
164
+ user: "1000"
165
+ description: Install Markdown and spelling linters
166
+
167
+ - command: |
168
+ set -eu
169
+ uv tool install ruff@0.16.2
170
+ uv tool install yamllint@1.38.0
171
+ user: "1000"
172
+ description: Install Python linters
173
+
174
+ # No apt/uv/npm package; fetched as a pinned, checksummed release binary.
175
+ - command: |
176
+ set -eu
177
+ MQ_VERSION=0.8.2
178
+
179
+ case "$(uname -m)" in
180
+ x86_64) MQ_TARGET=x86_64-unknown-linux-gnu ;;
181
+ aarch64) MQ_TARGET=aarch64-unknown-linux-gnu ;;
182
+ *)
183
+ echo "mq: unsupported architecture: $(uname -m)" >&2
184
+ exit 1
185
+ ;;
186
+ esac
187
+
188
+ tmp=$(mktemp -d)
189
+ base="https://github.com/harehare/mq/releases/download/v${MQ_VERSION}"
190
+ curl -fsSL -o "$tmp/mq" "${base}/mq-${MQ_TARGET}"
191
+ curl -fsSL -o "$tmp/checksums.txt" "${base}/checksums.txt"
192
+
193
+ expected=$(awk -v f="mq-${MQ_TARGET}/mq-${MQ_TARGET}" '$2 == f { print $1 }' \
194
+ "$tmp/checksums.txt")
195
+ actual=$(sha256sum "$tmp/mq" | awk '{ print $1 }')
196
+ if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then
197
+ echo "mq: checksum mismatch for ${MQ_TARGET}" >&2
198
+ exit 1
199
+ fi
200
+
201
+ mkdir -p "$HOME/.local/bin"
202
+ install -m 0755 "$tmp/mq" "$HOME/.local/bin/mq"
203
+ rm -rf "$tmp"
204
+ user: "1000"
205
+ description: Install mq (Markdown query tool)
206
+
207
+ # Same story as mq, one release archive rather than a bare binary. The
208
+ # checksums.txt second column is a plain filename here, so the awk match is
209
+ # on the archive name. Only okfctl is extracted: the archive also carries
210
+ # okfctl-search, whose semantic index needs a model download from a host
211
+ # that is not allowlisted.
212
+ - command: |
213
+ set -eu
214
+ OKFCTL_VERSION=0.4.0
215
+
216
+ case "$(uname -m)" in
217
+ x86_64) OKFCTL_ARCH=amd64 ;;
218
+ aarch64) OKFCTL_ARCH=arm64 ;;
219
+ *)
220
+ echo "okfctl: unsupported architecture: $(uname -m)" >&2
221
+ exit 1
222
+ ;;
223
+ esac
224
+
225
+ tmp=$(mktemp -d)
226
+ archive="okfctl_${OKFCTL_VERSION}_linux_${OKFCTL_ARCH}.tar.gz"
227
+ base="https://github.com/cwest/okfctl/releases/download/v${OKFCTL_VERSION}"
228
+ curl -fsSL -o "$tmp/$archive" "${base}/${archive}"
229
+ curl -fsSL -o "$tmp/checksums.txt" "${base}/checksums.txt"
230
+
231
+ expected=$(awk -v f="$archive" '$2 == f { print $1 }' "$tmp/checksums.txt")
232
+ actual=$(sha256sum "$tmp/$archive" | awk '{ print $1 }')
233
+ if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then
234
+ echo "okfctl: checksum mismatch for ${archive}" >&2
235
+ exit 1
236
+ fi
237
+
238
+ tar -xzf "$tmp/$archive" -C "$tmp" okfctl
239
+ mkdir -p "$HOME/.local/bin"
240
+ install -m 0755 "$tmp/okfctl" "$HOME/.local/bin/okfctl"
241
+ rm -rf "$tmp"
242
+ user: "1000"
243
+ description: Install okfctl (OKF bundle tool)
244
+
245
+ # Native Pi package (not MCP): tools + context7-docs skill under ~/.pi/agent/npm/.
246
+ - command: |
247
+ set -eu
248
+ CONTEXT7_PI_VERSION=0.1.2
249
+ pi install "npm:@upstash/context7-pi@${CONTEXT7_PI_VERSION}"
250
+ user: "1000"
251
+ description: Install Context7 Pi extension
252
+
253
+ # Static files/home/ copies do not reliably preserve the git 100755 bit;
254
+ # set it explicitly so the compile-okf skill can invoke the wrapper.
255
+ - command: |
256
+ set -eu
257
+ scripts=/home/agent/.pi/agent/skills/compile-okf/scripts
258
+ chmod 0755 "$scripts/check-okf.sh"
259
+ description: Make the compile-okf check script executable
260
+
261
+ # Bind mounts do not survive a sandbox stop. The startup hook covers normal
262
+ # starts and sbx exec; the entrypoint above is a second required call site for
263
+ # starts where the hook is not replayed. The helper is idempotent.
264
+ startup:
265
+ - command:
266
+ - sh
267
+ - -c
268
+ - |
269
+ set -eu
270
+ HOME="${HOME:-/home/agent}"
271
+ export HOME
272
+ sh "$HOME/.local/lib/code2okf/mount-state.sh" \
273
+ "$HOME/.pi/agent/sessions" sessions
274
+ user: "agent"
275
+ description: Bind-mount Pi sessions onto persistent state
276
+
277
+ # Workspace is not mounted during setup.install, so the CLIs are shims that
278
+ # resolve from ${WORKDIR} — the workspace path — rather than from a copy
279
+ # (always tracks the tree). ${WORKDIR} is the wiki, and the projects sit in
280
+ # the scripts/ mount beside it, hence the dirname.
281
+ files:
282
+ - path: /home/agent/.local/bin/inspectmd
283
+ mode: "0755"
284
+ description: Workspace-backed inspectmd CLI
285
+ content: |
286
+ #!/bin/sh
287
+ set -eu
288
+ exec uv tool run --from "$(dirname "${WORKDIR}")/scripts/inspectmd" inspectmd "$@"
289
+
290
+ - path: /home/agent/.local/bin/inspectokf
291
+ mode: "0755"
292
+ description: Workspace-backed inspectokf CLI
293
+ content: |
294
+ #!/bin/sh
295
+ set -eu
296
+ exec uv tool run --from "$(dirname "${WORKDIR}")/scripts/inspectokf" inspectokf "$@"
297
+
298
+ - path: /home/agent/.local/bin/sizeokf
299
+ mode: "0755"
300
+ description: Workspace-backed sizeokf CLI
301
+ content: |
302
+ #!/bin/sh
303
+ set -eu
304
+ exec uv tool run --from "$(dirname "${WORKDIR}")/scripts/sizeokf" sizeokf "$@"
305
+
306
+ - path: /home/agent/.local/bin/merkleokf
307
+ mode: "0755"
308
+ description: Workspace-backed merkleokf CLI
309
+ content: |
310
+ #!/bin/sh
311
+ set -eu
312
+ exec uv tool run --from "$(dirname "${WORKDIR}")/scripts/merkleokf" merkleokf "$@"
code2okf/resources.py ADDED
@@ -0,0 +1,74 @@
1
+ """Locate the kit, the OKF spec, and the helper CLI sources this package ships.
2
+
3
+ Prefers the installed package's own bundled copies (importlib.resources);
4
+ falls back to the checkout's layout so `uv run code2okf` works with no build
5
+ step. The checkout fallback is a second code path and is tested on its own.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+
13
+ # src/code2okf/resources.py -> parents[0]=src/code2okf, [1]=src, [2]=repo root.
14
+ _CHECKOUT_ROOT = Path(__file__).resolve().parents[2]
15
+
16
+
17
+ class ResourcesError(Exception):
18
+ """code2okf cannot locate its own kit, spec, or helper CLI sources."""
19
+
20
+
21
+ def _installed_root() -> Path | None:
22
+ """The installed package's own directory, if it carries a bundled kit.
23
+
24
+ A checkout (this package imported via `pythonpath`, with no wheel build)
25
+ has no ``kit/`` sibling next to ``resources.py``, so this returns None and
26
+ every lookup below falls back to the checkout's own layout.
27
+ """
28
+ try:
29
+ candidate = Path(str(files("code2okf")))
30
+ except (ModuleNotFoundError, TypeError):
31
+ return None
32
+ if (candidate / "kit").is_dir():
33
+ return candidate
34
+ return None
35
+
36
+
37
+ def _checkout_path(relative: str, what: str) -> Path:
38
+ """The checkout fallback location for `relative`, verified to exist.
39
+
40
+ A released wheel carries the kit, the spec and the CLI sources, so
41
+ _installed_root finds them and this path is never taken. It is reached
42
+ from a checkout (`uv run code2okf`), and by an install built without those
43
+ assets -- where __file__ resolves under site-packages and a silent
44
+ parents[2] would compute a path that cannot ever exist, leaving every
45
+ caller downstream to report a generic "not a file" with no clue why.
46
+ Fail here instead, with the actual cause.
47
+ """
48
+ candidate = _CHECKOUT_ROOT / relative
49
+ if not candidate.exists():
50
+ raise ResourcesError(
51
+ f"cannot locate {what} ({candidate}). This looks like an installed "
52
+ "code2okf built without its bundled kit, and not a development "
53
+ "checkout either. Run from a checkout with `uv run code2okf`, or "
54
+ "install a release whose wheel carries the kit."
55
+ )
56
+ return candidate
57
+
58
+
59
+ def kit_dir() -> Path:
60
+ """The code2okf sandbox kit: the installed ``code2okf/kit``, else ``kits/code2okf``."""
61
+ root = _installed_root()
62
+ return root / "kit" if root is not None else _checkout_path("kits/code2okf", "the sandbox kit")
63
+
64
+
65
+ def spec_md() -> Path:
66
+ """The bundled OKF spec: the installed ``code2okf/SPEC.md``, else the checkout's."""
67
+ root = _installed_root()
68
+ return root / "SPEC.md" if root is not None else _checkout_path("SPEC.md", "the OKF spec")
69
+
70
+
71
+ def clis_dir() -> Path:
72
+ """The four helper CLI projects: the installed ``code2okf/clis``, else ``scripts/``."""
73
+ root = _installed_root()
74
+ return root / "clis" if root is not None else _checkout_path("scripts", "the helper CLI projects")
code2okf/sandbox.py ADDED
@@ -0,0 +1,266 @@
1
+ """The one seam onto the `sbx` CLI.
2
+
3
+ Every process this package spawns to talk to a sandbox goes through
4
+ :func:`_run` or :class:`Exec` in this module, and nowhere else — so a test
5
+ only ever needs to fake ``subprocess.run``/``subprocess.Popen`` once, at this
6
+ one boundary, to exercise everything above it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import re
13
+ import secrets
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Iterable, Iterator
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ MIN_VERSION = (0, 43, 0)
22
+
23
+ # This is the plan's own documented fallback (interface-plan.md, "Risks and
24
+ # open items"), not an invented deviation: which `sbx inspect` field (if any)
25
+ # is a stable sandbox identity is explicitly left unverified there, to be
26
+ # confirmed against a *live* sbx session in stage 3. Stage 2 is offline and
27
+ # has no sandbox to inspect, so it implements the fallback the plan already
28
+ # specifies instead of guessing at stage 3's job: a random token written into
29
+ # the guest at creation and read back on reuse.
30
+ #
31
+ # Threat model: the token is guest-writable, so it is not proof against a
32
+ # guest that deliberately tries to impersonate ownership. It only needs to
33
+ # answer "did *we* create this sandbox" against accidental reuse (a stale
34
+ # sandbox from a prior run, an unrelated sandbox that happens to hold this
35
+ # name) — and resolve_sandbox_state() always compares the *live* token against
36
+ # the one recorded at creation, so a missing or altered token can only cause a
37
+ # false *rejection* (fails safe: exit 2, "sbx rm --force"), never a false
38
+ # acceptance of a sandbox we did not create.
39
+ #
40
+ # Inside the guest VM only — never a host path, so this is not a shared-tmp
41
+ # race on the host. Random per creation (see create()), so a stale file left
42
+ # by an unrelated image could never be mistaken for our own.
43
+ OWNER_TOKEN_PATH = "/tmp/code2okf-owner" # noqa: S108
44
+
45
+
46
+ class SandboxError(Exception):
47
+ """A checked failure talking to `sbx`."""
48
+
49
+
50
+ def _run(argv: list[str], *, stdin: int | None = subprocess.DEVNULL) -> subprocess.CompletedProcess[str]:
51
+ """Run `argv` and capture it. The only place this module calls subprocess.run."""
52
+ return subprocess.run(argv, stdin=stdin, capture_output=True, text=True, check=False) # noqa: S603
53
+
54
+
55
+ def present() -> bool:
56
+ """Whether the `sbx` executable exists on PATH."""
57
+ return shutil.which("sbx") is not None
58
+
59
+
60
+ def version() -> tuple[int, int, int] | None:
61
+ """Parsed (major, minor, patch) from `sbx version`, or None if unparsable."""
62
+ result = _run(["sbx", "version"])
63
+ if result.returncode != 0:
64
+ return None
65
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout)
66
+ if not match:
67
+ return None
68
+ major, minor, patch = (int(part) for part in match.groups())
69
+ return (major, minor, patch)
70
+
71
+
72
+ def version_at_least(minimum: tuple[int, int, int] = MIN_VERSION) -> bool:
73
+ """Whether `sbx version` is at least `minimum`."""
74
+ found = version()
75
+ return found is not None and found >= minimum
76
+
77
+
78
+ def logged_in() -> bool:
79
+ """`sbx ls` succeeding is what a logged-in session looks like."""
80
+ return _run(["sbx", "ls"]).returncode == 0
81
+
82
+
83
+ def preflight() -> None:
84
+ """Check the sbx environment before anything that would shell out to it.
85
+
86
+ Every other function in this module assumes `sbx` is on PATH: `_run`
87
+ calls `subprocess.run(["sbx", ...])` directly, which raises a bare
88
+ FileNotFoundError (not SandboxError) if it is missing. Callers -- cli.py
89
+ and `python -m code2okf.sandbox` alike -- must call this first and turn
90
+ SandboxError into a clean exit, or a missing `sbx` crashes instead.
91
+ """
92
+ if not present():
93
+ raise SandboxError("'sbx' CLI not found in PATH. Install it with: brew install docker/tap/sbx")
94
+ if not version_at_least():
95
+ minimum = ".".join(str(part) for part in MIN_VERSION)
96
+ raise SandboxError(f"sbx must be at least version {minimum}")
97
+ if not logged_in():
98
+ raise SandboxError("not logged in to sbx; run `sbx login`")
99
+
100
+
101
+ def exists(name: str) -> bool:
102
+ """Whether a sandbox called `name` currently exists."""
103
+ result = _run(["sbx", "ls", "-q"])
104
+ return name in result.stdout.splitlines()
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Mount:
109
+ """One `sbx run` workspace argument."""
110
+
111
+ path: Path
112
+ readonly: bool = False
113
+
114
+ def as_arg(self) -> str:
115
+ """The positional argument `sbx run` expects for this mount."""
116
+ return f"{self.path}:ro" if self.readonly else str(self.path)
117
+
118
+
119
+ def create(name: str, kit_dir: Path, mounts: Iterable[Mount], env: dict[str, str]) -> str:
120
+ """Recreate `name` over `mounts`, and return its fresh owner token.
121
+
122
+ Tolerates a prior sandbox by this name not existing, mirroring today's
123
+ `sbx rm --force ... || true`. Raises SandboxError on any failure from here
124
+ on — a failed create must leave no ownership marker (see workbench.py).
125
+ """
126
+ _run(["sbx", "rm", "--force", name])
127
+
128
+ argv = ["sbx", "run", "--detached", "--name", name]
129
+ for key, value in env.items():
130
+ argv += ["-e", f"{key}={value}"]
131
+ argv.append(str(kit_dir))
132
+ argv += [mount.as_arg() for mount in mounts]
133
+ result = _run(argv)
134
+ if result.returncode != 0:
135
+ raise SandboxError(f"sbx run failed for {name!r}: {result.stderr.strip()}")
136
+
137
+ token = secrets.token_hex(16)
138
+ written = _run(["sbx", "exec", name, "--", "sh", "-c", f"echo {token} >{OWNER_TOKEN_PATH}"])
139
+ if written.returncode != 0:
140
+ # sbx run just succeeded, so name now exists but carries no owner
141
+ # token -- left alone, the next invocation would see it as an
142
+ # unowned sandbox and demand a manual `sbx rm --force`, even though
143
+ # we are the ones who just created it moments ago. Clean up after
144
+ # ourselves instead.
145
+ _run(["sbx", "rm", "--force", name])
146
+ raise SandboxError(f"could not record an ownership token in {name!r}: {written.stderr.strip()}")
147
+ return token
148
+
149
+
150
+ def identity(name: str) -> str | None:
151
+ """The owner token left inside `name` at creation, or None if unreadable.
152
+
153
+ None covers both "no such sandbox" and "exists but is not ours" (no token
154
+ file) — either way, it cannot be recognised as a sandbox we created.
155
+ """
156
+ result = _run(["sbx", "exec", name, "--", "cat", OWNER_TOKEN_PATH])
157
+ if result.returncode != 0:
158
+ return None
159
+ return result.stdout.strip() or None
160
+
161
+
162
+ def remove(name: str) -> None:
163
+ """Delete the sandbox called `name`. Only call this on one we own."""
164
+ _run(["sbx", "rm", "--force", name])
165
+
166
+
167
+ def probe(name: str, path: Path) -> bool:
168
+ """A cheap in-VM check that `path` (a workbench mount) is visible in `name`."""
169
+ return _run(["sbx", "exec", name, "--", "test", "-d", str(path)]).returncode == 0
170
+
171
+
172
+ def key_is_proxy_managed(name: str) -> bool:
173
+ """Whether OPENROUTER_API_KEY inside `name` is the proxy-managed sentinel."""
174
+ result = _run(["sbx", "exec", name, "--", "sh", "-lc", 'echo "$OPENROUTER_API_KEY"'])
175
+ return result.returncode == 0 and result.stdout.strip() == "proxy-managed"
176
+
177
+
178
+ class Exec:
179
+ """One streamed `sbx exec`: iterate stdout+stderr lines, then read .returncode."""
180
+
181
+ def __init__(self, argv: list[str]) -> None:
182
+ """Start `argv`, ready to iterate over its combined stdout+stderr."""
183
+ self.argv = argv
184
+ self.returncode: int | None = None
185
+ self._proc = subprocess.Popen( # noqa: S603
186
+ argv,
187
+ stdin=subprocess.DEVNULL,
188
+ stdout=subprocess.PIPE,
189
+ stderr=subprocess.STDOUT,
190
+ text=True,
191
+ bufsize=1,
192
+ )
193
+
194
+ def __iter__(self) -> Iterator[str]:
195
+ """Yield each line as it arrives; sets .returncode once exhausted."""
196
+ assert self._proc.stdout is not None # noqa: S101
197
+ for line in self._proc.stdout:
198
+ yield line.rstrip("\n")
199
+ self.returncode = self._proc.wait()
200
+
201
+ def close(self) -> None:
202
+ """Best-effort local cleanup, safe to call on an already-finished run.
203
+
204
+ Stops the local `sbx exec` conduit so an interrupted run does not
205
+ leave one behind. Deliberately *only* the local side: `pi` runs
206
+ inside the VM and killing the conduit does not reap it, and remote
207
+ process reaping is out of scope (see the plan's deferred items).
208
+ """
209
+ if self._proc.poll() is None:
210
+ with contextlib.suppress(OSError):
211
+ self._proc.terminate()
212
+ if self._proc.stdout is not None:
213
+ with contextlib.suppress(OSError):
214
+ self._proc.stdout.close()
215
+
216
+
217
+ def exec_stream(name: str, argv: list[str]) -> Exec:
218
+ """Run `argv` inside `name`, streamed. stdin is always /dev/null — see compile.py."""
219
+ return Exec(["sbx", "exec", name, "--", *argv])
220
+
221
+
222
+ def exec_capture(name: str, argv: list[str]) -> subprocess.CompletedProcess[str]:
223
+ """Run a short `argv` inside `name` and capture it whole (e.g. a hash check)."""
224
+ return _run(["sbx", "exec", name, "--", *argv])
225
+
226
+
227
+ def _ensure_default_sandbox() -> int:
228
+ """`python -m code2okf.sandbox`: ensure the code2okf sandbox exists (maintainers).
229
+
230
+ Imports workbench lazily: workbench imports this module at its own top
231
+ level, and by the time this function runs (only from the `__main__`
232
+ guard below) that import has already completed, so the late import here
233
+ just retrieves it from sys.modules rather than re-entering it.
234
+ """
235
+ from code2okf import workbench
236
+
237
+ try:
238
+ preflight()
239
+ except SandboxError as exc:
240
+ print(f"code2okf.sandbox: {exc}", file=sys.stderr)
241
+ return 2
242
+
243
+ wb = workbench.Workbench.default()
244
+ try:
245
+ # Same lock a real compile run takes: this helper touches the same
246
+ # sandbox, so it must not race a concurrent `code2okf` invocation.
247
+ with workbench.lock():
248
+ wb.ensure_roots()
249
+ state = workbench.ensure_sandbox(wb)
250
+ except workbench.LockHeld:
251
+ print("code2okf.sandbox: another code2okf run is using the sandbox; try again later", file=sys.stderr)
252
+ return 2
253
+ except (
254
+ workbench.UnsafeLockFile,
255
+ workbench.UnownedSandboxError,
256
+ workbench.KeyNotProxyManagedError,
257
+ SandboxError,
258
+ ) as exc:
259
+ print(f"code2okf.sandbox: {exc}", file=sys.stderr)
260
+ return 2
261
+ print(f"code2okf.sandbox: sandbox {workbench.SANDBOX_NAME!r} {state}")
262
+ return 0
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(_ensure_default_sandbox())