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/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())
|