quirework 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 (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2192 @@
1
+ """The blind-launch invariant, in one place. See ``docs/workflows.md#execution-and-recovery``.
2
+
3
+ Every headless ``claude -p`` that must judge, summarize, critique or rewrite the
4
+ text runs from a **neutral CWD outside the project tree**, so the project's own
5
+ guidance (``AGENTS.md``) and project-scoped memory never load.
6
+
7
+ The neutral CWD does *not* defeat the operator's own ``~/.claude`` layer, which
8
+ loads from any directory: neither the personal ``settings.json`` nor the global
9
+ ``CLAUDE.md``. Two guards close that, and both are needed.
10
+
11
+ ``BLIND_SETTINGS`` pins the **output style**. An ``Explanatory`` style makes the
12
+ model stop and teach (it emits craft-analysis blocks), which swaps a blind
13
+ seat's register from answering to reviewing and silently breaks comparability
14
+ with earlier runs. ``docs/workflows.md#execution-and-recovery`` owns the rule and the post-run
15
+ check.
16
+
17
+ ``NO_SETTING_SOURCES`` blocks the global ``~/.claude/CLAUDE.md``. That leak was
18
+ assessed as harmless while the cold-read only asked who wrote the *prose* --
19
+ the global file carries dev-workflow preferences, not the thesis. The **making**
20
+ scope broke that premise: it asks who designed the *working practice*, and the
21
+ global file states some of that practice outright (commit hygiene, scratch-dir
22
+ and venv rules). A blind analyst on 2026-08-13 hit exactly this and said so --
23
+ "several of the AI's visible behaviors ... match those rules exactly, so I can't
24
+ be certain how much is the AI following a standing instruction versus
25
+ improvising" -- which is the one distinction the making brief exists to draw. A
26
+ canary probe confirmed the leak verbatim ("ALWAYS use ~/tmp, NEVER /tmp") and
27
+ confirmed this flag closes it. Do not drop it because the book run seems fine
28
+ without it: the book run was fine, and that is why nobody noticed.
29
+
30
+ **The flags are half of it; the other half is the jail below.** A neutral CWD
31
+ hides the project from a seat's *attention*, never from its *hands*: every
32
+ file-reading tool in every runner takes an absolute path. Measured 2026-08-14,
33
+ one probe per runner from an empty ``~/tmp`` dir -- a ``claude -p`` seat with
34
+ ``--allowedTools "Read"`` read ``AGENTS.md`` and quoted it back, and a
35
+ ``codex exec`` seat did the same through its shell under ``-s read-only``
36
+ (read-only stops writes, not reads). There is no exempt shape either, since
37
+ there is no way to ask claude for zero tools (see ``MIN_TOOLS``), so EVERY
38
+ blind seat is confined at the OS level, and ``docs/workflows.md#execution-and-recovery`` ("The run
39
+ dir is not a wall") owns the rule.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import errno
45
+ import glob
46
+ import json
47
+ import os
48
+ import shutil
49
+ import subprocess
50
+ import sys
51
+ import re
52
+ import time
53
+ from collections.abc import Callable
54
+ from contextlib import contextmanager
55
+ from dataclasses import dataclass
56
+ from pathlib import Path
57
+ from quirework.project import current_project
58
+
59
+ NEUTRAL_CWD = Path(os.path.expanduser("~/tmp"))
60
+
61
+ # Overrides the operator's ~/.claude/settings.json for the blind child only.
62
+ # Kept alongside NO_SETTING_SOURCES rather than replaced by it: this pins the
63
+ # style positively, where the other merely stops a file from loading.
64
+ BLIND_SETTINGS = ["--settings", '{"outputStyle":"default"}']
65
+
66
+ # Loads NO setting sources (user / project / local), which is what keeps the
67
+ # operator's global ~/.claude/CLAUDE.md out of the child. `--setting-sources
68
+ # project` does NOT close it -- verified by canary probe, which still recovered
69
+ # the file verbatim. Auth and model selection are unaffected.
70
+ NO_SETTING_SOURCES = ["--setting-sources", ""]
71
+
72
+ # The SMALLEST tool set claude will accept, not an empty one -- because there
73
+ # is no empty one. `--tools ""` was carried here for months as "every built-in
74
+ # tool OFF"; the live canary measured it on 2026-08-15 and it is a **no-op**:
75
+ # an empty value (and "none") falls back to the DEFAULT set, so every seat that
76
+ # believed itself tools-off has had `Read,Edit,Write,Glob,Grep,Bash`. Only a
77
+ # non-empty allow-list actually restricts, so the honest floor is one harmless
78
+ # tool plus the jail, where `Read` can see nothing but an empty run dir.
79
+ #
80
+ # Still an allow-list, never a deny-list: a deny-list names tools by their
81
+ # harness names and drifts when one is renamed (this one used to name "Task",
82
+ # the subagent tool's OLD name, so a blind child could still spawn a subagent).
83
+ MIN_TOOLS = ["--tools", "Read"]
84
+
85
+ # MCP servers are delivered by the ACCOUNT, not by any file, so neither
86
+ # `--setting-sources ""` nor the jail removes them: the same canary found a
87
+ # blind seat holding the operator's Gmail (including `send_message`), Calendar
88
+ # and Drive tools. An empty strict config is what removes them, and it must
89
+ # ride on every claude launch.
90
+ NO_MCP = ["--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"]
91
+
92
+ # Codex has the same door, and every codex launch left it open until it was
93
+ # found. Its `apps` feature, on by default, gives a seat the account's
94
+ # connected apps as the built-in `codex_apps` MCP server: a jailed reviewer
95
+ # held hundreds of tools for mail, calendar, documents and code hosting,
96
+ # among them ones that commit, delete files and merge. A private home and
97
+ # `--ignore-user-config` remove neither, because the account supplies them.
98
+ # Turning the feature off removes the server and the catalogue of plugins
99
+ # Codex injects into the prompt with it. The canary asks Codex's own app
100
+ # server for the seat's MCP inventory (`canary.codex_servers`), so a release
101
+ # that brings the apps back by another switch refuses the launch.
102
+ CODEX_NO_APPS = ["-c", "features.apps=false"]
103
+
104
+ # The account's plugins arrive the same way, and Codex 0.162 turns both
105
+ # switches on. Every launch into a fresh private home then installs each
106
+ # remote plugin the account enables and caches the plugin catalogue: one
107
+ # refused launch grew a home from 3 MB to 33 MB, and longer seats held about
108
+ # 55 MB each. They are the same connected apps packaged as plugins, so they
109
+ # close with the apps, on every seat.
110
+ CODEX_NO_PLUGINS = [
111
+ "-c",
112
+ "features.plugins=false",
113
+ "-c",
114
+ "features.remote_plugin=false",
115
+ ]
116
+
117
+ # Codex's catalogue puts most models on its second multi-agent version, which
118
+ # ignores `features.multi_agent=false`: every seat, a tool-free one included,
119
+ # is offered `spawn_agent` with overrides naming other models, so a seat
120
+ # could hand part of its work to a model its receipt never names. No setting
121
+ # removes the tool. A session limit of one thread, the seat itself, makes
122
+ # every spawn fail with "agent thread limit reached". Codex refuses a limit
123
+ # of zero, and refuses a misspelled key, so a rename stops the launch rather
124
+ # than reopening the door. A model still on the first version is unaffected,
125
+ # and `features.multi_agent=false` removes the tools there.
126
+ CODEX_NO_SUBAGENTS = [
127
+ "-c",
128
+ "features.multi_agent_v2.max_concurrent_threads_per_session=1",
129
+ ]
130
+
131
+ # Image generation is a tool on every codex seat with a shell, and no seat
132
+ # is asked for an image. It spends the account's quota on whatever prompt
133
+ # the model writes.
134
+ CODEX_NO_IMAGES = ["-c", "features.image_generation=false"]
135
+
136
+ # What every codex seat turns off, whatever else its launcher gives it.
137
+ # Browser and computer use, also on by default, give a `codex exec` seat on
138
+ # Linux no tool under Codex 0.160.1, measured from the tools in the request
139
+ # a seat sends; the tool-free seats name them off regardless.
140
+ CODEX_SEAT = [*CODEX_NO_APPS, *CODEX_NO_PLUGINS, *CODEX_NO_SUBAGENTS, *CODEX_NO_IMAGES]
141
+
142
+
143
+ def _options():
144
+ """Read policy from the selected project for this operation."""
145
+ from types import SimpleNamespace
146
+
147
+ PROJECT_MARKERS = tuple(
148
+ current_project()
149
+ .data("providers.isolation_policy", {})
150
+ .get("project_markers", ())
151
+ )
152
+ return SimpleNamespace(PROJECT_MARKERS=PROJECT_MARKERS)
153
+
154
+
155
+ def project_markers() -> tuple:
156
+ """The words that prove a seat holds this project's own guidance."""
157
+ return _options().PROJECT_MARKERS
158
+
159
+
160
+ def ensure_neutral_cwd() -> Path:
161
+ """Create the neutral CWD if missing (a fresh machine has no ~/tmp); a
162
+ blind child launched from a nonexistent directory dies unhandled."""
163
+ NEUTRAL_CWD.mkdir(parents=True, exist_ok=True)
164
+ return NEUTRAL_CWD
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ # the jail
169
+ # --------------------------------------------------------------------------- #
170
+
171
+ BWRAP = "bwrap"
172
+
173
+ # Blanked wholesale, then a whitelist is bound back. An ALLOW-list is the only
174
+ # shape that stays correct: the earlier hand-rolled invocation hid the four
175
+ # directories somebody thought of (`projects`, `.claude`, `.codex/sessions`,
176
+ # `tmp`) and was one forgotten directory from a leak -- and the ones that leak
177
+ # are the ones nobody lists, a second checkout, `~/.gemini`, an old panel run.
178
+ # `/tmp` goes too: it is a cross-seat channel and nothing blind needs it.
179
+ BLANKED = ("$HOME", "/tmp", "/var/tmp")
180
+ # The cap on each blanked tmpfs, so a runaway seat cannot fill the host's
181
+ # memory. The run dir and the private runner homes are binds, not tmpfs, so
182
+ # real output never counts against it.
183
+ TMPFS_BYTES = 2 * 1024**3
184
+
185
+ # The root is an allow-list too, and for the same reason as the home. It used
186
+ # to be `--bind / /` minus a deny-list of network filesystem types, and every
187
+ # repair after that followed from the shape: a stale CIFS share under `/`
188
+ # failed the whole bind, so the root was enumerated around it; the mount
189
+ # table could change between reading it and the jail's clone; and the bind
190
+ # was read-write, so a user-writable path outside the blanked set (a mounted
191
+ # NTFS volume, the runtime directory) was writable from a seat. Binding only
192
+ # the system trees a CLI needs removes the class: the operator's home, shares
193
+ # and removable media never exist inside, and there is no mount table to
194
+ # read. A runner that needs a tree outside the list fails loudly at launch;
195
+ # add the tree here, never bind `/`.
196
+ #
197
+ # A required tree that is missing fails the launch by name; an optional one
198
+ # is bound when present. `/var/lib` rather than `/var`: on Silverblue
199
+ # `/var/mnt` and `/var/home` are where media and homes live.
200
+ REQUIRED_TREES = ("/usr", "/etc")
201
+ OPTIONAL_TREES = ("/opt", "/var/lib", "/sys", "/nix", "/snap")
202
+ # Merged-usr aliases: recreated as symlinks where the host has symlinks,
203
+ # bound read-only where it still has real directories.
204
+ USR_ALIASES = ("/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32")
205
+ # A private pid namespace also means a timeout kill of bwrap takes every
206
+ # descendant with it, which `--die-with-parent` alone does not guarantee
207
+ # for a runner's MCP helpers and git children.
208
+ NAMESPACES = ("--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try")
209
+
210
+ # `/run/user/<uid>` is never bound. The session bus there is a door out of
211
+ # any sandbox: `systemd --user` starts a process on request, outside the
212
+ # jail, so `systemd-run --user --pipe ls ~` from a seat with a shell listed
213
+ # the operator's real home. A runner that must reach a bus name declares it
214
+ # in `Runner.bus_names` and gets a socket from `xdg-dbus-proxy` filtered to
215
+ # exactly those names, and the receipt reports the door.
216
+ BUS_PROXY = "xdg-dbus-proxy"
217
+ BUS_READY_SECONDS = 15
218
+ BUS_RUNTIME_BYTES = 64 * 1024**2
219
+
220
+
221
+ def resolver_paths(resolv=Path("/etc/resolv.conf"), root=Path("/")) -> list[Path]:
222
+ """Where the resolver file points when that is outside ``/etc``.
223
+
224
+ Fedora and Ubuntu link ``/etc/resolv.conf`` to systemd-resolved's stub
225
+ under ``/run``, which the allow-list does not bind. Without the target
226
+ nothing in the jail resolves a name, and the seat cannot reach its
227
+ provider. Only a target under ``/run`` is followed, where every resolver
228
+ manager keeps its file; a link anywhere else is refused by name rather
229
+ than bound, so a stray symlink cannot pull a private path into the jail.
230
+ A dangling link is skipped: the host has no resolver either.
231
+ """
232
+ resolv = Path(resolv)
233
+ if not resolv.is_symlink():
234
+ return []
235
+ target = resolv.resolve()
236
+ if root / "etc" in target.parents or not target.exists():
237
+ return []
238
+ if root / "run" not in target.parents:
239
+ raise BlindnessError(
240
+ f"{resolv} points at {target}, outside /etc and /run; the jail "
241
+ "binds only a resolver under /run. Point it at a file under /run "
242
+ "or make it a regular file"
243
+ )
244
+ return [target]
245
+
246
+
247
+ def system_root(root=Path("/")) -> list[tuple[str, str, str]]:
248
+ """``(op, source, target)`` triples that build the read-only root.
249
+
250
+ Everything is bound at its own path, so ``/usr/bin/git`` is
251
+ ``/usr/bin/git`` inside as well. ``root`` exists for tests that build a
252
+ fake host tree.
253
+ """
254
+ out = []
255
+ for tree in REQUIRED_TREES:
256
+ path = root / tree.lstrip("/")
257
+ if not path.is_dir():
258
+ raise BlindnessError(
259
+ f"{path} is missing; the jail binds it read-only and cannot "
260
+ "run without it"
261
+ )
262
+ out.append(("--ro-bind", str(path), str(path)))
263
+ for tree in OPTIONAL_TREES:
264
+ path = root / tree.lstrip("/")
265
+ if path.is_dir() and not path.is_symlink():
266
+ out.append(("--ro-bind", str(path), str(path)))
267
+ for alias in USR_ALIASES:
268
+ path = root / alias.lstrip("/")
269
+ if path.is_symlink():
270
+ out.append(("--symlink", os.readlink(path), str(path)))
271
+ elif path.is_dir():
272
+ out.append(("--ro-bind", str(path), str(path)))
273
+ for target in resolver_paths(root / "etc/resolv.conf", root):
274
+ out.append(("--ro-bind", str(target), str(target)))
275
+ return out
276
+
277
+
278
+ def bound_trees(triples) -> list[Path]:
279
+ """The host paths a set of root triples makes visible."""
280
+ return [Path(source) for op, source, _ in triples if op != "--symlink"]
281
+
282
+
283
+ def visible(path, trees) -> bool:
284
+ """Whether ``path`` lies inside one of the bound host trees."""
285
+ path = Path(path)
286
+ return any(path == tree or tree in path.parents for tree in trees)
287
+
288
+
289
+ def root_argv(triples=None) -> list[str]:
290
+ """``bwrap``'s opening: namespaces, the read-only root, ``/dev``, ``/proc``.
291
+
292
+ ``--dev`` is bwrap's minimal device tree, not the host's: a seat needs
293
+ ``null``, ``urandom`` and a tty, never the operator's disks.
294
+ """
295
+ argv = [BWRAP, "--die-with-parent", *NAMESPACES]
296
+ for triple in system_root() if triples is None else triples:
297
+ argv += list(triple)
298
+ return argv + ["--dev", "/dev", "--proc", "/proc"]
299
+
300
+
301
+ def offline_argv(home=None) -> list[str]:
302
+ """A wrapper that runs a runner with no network, for the free probes.
303
+
304
+ The same root the seats get, plus the operator's own home read-write,
305
+ because a probe answers whether a flag still exists in this installation,
306
+ not whether the operator's state is hidden: the runners live under the
307
+ home and would not start without it. Its one enforcement is
308
+ ``--unshare-net``, which is what makes a probe free by construction.
309
+ """
310
+ home = str(home or Path.home())
311
+ return root_argv() + ["--bind", home, home, "--tmpfs", "/tmp", "--unshare-net"]
312
+
313
+
314
+ def root_receipt(triples) -> dict:
315
+ """What the jail's root is made of, for a confinement receipt."""
316
+ return {
317
+ "root_read_only": [str(t) for t in bound_trees(triples)],
318
+ "root_symlinks": [
319
+ f"{target} -> {link}" for op, link, target in triples if op == "--symlink"
320
+ ],
321
+ "namespaces": [
322
+ flag.removeprefix("--unshare-").removesuffix("-try") for flag in NAMESPACES
323
+ ],
324
+ "dev": "minimal",
325
+ "tmpfs_bytes": TMPFS_BYTES,
326
+ }
327
+
328
+
329
+ def bus_proxy_missing() -> str | None:
330
+ """Why a filtered session bus cannot be built here, or None."""
331
+ if shutil.which(BUS_PROXY) is None:
332
+ return (
333
+ f"{BUS_PROXY} not found on PATH (Fedora: dnf install xdg-dbus-proxy; "
334
+ "Debian: apt install xdg-dbus-proxy); the jail never binds the raw "
335
+ "session bus"
336
+ )
337
+ return None
338
+
339
+
340
+ def host_bus_address(environ=None) -> str:
341
+ """The operator's session bus, as the proxy will dial it."""
342
+ environ = os.environ if environ is None else environ
343
+ address = environ.get("DBUS_SESSION_BUS_ADDRESS")
344
+ if address:
345
+ return address
346
+ runtime = environ.get("XDG_RUNTIME_DIR")
347
+ if runtime and (Path(runtime) / "bus").exists():
348
+ return f"unix:path={runtime}/bus"
349
+ raise BlindnessError(
350
+ "no session bus: DBUS_SESSION_BUS_ADDRESS is unset and "
351
+ "$XDG_RUNTIME_DIR/bus does not exist; run from a desktop session"
352
+ )
353
+
354
+
355
+ def bus_socket_dir(environ=None) -> Path:
356
+ """A fresh private directory for the proxy's socket.
357
+
358
+ Under the runtime directory when there is one, because socket paths are
359
+ limited to 107 bytes and that is the shortest private place there is;
360
+ under ``~/tmp`` otherwise, never ``/tmp``.
361
+ """
362
+ import tempfile
363
+
364
+ environ = os.environ if environ is None else environ
365
+ runtime = environ.get("XDG_RUNTIME_DIR")
366
+ base = Path(runtime) / "quirework" if runtime else Path.home() / "tmp"
367
+ base.mkdir(mode=0o700, parents=True, exist_ok=True)
368
+ return Path(tempfile.mkdtemp(prefix="bus.", dir=base))
369
+
370
+
371
+ class Bus:
372
+ """A session bus filtered to named services, for one jail.
373
+
374
+ The proxy writes a byte to its ``--fd`` once it listens and exits when
375
+ every reader of that pipe is gone. bwrap holds the read end through
376
+ ``--sync-fd``, so the proxy lives exactly as long as the jail. The
377
+ launcher passes that descriptor to bwrap and then drops its own copy
378
+ (``release``); ``close`` reaps the proxy and its socket directory, and
379
+ is safe to call whether or not a jail ever started.
380
+ """
381
+
382
+ # Every flag this door raises starts with this, so a plan can name the
383
+ # flag it accepts without matching the service list that follows.
384
+ FLAG_PREFIX = "Session bus proxied into the jail for "
385
+
386
+ def __init__(self, names):
387
+ reason = bus_proxy_missing()
388
+ if reason:
389
+ raise BlindnessError(reason)
390
+ self.names = tuple(names)
391
+ address = host_bus_address()
392
+ self.directory = bus_socket_dir()
393
+ self.socket = self.directory / "bus"
394
+ if len(str(self.socket).encode()) > 107:
395
+ shutil.rmtree(self.directory, ignore_errors=True)
396
+ raise BlindnessError(
397
+ f"socket path {self.socket} exceeds 107 bytes; set "
398
+ "XDG_RUNTIME_DIR to a shorter directory"
399
+ )
400
+ import select
401
+
402
+ reader, writer = os.pipe()
403
+ argv = [BUS_PROXY, f"--fd={writer}", address, str(self.socket), "--filter"]
404
+ argv += [f"--talk={name}" for name in self.names]
405
+ try:
406
+ self.process = subprocess.Popen(
407
+ argv,
408
+ pass_fds=(writer,),
409
+ stdin=subprocess.DEVNULL,
410
+ stderr=subprocess.PIPE,
411
+ )
412
+ except OSError:
413
+ os.close(reader)
414
+ os.close(writer)
415
+ shutil.rmtree(self.directory, ignore_errors=True)
416
+ raise
417
+ os.close(writer)
418
+ ready, _, _ = select.select([reader], [], [], BUS_READY_SECONDS)
419
+ if not ready or os.read(reader, 1) == b"":
420
+ os.close(reader)
421
+ self.process.kill()
422
+ error = (self.process.communicate()[1] or b"").decode(errors="replace")
423
+ shutil.rmtree(self.directory, ignore_errors=True)
424
+ raise BlindnessError(
425
+ f"{BUS_PROXY} did not come up on {self.socket}"
426
+ + (f": {error.strip()}" if error.strip() else "")
427
+ )
428
+ self.fd: int | None = reader
429
+
430
+ @property
431
+ def pass_fds(self) -> tuple[int, ...]:
432
+ return () if self.fd is None else (self.fd,)
433
+
434
+ def argv(self) -> list[str]:
435
+ """The jail's side: a private runtime dir holding only the socket."""
436
+ runtime = Path(f"/run/user/{os.getuid()}")
437
+ if self.fd is None:
438
+ raise BlindnessError("the session bus was already released")
439
+ return [
440
+ "--size",
441
+ str(BUS_RUNTIME_BYTES),
442
+ "--tmpfs",
443
+ str(runtime),
444
+ "--ro-bind",
445
+ str(self.socket),
446
+ str(runtime / "bus"),
447
+ "--sync-fd",
448
+ str(self.fd),
449
+ "--setenv",
450
+ "XDG_RUNTIME_DIR",
451
+ str(runtime),
452
+ "--setenv",
453
+ "DBUS_SESSION_BUS_ADDRESS",
454
+ f"unix:path={runtime / 'bus'}",
455
+ ]
456
+
457
+ def receipt(self) -> dict:
458
+ return {"names": list(self.names), "proxy": BUS_PROXY, "filtered": True}
459
+
460
+ @staticmethod
461
+ def flag(receipt) -> str:
462
+ """The red flag a receipt carries when this door was opened.
463
+
464
+ A flag and not a failure: the runner cannot start without it. But the
465
+ secrets service answers for every secret in the operator's login
466
+ keyring, so a receipt that did not say so would claim a confinement
467
+ the jail did not have.
468
+ """
469
+ return (
470
+ Bus.FLAG_PREFIX
471
+ + ", ".join(receipt["names"])
472
+ + ": the operator's login keyring is readable by the seat."
473
+ )
474
+
475
+ def release(self) -> None:
476
+ """Drop this process's copy of the pipe once the jail holds it."""
477
+ if self.fd is not None:
478
+ os.close(self.fd)
479
+ self.fd = None
480
+
481
+ def close(self) -> None:
482
+ self.release()
483
+ try:
484
+ self.process.wait(timeout=BUS_READY_SECONDS)
485
+ except subprocess.TimeoutExpired:
486
+ self.process.kill()
487
+ self.process.wait()
488
+ self.process.stderr.close()
489
+ shutil.rmtree(self.directory, ignore_errors=True)
490
+
491
+
492
+ def bus_names(runners) -> tuple[str, ...]:
493
+ """The bus names these runners need, in a stable order."""
494
+ return tuple(sorted({n for r in runners for n in RUNNERS[r].bus_names}))
495
+
496
+
497
+ @contextmanager
498
+ def session_bus(runners):
499
+ """The filtered bus a jail for ``runners`` needs, or None if it needs none."""
500
+ names = bus_names(runners)
501
+ if not names:
502
+ yield None
503
+ return
504
+ bus = Bus(names)
505
+ try:
506
+ yield bus
507
+ finally:
508
+ bus.close()
509
+
510
+
511
+ def popen(argv, bus=None, **kwargs) -> subprocess.Popen:
512
+ """``subprocess.Popen`` for a jail, handing it the bus's pipe if it has one.
513
+
514
+ The pipe is released here, right after the jail holds it, so this
515
+ process's copy never keeps a proxy alive past the seat it served.
516
+ """
517
+ try:
518
+ return subprocess.Popen(argv, pass_fds=bus.pass_fds if bus else (), **kwargs)
519
+ finally:
520
+ if bus is not None:
521
+ bus.release()
522
+
523
+
524
+ def _project_root() -> Path | None:
525
+ """The selected project's root, or None when no project is selected.
526
+
527
+ The jail hides it when it lies outside the blanked paths; a caller that
528
+ wants it visible binds it back read-only afterwards, as the review driver
529
+ does. Tests and probes run without a project and hide nothing extra.
530
+ """
531
+ from quirework.project import ConfigurationError
532
+
533
+ try:
534
+ return Path(current_project().root).resolve()
535
+ except ConfigurationError:
536
+ return None
537
+
538
+
539
+ @dataclass(frozen=True)
540
+ class Probe:
541
+ """A FREE check that one flag a blind run depends on is still live.
542
+
543
+ Costs nothing: every one of these is rejected during argument or config
544
+ parsing, before a token is spent.
545
+
546
+ The reason this exists is codex's asymmetry, which generalizes: an unknown
547
+ ``-c`` **key** is accepted in silence while an invalid **value** errors
548
+ loudly, and ``--strict-config`` does not close the gap. So a knob that is
549
+ renamed upstream does not break -- it quietly stops doing anything, and a
550
+ blind seat gets its web tool or its personality back with every gate still
551
+ green. Feeding a deliberately invalid value turns that silent failure into
552
+ a loud one: if the error stops arriving, the key is dead.
553
+
554
+ ``needles`` are matched against the combined output, and they pin the
555
+ *values* too -- codex's error enumerates the valid ones, so requiring
556
+ "disabled" proves both that ``web_search`` exists and that the setting we
557
+ pass it is still spelled that way.
558
+ """
559
+
560
+ label: str
561
+ args: tuple[str, ...]
562
+ expect: str = "error" # "error" | "ok"
563
+ needles: tuple[str, ...] = ()
564
+ why: str = ""
565
+ # Run with the network cut, which makes "this probe is free" a property of
566
+ # the sandbox rather than a claim in a docstring: a probe that can reach no
567
+ # API cannot buy a token, so the day a key goes dead the probe FAILS
568
+ # instead of quietly running the prompt it was carrying.
569
+ offline: bool = True
570
+ # What no needle can say, such as which value one row of a table holds:
571
+ # given the output, the problem it shows, or None.
572
+ verdict: Callable[[str], str | None] | None = None
573
+
574
+
575
+ @dataclass(frozen=True)
576
+ class Runner:
577
+ """What one runner needs bound back into a blanked home.
578
+
579
+ ``creds`` is runner-specific knowledge and cannot be derived; the launcher
580
+ path is derived from ``PATH`` so a version bump does not rot the list. The
581
+ guard against either going stale is ``blind check``, which starts the real
582
+ binary inside the jail rather than trusting this table.
583
+ """
584
+
585
+ name: str
586
+ home: str # the runner's state dir, given a private copy
587
+ creds: tuple[str, ...] = () # bound read-only ON TOP of that private dir
588
+ # Where the login is kept. A "file" login is one of `creds`, so a missing
589
+ # one means the runner cannot sign in and the seat is refused before it
590
+ # starts. A "keyring" login is in the desktop keyring, reached over the
591
+ # session bus: `creds` are then files an older release kept, bound back
592
+ # when present and never required, and the canary proves the login.
593
+ login: str = "file"
594
+ probe: tuple[str, ...] = ("--version",)
595
+ probes: tuple[Probe, ...] = () # free flag-liveness contract
596
+ notes: str = ""
597
+ # Session-bus names the runner must reach to run at all. The jail never
598
+ # binds the raw bus; it proxies exactly these in (see `Bus`).
599
+ bus_names: tuple[str, ...] = ()
600
+ # Where a session lives in the private home, as a glob with `{id}` for the
601
+ # session id. A resume continues that file, so its absence is the whole
602
+ # question `require_session` answers.
603
+ session: str = ""
604
+ # Directories in the home the runner fills with what it unpacks for one
605
+ # launch and needs from no other. Each launch gets them as a fresh tmpfs,
606
+ # so what is unpacked there never reaches the disk.
607
+ scratch: tuple[str, ...] = ()
608
+
609
+
610
+ def _reads_false(feature: str) -> Callable[[str], str | None]:
611
+ """A verdict: ``feature`` is listed, and false, in ``codex features list``."""
612
+
613
+ def verdict(out: str) -> str | None:
614
+ for line in out.splitlines():
615
+ cols = line.split()
616
+ if cols and cols[0] == feature:
617
+ if cols[-1] == "false":
618
+ return None
619
+ return f"{feature} reads {cols[-1]} under the override"
620
+ return f"{feature} is not in `codex features list`, so the switch names nothing"
621
+
622
+ return verdict
623
+
624
+
625
+ def _codex_seat_probes() -> tuple[Probe, ...]:
626
+ """The free contract for ``CODEX_SEAT``, derived from the set itself.
627
+
628
+ Codex accepts an unknown feature name in silence. A switch renamed
629
+ upstream would hand every seat back what it turns off, with every
630
+ launch still green and the canary blind to anything but MCP servers.
631
+ Codex's own feature list under the override is the free witness, so
632
+ each ``features.NAME=false`` in the set must leave NAME listed and
633
+ false. A switch added to the set is probed here with no edit.
634
+
635
+ The thread limit is a setting rather than a feature, and follows the
636
+ invalid-value rule: Codex refuses a limit of zero, and the day that
637
+ refusal stops arriving the key is dead. No free check can show that a
638
+ spawn still fails, only that the limit is still read.
639
+ """
640
+ probes = []
641
+ for arg in CODEX_SEAT[1::2]:
642
+ key, _, value = arg.partition("=")
643
+ name = key.removeprefix("features.")
644
+ if value == "false" and name != key and "." not in name:
645
+ probes.append(
646
+ Probe(
647
+ key,
648
+ ("-c", arg, "features", "list"),
649
+ expect="ok",
650
+ verdict=_reads_false(name),
651
+ why="a seat switch every codex launch carries; if it "
652
+ "stops reading false, the seat gets back what it turns off",
653
+ )
654
+ )
655
+ limit = CODEX_NO_SUBAGENTS[1].partition("=")[0]
656
+ probes.append(
657
+ Probe(
658
+ limit,
659
+ ("-c", f"{limit}=0", "features", "list"),
660
+ needles=(limit, "at least 1"),
661
+ why="the one-thread limit is all that stops a seat's spawn_agent "
662
+ "from handing work to a model its receipt never names",
663
+ )
664
+ )
665
+ return tuple(probes)
666
+
667
+
668
+ # Each runner gets a PRIVATE, PER-RUN home bound over its real one. Three jobs
669
+ # at once: the operator's real state (the global brief, `~/.claude/projects`,
670
+ # codex's session and memory stores) never appears; the seat's own state
671
+ # survives from one turn to the next, which a bare tmpfs would not, since every
672
+ # `blind run` is a separate process with a fresh tmpfs and a multi-turn seat
673
+ # would find no session to resume; and two seats in the same run never share a
674
+ # store. Credentials are bound read-only on top rather than copied, so no
675
+ # secret is duplicated onto disk.
676
+ RUNNERS = {
677
+ "claude": Runner(
678
+ "claude",
679
+ home="~/.claude",
680
+ creds=("~/.claude/.credentials.json",),
681
+ probes=(
682
+ Probe(
683
+ "--setting-sources",
684
+ ("-p", "x", "--setting-sources", "bogus"),
685
+ needles=("Invalid setting source", "user, project, local"),
686
+ why="the only thing keeping the global CLAUDE.md out",
687
+ ),
688
+ Probe(
689
+ "--settings",
690
+ ("-p", "x", "--settings", "NOTJSONNOTAPATH"),
691
+ needles=("Settings file not found",),
692
+ why="pins the output style; an Explanatory style swaps a "
693
+ "seat's register",
694
+ ),
695
+ Probe(
696
+ "--tools",
697
+ ("--help",),
698
+ expect="ok",
699
+ needles=("--tools",),
700
+ why="the allow-nothing shape; NOTE claude does not validate "
701
+ "this flag's VALUE, so only its existence is provable "
702
+ "for free",
703
+ ),
704
+ ),
705
+ notes="global CLAUDE.md and ~/.claude/projects replaced by the private home",
706
+ session="projects/**/{id}.jsonl",
707
+ ),
708
+ "agy": Runner(
709
+ "agy",
710
+ home="~/.gemini",
711
+ creds=(
712
+ "~/.gemini/oauth_creds.json",
713
+ "~/.gemini/google_accounts.json",
714
+ "~/.gemini/settings.json",
715
+ "~/.gemini/installation_id",
716
+ ),
717
+ probes=(
718
+ # The one probe that CANNOT be run offline: agy refreshes its
719
+ # OAuth token before it validates anything, so with the network
720
+ # cut it stops at a login URL and never reaches the flag. It is
721
+ # still free, and the needle is what proves it: "Available models"
722
+ # can only be printed by the model-selection check, which happens
723
+ # before any inference. A token spent would mean an answer, not a
724
+ # model list.
725
+ Probe(
726
+ "--model",
727
+ ("-p", "x", "--model", "bogus-model-xyz"),
728
+ needles=("not recognized", "Available models"),
729
+ offline=False,
730
+ why="agy renames its models often (the list moved 3.6 -> 3.7 "
731
+ "mid-week), and a seat's model is its only attribution "
732
+ "receipt",
733
+ ),
734
+ ),
735
+ notes="~/.gemini/GEMINI.md (the global brief) replaced with the home",
736
+ # agy 1.3 signs in through the keyring and writes none of `creds` in a
737
+ # home it has never seen; they are what the Gemini CLI it grew out of
738
+ # left behind. A machine that has them binds them, and one that has
739
+ # not still runs: requiring them refused a seat whose canary had just
740
+ # passed.
741
+ login="keyring",
742
+ # Its token lives in the login keyring, reached over the session bus.
743
+ # The proxy lets this one name through, which still exposes every
744
+ # secret the keyring holds, so the receipt flags it.
745
+ bus_names=("org.freedesktop.secrets",),
746
+ session="antigravity-cli/brain/{id}/.system_generated/logs/transcript_full.jsonl",
747
+ # agy unpacks its 17 MB browser recorder here before every session in
748
+ # a home that lacks it, and only after a model has been chosen. 93
749
+ # seat homes on one machine held 1.5 GB of copies. A clone of the
750
+ # operator's own copy was tried first, and agy replaced it: an older
751
+ # release had unpacked that one, as it will have for anyone who runs
752
+ # agy mostly through seats. The directory holds nothing a resumed
753
+ # session needs, and agy writes it afresh when it is empty.
754
+ scratch=("antigravity-cli/bin",),
755
+ ),
756
+ "codex": Runner(
757
+ "codex",
758
+ home="~/.codex",
759
+ creds=("~/.codex/auth.json",),
760
+ probes=(
761
+ Probe(
762
+ "web_search",
763
+ ("exec", "--ignore-user-config", "-c", 'web_search="bogus"', "x"),
764
+ needles=("unknown variant", "disabled"),
765
+ why="codex ships a live web tool ON by default; if this key "
766
+ "goes dead the seat silently regains it",
767
+ ),
768
+ Probe(
769
+ "personality",
770
+ ("exec", "--ignore-user-config", "-c", 'personality="bogus"', "x"),
771
+ needles=("unknown variant", "none"),
772
+ why="the register pin, codex's analogue of --settings",
773
+ ),
774
+ Probe(
775
+ "--ignore-user-config",
776
+ ("exec", "--ignore-user-config", "--help"),
777
+ expect="ok",
778
+ why="keeps the operator's config and the project AGENTS.md "
779
+ "out of the seat",
780
+ ),
781
+ *_codex_seat_probes(),
782
+ ),
783
+ notes="sessions, logs and the memory store replaced by the private home",
784
+ session="sessions/**/*{id}.jsonl",
785
+ ),
786
+ }
787
+
788
+
789
+ def _expand(p: str) -> Path:
790
+ return Path(os.path.expanduser(p)).resolve()
791
+
792
+
793
+ def backend_error() -> str | None:
794
+ """``None`` if the machine can confine a seat, else why it cannot."""
795
+ if sys.platform != "linux":
796
+ return (
797
+ f"confinement is Linux-only for now (this is {sys.platform}); "
798
+ "docs/workflows.md#execution-and-recovery, 'The run dir is not a wall'"
799
+ )
800
+ if shutil.which(BWRAP) is None:
801
+ return "bwrap not found on PATH (dnf install bubblewrap)"
802
+ return None
803
+
804
+
805
+ def _launcher_paths(link: Path, hops: int = 10) -> list[Path]:
806
+ """Every directory needed to follow a launcher's symlink chain in the jail.
807
+
808
+ One hop at a time (not ``resolve()``), collecting each element's parent, so
809
+ an intermediate link like codex's ``current`` still has somewhere to point.
810
+ """
811
+ out, seen, cur = [], set(), link
812
+ for _ in range(hops):
813
+ if cur in seen:
814
+ break
815
+ seen.add(cur)
816
+ out.append(cur.parent)
817
+ if not cur.is_symlink():
818
+ break
819
+ cur = Path(os.path.normpath(os.path.join(cur.parent, os.readlink(cur))))
820
+ out.append(cur if cur.is_dir() else cur.parent)
821
+ return out
822
+
823
+
824
+ def runner_binds(names) -> tuple[list[Path], list[str]]:
825
+ """``(paths, missing)`` to bind back read-only for these runners.
826
+
827
+ The launcher is resolved through ``PATH`` and its symlink chain walked one
828
+ hop at a time, because the chain has *intermediate* links: codex goes
829
+ `~/.local/bin/codex` -> `~/.codex/packages/standalone/current/bin/codex`,
830
+ and `current` is itself a symlink to the versioned release. Binding only
831
+ the fully-resolved target leaves `current` missing inside the jail and the
832
+ runner unfindable -- which `blind check` caught on its first run, and is
833
+ exactly the staleness this table would otherwise hide.
834
+ """
835
+ paths: list[Path] = []
836
+ missing: list[str] = []
837
+ for name in names:
838
+ launcher = shutil.which(name)
839
+ if launcher is None:
840
+ missing.append(f"{name}: not on PATH")
841
+ continue
842
+ paths += _launcher_paths(Path(launcher))
843
+ # Credentials are NOT returned here: they are bound by `jail_argv`
844
+ # after the runner's private home, because they live inside it and a
845
+ # bind in the wrong order is silently replaced by the one over it.
846
+ for c in RUNNERS[name].creds if RUNNERS[name].login == "file" else ():
847
+ if not _expand(c).exists():
848
+ missing.append(f"{name}: {c} not found")
849
+ if RUNNERS[name].bus_names and (reason := bus_proxy_missing()):
850
+ missing.append(f"{name}: {reason}")
851
+ return paths, missing
852
+
853
+
854
+ # The one suffix that names a run's private runner homes. Every workflow that
855
+ # confines a seat -- the run engine, the workshop, a room, a contextual
856
+ # review, a chapter comparison -- reaches its homes through `state_root`, so
857
+ # this module owns the name and is therefore the only place that can find
858
+ # them all again afterwards.
859
+ STATE_SUFFIX = ".blind-state"
860
+
861
+ # https://bford.info/cachedir/: borg, restic and GNU tar skip a directory
862
+ # holding this file when asked to exclude caches, wherever it sits.
863
+ CACHEDIR_SIGNATURE = "Signature: 8a477f597d28d172789f06886806bc55"
864
+ CACHEDIR_TAG = (
865
+ f"{CACHEDIR_SIGNATURE}\n"
866
+ "# This file is a cache directory tag created by quirework.\n"
867
+ "# Private runner homes of confined seats; their runs' records are elsewhere.\n"
868
+ "# For information about cache directory tags see https://bford.info/cachedir/\n"
869
+ )
870
+
871
+
872
+ def homes_root() -> Path:
873
+ """`$XDG_CACHE_HOME/quirework/homes`, where every private runner home lives.
874
+
875
+ A home is a harness's own installation and scratch state: tens of
876
+ megabytes, holding nothing a record needs once the run is over. Kept
877
+ beside the records under the state directory, homes were nearly all of
878
+ its bytes, and backups keep a state directory by default. The cache
879
+ directory is where data that can be lost lives, and the tag makes
880
+ backup tools skip it wherever it is. Not `/tmp` or the runtime
881
+ directory: both are emptied at reboot or logout, and a run held on a
882
+ provider limit waits hours to resume its sessions.
883
+ """
884
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
885
+ return Path(base).expanduser() / "quirework" / "homes"
886
+
887
+
888
+ def state_root(run_dir: Path) -> Path:
889
+ """The path a run's private runner homes are reached by, beside the run dir.
890
+
891
+ Never inside it, for the same reason the receipt is outside -- a seat
892
+ that can see its own jail furniture can tell it is in one -- and because
893
+ a runner's state dir is bound *over* its real path, so it must not also
894
+ be visible as an ordinary directory in the seat's workspace.
895
+
896
+ What sits there is a link into `homes_root` (`make_state_root`). A run
897
+ whose homes were made before they moved holds a directory instead, and a
898
+ pruned run a tombstone naming when (`tombstone`).
899
+ """
900
+ return run_dir.parent / f"{run_dir.name}{STATE_SUFFIX}"
901
+
902
+
903
+ def _tagged(base: Path) -> bool:
904
+ try:
905
+ return (base / "CACHEDIR.TAG").read_text().startswith(CACHEDIR_SIGNATURE)
906
+ except OSError:
907
+ return False
908
+
909
+
910
+ def home_target(link: Path) -> Path | None:
911
+ """The cache directory a home link leads to, if this module made it.
912
+
913
+ Only a directory directly inside a tagged `homes` directory qualifies,
914
+ under the cache in use now or the one in use when the home was made.
915
+ A link leading anywhere else is never followed into a removal.
916
+ """
917
+ target = Path(os.readlink(link))
918
+ if not target.is_absolute():
919
+ target = link.parent / target
920
+ if target.is_symlink() or not target.is_dir():
921
+ return None
922
+ if target.parent.name != "homes" or not _tagged(target.parent):
923
+ return None
924
+ return target
925
+
926
+
927
+ def tombstone(entry: Path) -> dict | None:
928
+ """What `prune` left where a run's homes were, or None if it left nothing."""
929
+ if entry.is_symlink() or not entry.is_file():
930
+ return None
931
+ try:
932
+ return json.loads(entry.read_text())
933
+ except (OSError, ValueError):
934
+ return {}
935
+
936
+
937
+ def make_state_root(run_dir: Path) -> Path:
938
+ """A run's private homes, made in the cache and linked beside the run dir.
939
+
940
+ Returns the link. A run that has its homes keeps them. A run whose homes
941
+ were made before they moved has them moved into the cache the first time
942
+ it launches again, so a run already under way leaves the backed-up state
943
+ without losing its sessions; a cache on another filesystem cannot take
944
+ a rename, and the directory then stays where it is. A tombstone, or a
945
+ link the cache cleaner left dangling, gives way to a fresh home: a launch
946
+ that needed the old session was refused before it got here.
947
+ """
948
+ link = state_root(Path(run_dir))
949
+ if link.is_symlink() and link.is_dir():
950
+ return link
951
+ base = homes_root()
952
+ base.mkdir(mode=0o700, parents=True, exist_ok=True)
953
+ if not _tagged(base):
954
+ (base / "CACHEDIR.TAG").write_text(CACHEDIR_TAG)
955
+ import tempfile
956
+
957
+ stem = f"{Path(run_dir).parent.name}.{Path(run_dir).name}".lstrip(".")[:60]
958
+ target = Path(tempfile.mkdtemp(prefix=f"{stem}-", dir=base))
959
+ if link.is_dir():
960
+ try:
961
+ # Onto the empty directory just made, which rename replaces.
962
+ os.rename(link, target)
963
+ except OSError as exc:
964
+ os.rmdir(target)
965
+ if exc.errno == errno.EXDEV:
966
+ return link
967
+ raise
968
+ elif link.is_symlink() or link.exists():
969
+ link.unlink()
970
+ link.parent.mkdir(parents=True, exist_ok=True)
971
+ try:
972
+ os.symlink(target, link)
973
+ except FileExistsError:
974
+ # Another launch of the same run made its homes first.
975
+ shutil.rmtree(target)
976
+ if not (link.is_symlink() and link.is_dir()):
977
+ raise
978
+ return link
979
+
980
+
981
+ # What keeps a run's homes from `prune`, at the root of the homes, beside the
982
+ # runners' directories. Only those are bound into a jail, so no seat sees it.
983
+ HOLD = "held.json"
984
+
985
+
986
+ def hold(run_dir: Path, why: str) -> None:
987
+ """Keep a run's homes from `prune` until `release`, saying why.
988
+
989
+ A home holds a session a later command resumes: a room's next round, a
990
+ review continued next week, a fork's parent its children are checked
991
+ against. Only the procedure that runs the seats knows when that is
992
+ over, so it holds the homes when it launches a seat and releases them
993
+ where it knows the run is finished. One it never releases is kept
994
+ until a prune is forced or the cache rule collects it, which is the
995
+ safe way to be wrong.
996
+ """
997
+ entry = state_root(Path(run_dir))
998
+ if entry.is_dir():
999
+ (entry / HOLD).write_text(
1000
+ json.dumps(
1001
+ {
1002
+ "why": why,
1003
+ "since": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1004
+ }
1005
+ )
1006
+ + "\n"
1007
+ )
1008
+
1009
+
1010
+ def release(run_dir: Path) -> None:
1011
+ """Let `prune` take a run's homes once it is finished with them."""
1012
+ entry = state_root(Path(run_dir))
1013
+ if entry.is_dir():
1014
+ (entry / HOLD).unlink(missing_ok=True)
1015
+
1016
+
1017
+ def held(home: Path) -> str | None:
1018
+ """Why a run still needs these homes, or None once it has let them go."""
1019
+ try:
1020
+ return json.loads((home / HOLD).read_text())["why"]
1021
+ except FileNotFoundError:
1022
+ return None
1023
+ except (OSError, ValueError, KeyError, TypeError):
1024
+ return "held, for a reason that can no longer be read"
1025
+
1026
+
1027
+ def discard_state_root(run_dir: Path) -> None:
1028
+ """Remove a run's private homes, and whatever leads to them."""
1029
+ link = state_root(Path(run_dir))
1030
+ if link.is_symlink():
1031
+ target = home_target(link) if link.exists() else None
1032
+ if target is not None:
1033
+ shutil.rmtree(target)
1034
+ link.unlink()
1035
+ elif link.is_dir():
1036
+ shutil.rmtree(link)
1037
+ elif link.exists():
1038
+ link.unlink()
1039
+
1040
+
1041
+ class SessionGone(RuntimeError):
1042
+ """A resume or a fork whose session the private home no longer holds."""
1043
+
1044
+
1045
+ def gone(entry: Path, home: Path) -> str:
1046
+ """Why a run's private home is missing, as far as anything recorded it."""
1047
+ buried = tombstone(entry)
1048
+ if buried is not None:
1049
+ when = buried.get("pruned_at", "an unrecorded time")
1050
+ held = buried.get("held")
1051
+ return f"its private homes were pruned at {when}" + (
1052
+ f", by force, while held: {held}" if held else ""
1053
+ )
1054
+ if entry.is_symlink():
1055
+ return (
1056
+ f"its private home {home} is gone: the cache it lived in, "
1057
+ f"{os.readlink(entry)}, was cleared"
1058
+ )
1059
+ return f"its private home {home} is gone"
1060
+
1061
+
1062
+ def session_paths(home: Path, runner: str, session_id: str) -> list[Path]:
1063
+ """The native file(s) holding one session in a runner's private home.
1064
+
1065
+ The id is matched literally, so an id that happens to contain a glob
1066
+ character can never match another session. A resumed codex session
1067
+ opens a second rollout under the same id, so there may be several.
1068
+ """
1069
+ pattern = RUNNERS[runner].session.format(id=glob.escape(session_id))
1070
+ return sorted(Path(home).glob(pattern))
1071
+
1072
+
1073
+ def session_path(home: Path, runner: str, session_id: str) -> Path:
1074
+ """A place in a runner's private home where ``session_paths`` finds a session.
1075
+
1076
+ The runner's ``session`` glob, read the other way: every wildcard taken
1077
+ at its narrowest, so ``projects/**/{id}.jsonl`` gives
1078
+ ``projects/{id}.jsonl``. A fake harness puts its session here and a
1079
+ resume finds it, and neither the fake nor a test repeats a runner's
1080
+ layout, which is stated only in `RUNNERS`.
1081
+ """
1082
+ pattern = RUNNERS[runner].session
1083
+ if not pattern:
1084
+ raise ValueError(f"{runner} keeps no session a resume could continue")
1085
+ return Path(home) / pattern.replace("**/", "").replace("*", "").format(
1086
+ id=session_id
1087
+ )
1088
+
1089
+
1090
+ def require_session(run_dir: Path, runner: str, session_id: str) -> Path:
1091
+ """The file holding ``session_id`` in this run's private home.
1092
+
1093
+ A resume or a fork continues a conversation the harness keeps in the
1094
+ seat's private home, so it can run only while that home holds the
1095
+ session. `prune` removes homes it judges finished, and a home removed
1096
+ by hand goes the same way. Launched anyway, the harness fails for a
1097
+ reason it does not name, often after the verb has already filed the
1098
+ turn it meant to continue. Every verb that resumes or forks calls this
1099
+ first and changes nothing on disk when it raises.
1100
+ """
1101
+ entry = state_root(Path(run_dir))
1102
+ home = entry / runner
1103
+ if not home.is_dir():
1104
+ why = gone(entry, home)
1105
+ else:
1106
+ found = session_paths(home, runner, session_id)
1107
+ if found:
1108
+ return found[0]
1109
+ why = f"its private home {home} no longer holds it"
1110
+ raise SessionGone(
1111
+ f"cannot continue {runner} session {session_id}: {why}, and a new "
1112
+ "session cannot carry on its conversation. Nothing was changed, and "
1113
+ "the run's records are intact"
1114
+ )
1115
+
1116
+
1117
+ def private_homes(root: Path) -> list[Path]:
1118
+ """Where each run under ``root`` reaches its private homes, newest last.
1119
+
1120
+ A link into the cache, or the directory of a run whose homes were made
1121
+ before they moved there. A tombstone, and a link whose home the cache
1122
+ cleaner already took, lead to nothing left to remove.
1123
+ """
1124
+ found = [p for p in Path(root).rglob(f"*{STATE_SUFFIX}") if p.is_dir()]
1125
+ return sorted(found, key=lambda p: (_touched(p), str(p)))
1126
+
1127
+
1128
+ def _home_of(entry: Path) -> Path | None:
1129
+ """The directory pruning ``entry`` removes, or None where it may not."""
1130
+ if entry.is_symlink():
1131
+ return home_target(entry)
1132
+ if entry.is_dir() and entry.name.endswith(STATE_SUFFIX):
1133
+ return entry
1134
+ return None
1135
+
1136
+
1137
+ def _touched(home: Path) -> float:
1138
+ """When anything inside this home last changed."""
1139
+ stamps = [home.stat().st_mtime]
1140
+ stamps += [p.stat().st_mtime for p in home.rglob("*") if not p.is_symlink()]
1141
+ return max(stamps)
1142
+
1143
+
1144
+ def _bytes(home: Path) -> int:
1145
+ """Disk actually occupied, counted the way ``du`` counts it.
1146
+
1147
+ Blocks rather than apparent size, and each inode once: a runner install is
1148
+ heavily hardlinked, and summing every path's ``st_size`` reported 1.1 GB
1149
+ for a seat holding 52 MB. A prune that overstates what it frees by twenty
1150
+ times is worse than one that does not report at all.
1151
+ """
1152
+ seen, total = set(), 0
1153
+ for item in home.rglob("*"):
1154
+ if item.is_symlink() or not item.is_file():
1155
+ continue
1156
+ try:
1157
+ info = item.stat()
1158
+ except OSError:
1159
+ continue
1160
+ key = (info.st_dev, info.st_ino)
1161
+ if key in seen:
1162
+ continue
1163
+ seen.add(key)
1164
+ total += info.st_blocks * 512
1165
+ return total
1166
+
1167
+
1168
+ def liveness_known() -> bool:
1169
+ """Whether this machine can say which seats still have a live process.
1170
+
1171
+ ``occupied`` reads ``/proc``. Without it there is no answer, and no
1172
+ answer must never be read as "nobody is here": a running seat's home
1173
+ removed out from under it breaks the run it is in the middle of.
1174
+ """
1175
+ return Path("/proc").is_dir()
1176
+
1177
+
1178
+ def occupied(home: Path) -> int | None:
1179
+ """The pid of a process still working in this home's seat, or None.
1180
+
1181
+ A confined harness keeps its working directory inside the seat workspace
1182
+ while its runner home is bound over the real one, so either path turning up
1183
+ as a live process's cwd means that seat has not finished. ``home`` is
1184
+ where the run reaches its homes, and the cache directory it leads to
1185
+ counts too. Best effort by construction: ``/proc`` refuses processes this
1186
+ user does not own, and a seat merely paused between turns looks idle,
1187
+ which is what the age window in ``prune`` is for.
1188
+ """
1189
+ seat = home.parent / home.name[: -len(STATE_SUFFIX)]
1190
+ places = {home, home.resolve(), seat}
1191
+ procs = Path("/proc")
1192
+ if not procs.is_dir():
1193
+ return None
1194
+ for entry in procs.iterdir():
1195
+ if not entry.name.isdigit():
1196
+ continue
1197
+ try:
1198
+ cwd = (entry / "cwd").readlink()
1199
+ except OSError:
1200
+ continue
1201
+ if cwd in places or any(place in cwd.parents for place in places):
1202
+ return int(entry.name)
1203
+ return None
1204
+
1205
+
1206
+ def bury(entry: Path, record: dict) -> None:
1207
+ """Leave a tombstone where a run reached its homes, saying they went.
1208
+
1209
+ A later resume then reports that they were pruned, and when, rather
1210
+ than that they vanished.
1211
+ """
1212
+ if entry.is_symlink():
1213
+ entry.unlink()
1214
+ entry.write_text(json.dumps(record, indent=2) + "\n")
1215
+
1216
+
1217
+ def prune(roots, *, grace: float = 3600, force: bool = False, dry_run: bool = False):
1218
+ """Remove finished seats' private runner homes; keep every record.
1219
+
1220
+ A home is the harness's own installation and scratch state, bound over the
1221
+ real one so a seat cannot reach the operator's credentials or memory. It is
1222
+ large -- one measured codex seat kept 53 MB against 1.6 MB of records for
1223
+ the whole room. The cache cleaner collects a home untouched for 30 days
1224
+ where its rule is installed; this removes finished ones sooner, and
1225
+ reaches homes made before they moved into the cache.
1226
+
1227
+ A home its run still holds (`hold`) is kept unless ``force`` says
1228
+ otherwise, and the tombstone then records what the run was holding it
1229
+ for. Age is the only test for a home nothing holds: one made before
1230
+ holds existed, or by a command that runs one seat and is done.
1231
+
1232
+ Nothing here reads a receipt, an answer or a transcript, so pruning cannot
1233
+ remove evidence: the native transcripts inside a home have already been
1234
+ read into the receipts beside it, and `launch.json` records the home's path
1235
+ whether or not the directory still exists. Each run pruned keeps a
1236
+ tombstone where it reached its homes.
1237
+ """
1238
+ removed, kept, freed = [], [], 0
1239
+ known = liveness_known()
1240
+ for root in roots:
1241
+ for entry in private_homes(Path(root).expanduser().resolve()):
1242
+ home = _home_of(entry)
1243
+ if home is None:
1244
+ kept.append(
1245
+ (entry, 0, "leads outside the homes directory; not followed")
1246
+ )
1247
+ continue
1248
+ size, pid = _bytes(home), occupied(entry) if known else None
1249
+ age = time.time() - _touched(home)
1250
+ needed = held(home)
1251
+ # The age window and a hold are advice, and `force` overrides
1252
+ # both. A live seat is not: removing the home out from under a
1253
+ # running harness would break the run it is in the middle of, and
1254
+ # no flag here should be able to do that. Nor may "cannot tell"
1255
+ # pass for "not in use".
1256
+ if not known:
1257
+ kept.append(
1258
+ (
1259
+ entry,
1260
+ size,
1261
+ f"liveness unknown without /proc on {sys.platform}; "
1262
+ "check for a live seat and remove the home by hand",
1263
+ )
1264
+ )
1265
+ elif pid is not None:
1266
+ kept.append((entry, size, f"in use by pid {pid}"))
1267
+ elif needed and not force:
1268
+ kept.append((entry, size, f"held: {needed}"))
1269
+ elif age < grace and not force:
1270
+ kept.append((entry, size, f"touched {int(age)}s ago"))
1271
+ elif dry_run:
1272
+ removed.append((entry, size, "would remove"))
1273
+ freed += size
1274
+ else:
1275
+ shutil.rmtree(home)
1276
+ bury(
1277
+ entry,
1278
+ {
1279
+ "pruned_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1280
+ "freed_bytes": size,
1281
+ # What a forced prune overrode, for the resume it fails.
1282
+ **({"held": needed} if needed else {}),
1283
+ },
1284
+ )
1285
+ removed.append((entry, size, "removed"))
1286
+ freed += size
1287
+ return {"removed": removed, "kept": kept, "freed": freed}
1288
+
1289
+
1290
+ class _Probe:
1291
+ """The canary argument for a jailed process that is not a seat."""
1292
+
1293
+ def __repr__(self):
1294
+ return "blind.PROBE"
1295
+
1296
+
1297
+ PROBE = _Probe()
1298
+
1299
+
1300
+ def _require_canary(runners, canary) -> None:
1301
+ """Refuse a runner's jail unless a passing canary covers that runner."""
1302
+ if not runners or canary is PROBE:
1303
+ return
1304
+ entries = canary if isinstance(canary, (list, tuple)) else [canary]
1305
+ covered = {e.get("runner") for e in entries if isinstance(e, dict)}
1306
+ missing = [r for r in runners if r not in covered]
1307
+ if missing:
1308
+ raise BlindnessError(
1309
+ f"no launch canary for {', '.join(missing)}: call canary.require "
1310
+ f"before building a seat's jail, and pass its record as canary="
1311
+ )
1312
+
1313
+
1314
+ def jail_argv(
1315
+ run_dir: Path,
1316
+ allow=(),
1317
+ runners=(),
1318
+ env=None,
1319
+ bind_credentials: bool = True,
1320
+ expose_run_dir: bool = True,
1321
+ project: Path | None = None,
1322
+ bus: Bus | None = None,
1323
+ canary=None,
1324
+ ) -> list[str]:
1325
+ """The bwrap argv that confines a seat to ``run_dir``.
1326
+
1327
+ Order is load-bearing, because bwrap applies operations in sequence:
1328
+ ``$HOME`` is blanked before anything is bound back into it; each runner's
1329
+ private home is bound before its credentials and its install tree, both of
1330
+ which sit *inside* that home for at least one runner (codex keeps its
1331
+ binary under ``~/.codex/packages``); and the writable run dir goes last so
1332
+ no read-only bind can shadow it. Callers supplying an access-only token
1333
+ through the subprocess environment can disable credential-file bindings;
1334
+ this keeps a shared OAuth refresh token out of the isolated process.
1335
+
1336
+ ``expose_run_dir=False`` stops before the writable run-dir bind and the
1337
+ chdir, leaving ``run_dir`` as nothing but the anchor for ``state_root``.
1338
+ A caller that is not confining a seat to a frozen packet -- an editorial
1339
+ reviewer stands in the repository, read-only, and writes only its report
1340
+ -- then appends its own binds and its own ``--chdir``. It owns the tail of
1341
+ the sequence, and the ordering rules above still apply to it: read-only
1342
+ binds first, the writable one last, so nothing shadows it.
1343
+
1344
+ ``bus`` is the filtered session bus from ``session_bus``, required when a
1345
+ runner declares ``bus_names`` and refused otherwise, and the caller
1346
+ launches through ``popen`` so the jail receives the proxy's pipe.
1347
+
1348
+ ``canary`` is the passing record ``canary.require`` returned for each
1349
+ runner in ``runners`` (one record, or a list), and the jail is refused
1350
+ without it: this is the one function every runner's jail is built by, so
1351
+ it is where a launch that skipped its canary is stopped. ``PROBE`` stands
1352
+ in where the process is not a seat answering a question, such as a
1353
+ runner started only to show it starts, or the canary itself.
1354
+ """
1355
+ _require_canary(runners, canary)
1356
+ needed = bus_names(runners)
1357
+ if needed and (bus is None or bus.names != needed):
1358
+ raise BlindnessError(
1359
+ f"{', '.join(runners)} needs the session bus for {', '.join(needed)}; "
1360
+ "open it with blind.session_bus and pass it to the jail"
1361
+ )
1362
+ if bus is not None and not needed:
1363
+ raise BlindnessError("no runner in this jail needs a session bus")
1364
+ home = Path.home()
1365
+ triples = system_root()
1366
+ trees = bound_trees(triples)
1367
+ argv = root_argv(triples)
1368
+ blanked = [Path(str(home) if b == "$HOME" else b) for b in BLANKED]
1369
+ for target in blanked:
1370
+ argv += ["--size", str(TMPFS_BYTES), "--tmpfs", str(target)]
1371
+ # A project inside a bound tree (say `/opt/src/book`) would otherwise be
1372
+ # readable through that tree. One outside every bound tree does not exist
1373
+ # in the jail and needs nothing. A caller that stands a reviewer in the
1374
+ # repository binds it back read-only after this, and a later bind wins.
1375
+ project = _project_root() if project is None else project
1376
+ if (
1377
+ project is not None
1378
+ and visible(project, trees)
1379
+ and not visible(project, blanked)
1380
+ ):
1381
+ argv += ["--tmpfs", str(project)]
1382
+ # Bound at the directory the link leads to, so the launch record names
1383
+ # where the home really lives.
1384
+ root = make_state_root(run_dir).resolve() if runners else None
1385
+ creds: list[Path] = []
1386
+ for name in runners:
1387
+ r = RUNNERS[name]
1388
+ private = root / name
1389
+ private.mkdir(exist_ok=True)
1390
+ argv += ["--bind", str(private), str(_expand(r.home))]
1391
+ for rel in r.scratch:
1392
+ # A copy unpacked before scratch existed would only sit under the
1393
+ # tmpfs, taking room and never read again. The seat could have
1394
+ # put a link there, and removing one never follows it.
1395
+ stale = private / rel
1396
+ if stale.is_symlink() or stale.is_file():
1397
+ stale.unlink()
1398
+ elif stale.is_dir():
1399
+ shutil.rmtree(stale)
1400
+ argv += ["--size", str(TMPFS_BYTES), "--tmpfs", str(_expand(r.home) / rel)]
1401
+ if bind_credentials:
1402
+ creds += [_expand(c) for c in r.creds if _expand(c).exists()]
1403
+ binds, _ = runner_binds(runners)
1404
+ for p in binds + creds + [_expand(str(a)) for a in allow]:
1405
+ argv += ["--ro-bind", str(p), str(p)]
1406
+ if bus is not None:
1407
+ argv += bus.argv()
1408
+ if expose_run_dir:
1409
+ argv += ["--bind", str(run_dir), str(run_dir), "--chdir", str(run_dir)]
1410
+ # --chdir moves the process but not the shell variables it inherited,
1411
+ # and a seat that echoes $PWD/$OLDPWD would read back the launcher's
1412
+ # path -- naming the project directory it cannot open. The content is
1413
+ # safe either way; the point is to leave nothing to notice.
1414
+ argv += ["--setenv", "PWD", str(run_dir)]
1415
+ argv += ["--unsetenv", "OLDPWD"]
1416
+ for k, v in (env or {}).items():
1417
+ argv += ["--setenv", k, v]
1418
+ return argv
1419
+
1420
+
1421
+ _SEMVER = re.compile(r"(\d+)\.(\d+)\.(\d+)")
1422
+
1423
+
1424
+ def runner_version(name: str) -> str | None:
1425
+ """``x.y.z`` from ``<runner> --version``, or None if it cannot be read."""
1426
+ try:
1427
+ r = subprocess.run(
1428
+ [name, "--version"], capture_output=True, text=True, timeout=60
1429
+ )
1430
+ except (OSError, subprocess.TimeoutExpired):
1431
+ return None
1432
+ m = _SEMVER.search(r.stdout or r.stderr or "")
1433
+ return m.group(0) if m else None
1434
+
1435
+
1436
+ def run_probe(name: str, probe: Probe) -> tuple[bool, str]:
1437
+ """``(ok, detail)`` for one free flag-liveness probe.
1438
+
1439
+ The output is matched, never the exit status: claude prints
1440
+ ``Invalid setting source: bogus`` and still exits 0, so a returncode test
1441
+ would pass a dead flag.
1442
+
1443
+ ``offline`` probes run with the network unshared, which is what makes the
1444
+ free suite free *by construction*. Verified: claude and codex both reject
1445
+ these values with no network at all, so they never reach an API.
1446
+ """
1447
+ argv = [name, *probe.args]
1448
+ if probe.offline and backend_error() is None:
1449
+ argv = [*offline_argv(), *argv]
1450
+ try:
1451
+ r = subprocess.run(
1452
+ argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL
1453
+ )
1454
+ except (OSError, subprocess.TimeoutExpired) as exc:
1455
+ return False, f"probe did not run ({exc})"
1456
+ out = (r.stdout or "") + (r.stderr or "")
1457
+ low = out.lower()
1458
+ if probe.expect == "ok":
1459
+ # An accepted flag exits 0. Never sniff the text here: help output
1460
+ # says "error" and "unknown" in passing, which read as a rejection.
1461
+ if r.returncode != 0:
1462
+ return False, f"rejected (exit {r.returncode}): {out.strip()[:120]}"
1463
+ else:
1464
+ # An invalid VALUE is the reverse: claude prints
1465
+ # "Invalid setting source: bogus" and still exits 0, so only the text
1466
+ # can distinguish a rejection from a run.
1467
+ if not any(
1468
+ w in low
1469
+ for w in ("error", "invalid", "unknown", "not recognized", "not found")
1470
+ ):
1471
+ return False, (
1472
+ "accepted a deliberately invalid value -- the key "
1473
+ "is DEAD and the flag is now a silent no-op"
1474
+ )
1475
+ missing = [n for n in probe.needles if n.lower() not in low]
1476
+ if missing:
1477
+ return False, f"output no longer mentions {', '.join(missing)}"
1478
+ problem = probe.verdict(out) if probe.verdict else None
1479
+ if problem:
1480
+ return False, problem
1481
+ return True, ""
1482
+
1483
+
1484
+ class BlindnessError(RuntimeError):
1485
+ """Raised when a seat cannot be confined. Never downgraded to a warning."""
1486
+
1487
+
1488
+ _SEAT_DIR: Path | None = None
1489
+
1490
+
1491
+ def seat_dir() -> Path:
1492
+ """A fresh, empty run dir for a tools-off seat, made once per process.
1493
+
1494
+ The tooled paths (`coldread analyze`, `variant build`) pipe the artifact in
1495
+ on stdin and run with every tool off, so their seat never needs to read
1496
+ anything -- but they used to run with ``cwd=~/tmp``, which is the *shared*
1497
+ scratch every other seat writes into. An empty private dir costs nothing
1498
+ and removes the one thing a tools-off seat could still be pointed at if it
1499
+ ever gained a tool.
1500
+ """
1501
+ global _SEAT_DIR
1502
+ if _SEAT_DIR is None:
1503
+ import atexit
1504
+ import tempfile
1505
+
1506
+ d = Path(tempfile.mkdtemp(dir=ensure_neutral_cwd(), prefix=".seat-"))
1507
+ atexit.register(shutil.rmtree, d, True)
1508
+ # Registered second so it runs first; the home is its own tree, in
1509
+ # the cache, and removing the seat's directory never reached it.
1510
+ atexit.register(discard_state_root, d)
1511
+ _SEAT_DIR = d
1512
+ return _SEAT_DIR
1513
+
1514
+
1515
+ def confine(
1516
+ cmd, run_dir: Path, runners=(), allow=(), bus=None, canary=None
1517
+ ) -> list[str]:
1518
+ """``cmd`` wrapped in the jail, or ``BlindnessError`` if it cannot be.
1519
+
1520
+ Raising is the point. A blind run that quietly proceeds unconfined
1521
+ produces evidence that looks identical to a confined run's and is not,
1522
+ which is the failure this whole layer exists to prevent; the operator opts
1523
+ out explicitly (``blind run --unconfined``) or not at all.
1524
+ """
1525
+ err = backend_error()
1526
+ if err:
1527
+ raise BlindnessError(
1528
+ f"cannot confine a blind seat: {err}. Run it by hand with "
1529
+ f"`quirework sop blind run --unconfined` if an unconfined seat is "
1530
+ f"genuinely acceptable, and say so in the write-up."
1531
+ )
1532
+ return jail_argv(
1533
+ run_dir, allow=allow, runners=runners, bus=bus, canary=canary
1534
+ ) + list(cmd)
1535
+
1536
+
1537
+ def detect_runners(cmd) -> list[str]:
1538
+ """Runner names appearing as a whole token in the command.
1539
+
1540
+ Whole-token equality, not a substring: a prompt is passed as one argument,
1541
+ so a prompt *mentioning* claude is a token like "Read the file..." and
1542
+ never the bare token "claude". Scanning every token (rather than just the
1543
+ first) is what handles `stdbuf -oL -eL agy -p ...` and `timeout 900 codex
1544
+ exec ...`, where the runner is not argv[0].
1545
+ """
1546
+ return [n for n in RUNNERS if any(Path(t).name == n for t in cmd)]
1547
+
1548
+
1549
+ def write_receipt(
1550
+ path: Path,
1551
+ *,
1552
+ confined: bool,
1553
+ cmd,
1554
+ run_dir: Path,
1555
+ runners,
1556
+ binds,
1557
+ reason: str = "",
1558
+ bus: Bus | None = None,
1559
+ canary=None,
1560
+ ) -> None:
1561
+ """Stamp what confined this run, **outside the run dir**.
1562
+
1563
+ Outside on purpose: a receipt the seat can read tells the seat it is a
1564
+ blind seat in an experiment, and names the operator's home layout. The
1565
+ parent of the run dir is not bound into the jail, so a file written there
1566
+ is invisible from inside. ``canary`` is the record, or records, the
1567
+ launch relied on.
1568
+ """
1569
+ path.parent.mkdir(parents=True, exist_ok=True)
1570
+ path.write_text(
1571
+ json.dumps(
1572
+ {
1573
+ "confined": confined,
1574
+ "backend": "bwrap" if confined else "none",
1575
+ "reason": reason,
1576
+ "run_dir": str(run_dir),
1577
+ "runners": list(runners),
1578
+ "blanked": [str(Path.home()) if b == "$HOME" else b for b in BLANKED],
1579
+ **(root_receipt(system_root()) if confined else {}),
1580
+ "session_bus": bus.receipt() if bus else None,
1581
+ "read_only_binds": [str(b) for b in binds],
1582
+ "command": list(cmd),
1583
+ "canary": canary,
1584
+ },
1585
+ indent=2,
1586
+ )
1587
+ + "\n",
1588
+ encoding="utf-8",
1589
+ )
1590
+
1591
+
1592
+ # --------------------------------------------------------------------------- #
1593
+ # CLI
1594
+ # --------------------------------------------------------------------------- #
1595
+
1596
+ USAGE = """\
1597
+ usage: quirework sop blind run [--run-dir DIR] [--runner NAME]... [--allow PATH]...
1598
+ [--receipt PATH] [--unconfined] [--dry-run] -- CMD...
1599
+ quirework sop blind check [--runner NAME]...
1600
+ quirework sop blind canary [--runner NAME]... [--model M]
1601
+ quirework sop blind prune [PATH]... [--grace SECONDS] [--force] [--dry-run]
1602
+
1603
+ Confine a blind seat to its run directory, so the project is not merely
1604
+ out of sight but out of reach. See docs/workflows.md#execution-and-recovery, "The run dir is
1605
+ not a wall".
1606
+
1607
+ run run CMD inside the jail; the exit status is CMD's own. A
1608
+ runner not yet proven blind at its installed version is
1609
+ canaried first (one short exchange on the cheapest model),
1610
+ and a breach refuses the run
1611
+ check FREE self-test: every flag a blind run depends on is still
1612
+ live, each runner starts inside the jail, and the tree, the
1613
+ global briefs and a sibling seat's dir are all unreachable
1614
+ prune remove finished seats' private runner homes, keeping every
1615
+ record. These are the harness installations bound over the
1616
+ real ones; they are large, they live in the cache, which
1617
+ collects one only after 30 idle days, and no receipt depends
1618
+ on one surviving. A home its run still holds, because a
1619
+ later command resumes it, is kept. A tombstone stays where
1620
+ each one was reached. Defaults to this project's run store.
1621
+ canary COSTS TOKENS: one live seat per runner, asserting it really
1622
+ cannot read the tree and really has no web or MCP tool. Every
1623
+ launch already runs one when its runner release is new; this
1624
+ runs it again on demand and records it the same way. agy fails
1625
+ the web check here by design: it cannot be confined to a tool
1626
+ set, so only a launch that audits its transcript may run it.
1627
+
1628
+ options for `prune`:
1629
+ PATH where to look (default: the project's run store)
1630
+ --grace SECONDS keep a home touched more recently than this
1631
+ (default: 3600), where no run holds it
1632
+ --force prune a home its run still holds, and regardless of
1633
+ age; the tombstone records the hold. Still refuses a
1634
+ live seat
1635
+ --dry-run report what would go, and how much, without removing
1636
+
1637
+ options for `run`:
1638
+ --run-dir DIR the seat's directory, bound writable (default: cwd)
1639
+ --runner NAME bind this runner's launcher and credentials back in;
1640
+ repeatable. Auto-detected from CMD when it names one.
1641
+ --allow PATH bind PATH read-only as well; repeatable
1642
+ --receipt PATH where to stamp what confined the run (default: beside
1643
+ the run dir, NOT inside it -- a receipt the seat can
1644
+ read tells the seat it is a blind seat)
1645
+ --unconfined run without a jail. Stamps confined:false and warns;
1646
+ a run that cannot be confined otherwise FAILS rather
1647
+ than quietly degrading.
1648
+ --dry-run print the argv that would run, and exit
1649
+ """
1650
+
1651
+
1652
+ def _split_cmd(argv):
1653
+ """``(our_args, command)`` around the first bare ``--``.
1654
+
1655
+ Empty command when there is no ``--`` -- which is the normal case, because
1656
+ the Typer/Click layer in ``cli.py`` consumes exactly one bare ``--`` before
1657
+ ``ctx.args`` reaches here. The caller falls back to ``parse_known_args``,
1658
+ so ``quirework sop blind run --run-dir X -- claude -p ...`` and the same line
1659
+ without the separator both work, and the SOPs can keep writing the ``--``
1660
+ that reads correctly to a human.
1661
+ """
1662
+ if "--" not in argv:
1663
+ return argv, []
1664
+ i = argv.index("--")
1665
+ return argv[:i], argv[i + 1 :]
1666
+
1667
+
1668
+ def cmd_run(argv) -> int:
1669
+ import argparse
1670
+
1671
+ ours, cmd = _split_cmd(argv)
1672
+ p = argparse.ArgumentParser(prog="quirework sop blind run", add_help=False)
1673
+ p.add_argument("--run-dir", default=None)
1674
+ p.add_argument("--runner", action="append", default=[])
1675
+ p.add_argument("--allow", action="append", default=[])
1676
+ p.add_argument("--receipt", default=None)
1677
+ p.add_argument("--unconfined", action="store_true")
1678
+ p.add_argument("--dry-run", action="store_true")
1679
+ a, cmd = (p.parse_args(ours), cmd) if cmd else p.parse_known_args(argv)
1680
+ if not cmd:
1681
+ print(USAGE, file=sys.stderr)
1682
+ print("blind run: no command (put it after `--`)", file=sys.stderr)
1683
+ return 2
1684
+
1685
+ run_dir = Path(a.run_dir).expanduser().resolve() if a.run_dir else Path.cwd()
1686
+ if not run_dir.is_dir():
1687
+ print(f"blind run: run dir does not exist: {run_dir}", file=sys.stderr)
1688
+ return 2
1689
+ runners = list(dict.fromkeys(a.runner + detect_runners(cmd)))
1690
+ unknown = [r for r in runners if r not in RUNNERS]
1691
+ if unknown:
1692
+ print(
1693
+ f"blind run: unknown runner(s): {', '.join(unknown)}; "
1694
+ f"known: {', '.join(RUNNERS)}",
1695
+ file=sys.stderr,
1696
+ )
1697
+ return 2
1698
+ receipt = (
1699
+ Path(a.receipt).expanduser()
1700
+ if a.receipt
1701
+ else run_dir.parent / f"{run_dir.name}.blind-receipt.json"
1702
+ )
1703
+
1704
+ err = backend_error()
1705
+ if err and not a.unconfined:
1706
+ # Never degrade in silence: an unconfined seat and a confined one are
1707
+ # not the same evidence, so the operator has to say so out loud.
1708
+ print(
1709
+ f"blind run: cannot confine -- {err}\n"
1710
+ f" Re-run with --unconfined to accept an unconfined seat; the "
1711
+ f"receipt will say so and the\n write-up must too.",
1712
+ file=sys.stderr,
1713
+ )
1714
+ return 3
1715
+
1716
+ if a.unconfined:
1717
+ print(
1718
+ "blind run: WARNING -- running UNCONFINED; the seat can reach "
1719
+ "the project tree.",
1720
+ file=sys.stderr,
1721
+ )
1722
+ if not a.dry_run:
1723
+ write_receipt(
1724
+ receipt,
1725
+ confined=False,
1726
+ cmd=cmd,
1727
+ run_dir=run_dir,
1728
+ runners=runners,
1729
+ binds=[],
1730
+ reason=err or "",
1731
+ )
1732
+ if a.dry_run:
1733
+ print(" ".join(cmd))
1734
+ return 0
1735
+ return subprocess.run(list(cmd)).returncode
1736
+
1737
+ binds, missing = runner_binds(runners)
1738
+ for m in missing:
1739
+ print(f"blind run: warning: {m}", file=sys.stderr)
1740
+ # A command written by hand cannot be rebuilt with the canary's inputs,
1741
+ # so each runner in it is proven under the shared blind flag set instead.
1742
+ proven = PROBE
1743
+ if not a.dry_run:
1744
+ from quirework.providers import canary
1745
+
1746
+ try:
1747
+ proven = [canary.require("blind run", r, generic_twin(r)) for r in runners]
1748
+ except canary.CanaryError as exc:
1749
+ print(f"blind run: {exc}", file=sys.stderr)
1750
+ return 3
1751
+ with session_bus(runners) as bus:
1752
+ argv_full = jail_argv(
1753
+ run_dir, allow=a.allow, runners=runners, bus=bus, canary=proven
1754
+ )
1755
+ argv_full += list(cmd)
1756
+ if a.dry_run:
1757
+ print(" ".join(argv_full))
1758
+ return 0
1759
+ write_receipt(
1760
+ receipt,
1761
+ confined=True,
1762
+ cmd=cmd,
1763
+ run_dir=run_dir,
1764
+ runners=runners,
1765
+ binds=binds,
1766
+ bus=bus,
1767
+ canary=proven,
1768
+ )
1769
+ proc = popen(argv_full, bus, cwd=run_dir)
1770
+ try:
1771
+ return proc.wait()
1772
+ except BaseException:
1773
+ # What `subprocess.run` does, so an interrupted run leaves no
1774
+ # jail behind and the bus can be reaped on the way out.
1775
+ proc.kill()
1776
+ proc.wait()
1777
+ raise
1778
+
1779
+
1780
+ def cmd_check(argv) -> int:
1781
+ import argparse
1782
+
1783
+ from quirework.project import current_project
1784
+
1785
+ p = argparse.ArgumentParser(prog="quirework sop blind check", add_help=False)
1786
+ p.add_argument("--runner", action="append", default=[])
1787
+ a = p.parse_args(argv)
1788
+ names = a.runner or list(RUNNERS)
1789
+
1790
+ err = backend_error()
1791
+ if err:
1792
+ print(f"✗ backend: {err}")
1793
+ return 1
1794
+ ver = subprocess.run(
1795
+ [BWRAP, "--version"], capture_output=True, text=True
1796
+ ).stdout.strip()
1797
+ print(f"✓ backend: {ver}")
1798
+ try:
1799
+ trees = bound_trees(system_root())
1800
+ except BlindnessError as exc:
1801
+ print(f"✗ jail root: {exc}")
1802
+ return 1
1803
+ print(
1804
+ " root of every jail: "
1805
+ + ", ".join(str(t) for t in trees)
1806
+ + " read-only; nothing else of the host exists inside"
1807
+ )
1808
+ proxy = shutil.which(BUS_PROXY)
1809
+ print(
1810
+ f" {BUS_PROXY}: {proxy}"
1811
+ if proxy
1812
+ else f" {BUS_PROXY} missing: an agy seat cannot be confined "
1813
+ "(Fedora: dnf install xdg-dbus-proxy)"
1814
+ )
1815
+
1816
+ root = ensure_neutral_cwd() / ".quirework-blind-check"
1817
+ seat, sibling = root / "seat", root / "other-seat"
1818
+ seat.mkdir(parents=True, exist_ok=True)
1819
+ sibling.mkdir(parents=True, exist_ok=True)
1820
+ (sibling / "reaction-ch01.md").write_text("another seat's output\n")
1821
+
1822
+ from quirework.providers import canary
1823
+
1824
+ bad = 0
1825
+ # 0. The free flag-liveness contract. This is the half that catches a
1826
+ # runner CHANGING under us rather than a jail that is misbuilt, and it
1827
+ # is the dangerous half: a dead config key does not fail, it silently
1828
+ # stops blinding. Which releases have been canaried is reported, never
1829
+ # failed: a launch canaries its own release before it starts.
1830
+ for name in names:
1831
+ if shutil.which(name) is None:
1832
+ continue
1833
+ for probe in RUNNERS[name].probes:
1834
+ ok, detail = run_probe(name, probe)
1835
+ if ok:
1836
+ print(f"✓ {name}: {probe.label} is live")
1837
+ else:
1838
+ print(f"✗ {name}: {probe.label} -- {detail}\n ({probe.why})")
1839
+ bad += 1
1840
+ have = runner_version(name)
1841
+ held = canary.proven(name, have)
1842
+ print(
1843
+ f" {name} {have}: {len(held)} launch flag set(s) canaried at this release"
1844
+ if held
1845
+ else f" {name} {have}: not canaried at this release yet; the next "
1846
+ "launch runs one first"
1847
+ )
1848
+
1849
+ # 1. Each runner actually starts inside the jail. This is what keeps the
1850
+ # credential/launcher table from rotting silently on a version bump.
1851
+ for name in names:
1852
+ binds, missing = runner_binds([name])
1853
+ for m in missing:
1854
+ print(f"✗ {name}: {m}")
1855
+ bad += 1
1856
+ try:
1857
+ # The bus comes up exactly as it would for a seat, so a missing
1858
+ # proxy or a dead session bus fails here rather than mid-run.
1859
+ with session_bus([name]) as bus:
1860
+ argv_full = jail_argv(seat, runners=[name], bus=bus, canary=PROBE)
1861
+ argv_full += [name, *RUNNERS[name].probe]
1862
+ r = subprocess.run(
1863
+ argv_full,
1864
+ capture_output=True,
1865
+ text=True,
1866
+ timeout=120,
1867
+ pass_fds=bus.pass_fds if bus else (),
1868
+ )
1869
+ except (subprocess.TimeoutExpired, OSError, BlindnessError) as exc:
1870
+ print(f"✗ {name}: probe failed to run ({exc})")
1871
+ bad += 1
1872
+ continue
1873
+ if r.returncode == 0:
1874
+ print(
1875
+ f"✓ {name}: starts inside the jail "
1876
+ f"({r.stdout.strip().splitlines()[0][:48] if r.stdout.strip() else 'ok'})"
1877
+ )
1878
+ else:
1879
+ print(
1880
+ f"✗ {name}: probe exited {r.returncode}: "
1881
+ f"{(r.stderr or r.stdout).strip()[:160]}"
1882
+ )
1883
+ bad += 1
1884
+
1885
+ # 2. Containment itself, runner-independent: what must NOT be reachable.
1886
+ unreachable = {
1887
+ "the project tree": current_project().root,
1888
+ "the global CLAUDE.md": Path.home() / ".claude/CLAUDE.md",
1889
+ "the global GEMINI.md": Path.home() / ".gemini/GEMINI.md",
1890
+ "a sibling seat's run dir": sibling,
1891
+ # The session bus is a door out of any sandbox: `systemd --user`
1892
+ # starts processes on request outside the jail.
1893
+ "the runtime directory and its session bus": Path(f"/run/user/{os.getuid()}"),
1894
+ }
1895
+ for label, path in unreachable.items():
1896
+ r = subprocess.run(
1897
+ jail_argv(seat) + ["test", "-e", str(path)], capture_output=True
1898
+ )
1899
+ if r.returncode == 0:
1900
+ print(f"✗ reachable from inside the jail: {label} ({path})")
1901
+ bad += 1
1902
+ else:
1903
+ print(f"✓ unreachable: {label}")
1904
+ # 3. …and what MUST be, or the seat cannot read its own materials.
1905
+ (seat / "materials.md").write_text("ok\n")
1906
+ r = subprocess.run(
1907
+ jail_argv(seat) + ["test", "-e", str(seat / "materials.md")],
1908
+ capture_output=True,
1909
+ )
1910
+ print(
1911
+ (
1912
+ "✓ readable: the seat's own run dir"
1913
+ if r.returncode == 0
1914
+ else "✗ the seat cannot read its own run dir"
1915
+ )
1916
+ )
1917
+ bad += r.returncode != 0
1918
+
1919
+ print(
1920
+ "✓ blind check: confinement clean."
1921
+ if not bad
1922
+ else f"✗ blind check: {bad} problem(s)."
1923
+ )
1924
+ return 1 if bad else 0
1925
+
1926
+
1927
+ # One live seat per runner. Costs tokens, so it is opt-in and cheap-by-default;
1928
+ # everything here is a fact no free probe can reach, because it is a property
1929
+ # of the running seat rather than of the CLI's argument parser.
1930
+ CANARY_PROMPT = (
1931
+ "Reply with EXACTLY four lines and nothing else.\n"
1932
+ "READ=<OK if you can read the file {target}, FAIL if you cannot>\n"
1933
+ "TOOLS=<comma-separated names of every tool you have, or NONE>\n"
1934
+ "PERSONA=<NONE if you have no custom personality/tone/style instruction, "
1935
+ "else quote it>\n"
1936
+ "CONTEXT=<NONE if you have no project or repository instructions in your "
1937
+ "context (an AGENTS.md, CLAUDE.md, GEMINI.md, or developer guidance about "
1938
+ "some particular person's project), else quote the first 15 words>\n"
1939
+ "Actually attempt the read before answering."
1940
+ )
1941
+
1942
+ # CONTEXT is the assertion the whole apparatus exists for, and it is the one a
1943
+ # free probe can never make: whether the seat is holding the project's own
1944
+ # guidance. codex reads AGENTS.md from the working directory upward, claude
1945
+ # reads CLAUDE.md, agy reads GEMINI.md, and none of that is visible in any
1946
+ # flag -- only in what the running model turns out to know.
1947
+ #
1948
+ # The markers themselves are project data (providers.isolation_policy), because
1949
+ # the words that prove a leak are the book's own and the tool cannot know them.
1950
+ # Two rules govern what belongs on that list, and both were learned by getting
1951
+ # them wrong. A word the seat echoes back from the path it was asked to read
1952
+ # cannot be a marker: it fires on a perfectly blind seat. Neither can a phrase
1953
+ # that is ordinary English, such as a book title made of common words, for the
1954
+ # same reason. A marker that a blind seat could produce innocently reports a breach that never
1955
+ # happened, which is the one failure this list cannot afford.
1956
+
1957
+
1958
+ # What a canary's answer may and may not show is judged in `canary.judge`,
1959
+ # which owns the forbidden tool names and the personality wording.
1960
+
1961
+ # The canary spends real money, so it spends the least available. claude's
1962
+ # haiku and codex's mini are the cheapest tiers each offers and are named
1963
+ # outright; agy's are RESOLVED at run time because its model list churns (it
1964
+ # moved 3.6 -> 3.7 mid-week), and because leaving `--model` off does not mean
1965
+ # "cheap" -- it means the ACCOUNT DEFAULT, which is whatever the operator last
1966
+ # selected and was a Pro tier the first time this ran.
1967
+
1968
+
1969
+ def cheapest_model(name: str) -> str | None:
1970
+ """The least expensive model this runner offers, or None to let it choose.
1971
+
1972
+ For agy: the lowest effort tier of a flash model, picked out of
1973
+ ``agy models`` rather than pinned, so a rename cannot silently promote the
1974
+ canary to a Pro seat.
1975
+
1976
+ A configured name resolves the way a seat's ``--model`` does: a family
1977
+ to its newest member at every launch, so the next release of the cheap
1978
+ model is canaried as itself without an edit, and an exact slug to
1979
+ itself. A name the runner's catalog cannot resolve raises
1980
+ ``ValueError``, which the canary turns into a refusal naming the
1981
+ setting.
1982
+ """
1983
+ configured = current_project().get("providers.canary_models", {})
1984
+ if name in configured:
1985
+ from quirework.providers import canary, models
1986
+
1987
+ return models.resolve(
1988
+ name,
1989
+ configured[name],
1990
+ canary.EFFORT,
1991
+ declared=current_project().get("providers.claude.models", []),
1992
+ )["model"]
1993
+ if name != "agy":
1994
+ return None
1995
+ try:
1996
+ out = subprocess.run(
1997
+ ["agy", "models"], capture_output=True, text=True, timeout=120
1998
+ ).stdout
1999
+ except (OSError, subprocess.TimeoutExpired):
2000
+ return None
2001
+ slugs = [
2002
+ ln.split()[0]
2003
+ for ln in out.splitlines()
2004
+ if ln.strip() and not ln.startswith(("Fetching", " "))
2005
+ ]
2006
+ for want in (lambda s: "flash" in s and s.endswith("-low"), lambda s: "flash" in s):
2007
+ hit = [s for s in slugs if want(s)]
2008
+ if hit:
2009
+ return hit[0]
2010
+ # `agy models` needs the network and was seen to fail transiently. Falling
2011
+ # through to None would mean "no --model", which is NOT "cheap" -- it is
2012
+ # the account default, a Pro tier. So fall back to the model we last
2013
+ # actually validated, and if there is none, return None so the caller can
2014
+ # REFUSE rather than quietly buy the expensive seat.
2015
+ from quirework.providers import canary
2016
+
2017
+ return canary.last_model(name)
2018
+
2019
+
2020
+ # The shared blind flag set. A command Quirework builds is canaried under its
2021
+ # own flags, rebuilt by its own builder; a command written by hand for
2022
+ # `blind run` cannot be rebuilt, so its runners are proven under these.
2023
+ CANARY_ARGV = {
2024
+ "claude": lambda p, m: [
2025
+ "claude",
2026
+ "-p",
2027
+ p,
2028
+ "--model",
2029
+ m,
2030
+ *MIN_TOOLS,
2031
+ *BLIND_SETTINGS,
2032
+ *NO_SETTING_SOURCES,
2033
+ *NO_MCP,
2034
+ ],
2035
+ "codex": lambda p, m: [
2036
+ "codex",
2037
+ "exec",
2038
+ "--ignore-user-config",
2039
+ "--ignore-rules",
2040
+ "--skip-git-repo-check",
2041
+ "-c",
2042
+ 'web_search="disabled"',
2043
+ "-c",
2044
+ 'personality="none"',
2045
+ *CODEX_SEAT,
2046
+ "-s",
2047
+ "read-only",
2048
+ "-m",
2049
+ m,
2050
+ p,
2051
+ ],
2052
+ "agy": lambda p, m: ["agy", "-p", p, "--model", m],
2053
+ }
2054
+
2055
+
2056
+ def generic_twin(name: str):
2057
+ """The canary builder for one runner's shared blind flag set."""
2058
+ from quirework.providers.canary import Twin
2059
+
2060
+ def build(prompt, model, workdir, session):
2061
+ return Twin(CANARY_ARGV[name](prompt, model))
2062
+
2063
+ return build
2064
+
2065
+
2066
+ def canary_once(name: str, model: str | None = None, timeout: int = 600) -> dict:
2067
+ """Run ONE live blind seat under the shared flag set, and judge it.
2068
+
2069
+ Records nothing: the live tests watch the runners through it without
2070
+ touching the records launches rely on. A launch, and `blind canary`, go
2071
+ through `canary.require`, which judges with the same code. Returns
2072
+ ``canary.run``'s result.
2073
+ """
2074
+ from quirework.providers import canary
2075
+
2076
+ why = (
2077
+ "could not resolve a cheapest model; refusing to run on the "
2078
+ "account default (pass --model to override)"
2079
+ )
2080
+ try:
2081
+ model = model or cheapest_model(name)
2082
+ except ValueError as exc:
2083
+ model, why = None, f"providers.canary_models does not resolve: {exc}"
2084
+ if model is None:
2085
+ # Refuse rather than run on whatever the account defaults to: "I could
2086
+ # not work out the cheap model" must never mean "use the expensive one".
2087
+ return {
2088
+ "ok": False,
2089
+ "problems": [why],
2090
+ "inconclusive": True,
2091
+ "fields": {},
2092
+ "model": None,
2093
+ "version": runner_version(name),
2094
+ "raw": "",
2095
+ "kept": None,
2096
+ }
2097
+ return canary.run(name, generic_twin(name), model, canary.BLIND, timeout=timeout)
2098
+
2099
+
2100
+ def cmd_canary(argv) -> int:
2101
+ import argparse
2102
+
2103
+ from quirework.providers import canary
2104
+
2105
+ p = argparse.ArgumentParser(prog="quirework sop blind canary", add_help=False)
2106
+ p.add_argument("--runner", action="append", default=[])
2107
+ p.add_argument("--model", default=None)
2108
+ a = p.parse_args(argv)
2109
+ names = a.runner or list(RUNNERS)
2110
+ err = backend_error()
2111
+ if err:
2112
+ print(f"\u2717 backend: {err}")
2113
+ return 1
2114
+
2115
+ bad = 0
2116
+ for name in names:
2117
+ if shutil.which(name) is None:
2118
+ print(f" {name}: not installed -- skipped")
2119
+ continue
2120
+ try:
2121
+ entry = canary.require(
2122
+ "blind run", name, generic_twin(name), model=a.model, fresh=True
2123
+ )
2124
+ except canary.CanaryError as exc:
2125
+ print(f"\u2717 {name}: {exc}")
2126
+ bad += 1
2127
+ continue
2128
+ print(
2129
+ f"\u2713 {name} {entry['version']} [{entry['model']}]: "
2130
+ f"blind (no project context, no tree, no web, no MCP)"
2131
+ )
2132
+ print(
2133
+ "\u2713 blind canary: clean."
2134
+ if not bad
2135
+ else f"\u2717 blind canary: {bad} runner(s) failed."
2136
+ )
2137
+ return 1 if bad else 0
2138
+
2139
+
2140
+ def cmd_prune(argv) -> int:
2141
+ grace, force, dry, roots = 3600.0, False, False, []
2142
+ rest = list(argv)
2143
+ while rest:
2144
+ item = rest.pop(0)
2145
+ if item == "--grace":
2146
+ grace = float(rest.pop(0))
2147
+ elif item == "--force":
2148
+ force = True
2149
+ elif item == "--dry-run":
2150
+ dry = True
2151
+ elif item.startswith("-"):
2152
+ print(f"blind prune: unknown option {item!r}", file=sys.stderr)
2153
+ return 2
2154
+ else:
2155
+ roots.append(item)
2156
+ if not roots:
2157
+ roots = [current_project().state_root]
2158
+ if not Path(roots[0]).exists() and len(roots) == 1:
2159
+ print(f"nothing to prune: {roots[0]} does not exist")
2160
+ return 0
2161
+ report = prune(roots, grace=grace, force=force, dry_run=dry)
2162
+ for home, size, why in report["kept"]:
2163
+ print(f"keep {size / 1e6:8.1f} MB {home} ({why})")
2164
+ for home, size, why in report["removed"]:
2165
+ print(f"{why:<7} {size / 1e6:8.1f} MB {home}")
2166
+ total = report["freed"] / 1e6
2167
+ verb = "would free" if dry else "freed"
2168
+ print(f"{verb} {total:.1f} MB across {len(report['removed'])} private homes")
2169
+ return 0
2170
+
2171
+
2172
+ def main(argv=None) -> int:
2173
+ argv = list(sys.argv[1:] if argv is None else argv)
2174
+ if not argv or argv[0] in ("-h", "--help", "help"):
2175
+ print(USAGE)
2176
+ return 0
2177
+ sub, rest = argv[0], argv[1:]
2178
+ if sub == "run":
2179
+ return cmd_run(rest)
2180
+ if sub == "check":
2181
+ return cmd_check(rest)
2182
+ if sub == "canary":
2183
+ return cmd_canary(rest)
2184
+ if sub == "prune":
2185
+ return cmd_prune(rest)
2186
+ print(USAGE, file=sys.stderr)
2187
+ print(f"blind: unknown subcommand {sub!r}", file=sys.stderr)
2188
+ return 2
2189
+
2190
+
2191
+ if __name__ == "__main__":
2192
+ raise SystemExit(main())