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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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())
|