@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.29
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.
- package/CHANGELOG.md +91 -0
- package/README.md +50 -22
- package/bin/redue.mjs +24 -12
- package/docs/user/agents.md +14 -7
- package/docs/user/getting-started.md +4 -3
- package/docs/user/json.md +2 -2
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +119 -42
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +10 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +49 -29
- package/src/cli.mjs +48 -17
- package/src/daemon.mjs +10 -31
- package/src/landlock.py +319 -20
- package/src/node-onboarding.mjs +3 -1
- package/src/presentation.mjs +4 -4
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +168 -22
- package/src/setup.mjs +26 -4
package/docs/user/sandbox.md
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
# Sandboxed recipe (typecheck and
|
|
1
|
+
# Sandboxed recipe (typecheck, lint and tests, any language)
|
|
2
2
|
|
|
3
|
-
The sandboxed recipe runs a typecheck or
|
|
3
|
+
The sandboxed recipe runs a typecheck, lint or test command inside an operating
|
|
4
4
|
system sandbox and reports later whether that result still applies to the
|
|
5
5
|
files you have. The sandbox holds for every process the command starts, so it
|
|
6
6
|
covers programs REDUE cannot record: TypeScript 7's native compiler, esbuild,
|
|
7
7
|
oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
|
|
8
|
-
and ruff.
|
|
8
|
+
and ruff, and test runners such as pytest.
|
|
9
9
|
|
|
10
10
|
**The claim.** A sandboxed result holds for a run that sees only the project's
|
|
11
|
-
files that git doesn't ignore, its
|
|
12
|
-
way CI does. It does not say that a run with
|
|
13
|
-
service on your machine or the network would
|
|
11
|
+
files that git doesn't ignore, except its documentation, its installed
|
|
12
|
+
packages and the toolchain, the way CI does. It does not say that a run with
|
|
13
|
+
your own configuration files, a service on your machine or the network would
|
|
14
|
+
give the same result.
|
|
14
15
|
|
|
15
|
-
A check that
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
A check that reads the documentation (a doctest over `README.md`) sees it
|
|
17
|
+
too. A check that needs files git ignores (generated code, another package's
|
|
18
|
+
built declarations) gets the whole checkout. See
|
|
19
|
+
[hidden files](#files-git-ignores-and-documentation).
|
|
19
20
|
|
|
20
21
|
## Where it applies
|
|
21
22
|
|
|
23
|
+
- **Tests:** a root `test` script the [direct test recipe](tests-direct.md)
|
|
24
|
+
does not take, and pytest in the project's `.venv`. See [tests](#tests).
|
|
22
25
|
- **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
|
|
23
26
|
with the `node-modules` linker). `init` tries the
|
|
24
27
|
[direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
|
|
@@ -49,14 +52,15 @@ redue status --sync
|
|
|
49
52
|
A Python check in the config:
|
|
50
53
|
|
|
51
54
|
```json
|
|
52
|
-
{"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-
|
|
55
|
+
{"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v3",
|
|
53
56
|
"command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
A Node check names its script, as the general recipe's do:
|
|
57
|
-
`{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-
|
|
58
|
-
`sandbox-
|
|
59
|
-
the whole
|
|
60
|
+
`{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v3", ...}`.
|
|
61
|
+
`sandbox-v3` hides the files git ignores and the documentation from the run,
|
|
62
|
+
`sandbox-v2` only the files git ignores; `sandbox-v1` runs it on the whole
|
|
63
|
+
checkout.
|
|
60
64
|
|
|
61
65
|
With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
|
|
62
66
|
`python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
|
|
@@ -64,12 +68,31 @@ that check. The last two count only when PATH finds the tool or Python in the
|
|
|
64
68
|
project's `.venv/bin` first. When the hook runs the check through REDUE, it
|
|
65
69
|
keeps `uv run` or `poetry run` in front, so the environment still syncs first.
|
|
66
70
|
|
|
67
|
-
## Files git ignores
|
|
68
|
-
|
|
69
|
-
`init` runs each check
|
|
70
|
-
git ignores
|
|
71
|
-
checkout (reusing the plain run). The first
|
|
72
|
-
result is written: `sandbox-
|
|
71
|
+
## Files git ignores and documentation
|
|
72
|
+
|
|
73
|
+
`init` runs each check four ways at most: plainly, then sandboxed with the
|
|
74
|
+
files git ignores and the documentation hidden, with only the files git
|
|
75
|
+
ignores hidden, and with the whole checkout (reusing the plain run). The first
|
|
76
|
+
sandboxed way that gives the same result is written: `sandbox-v3`,
|
|
77
|
+
`sandbox-v2` or `sandbox-v1`.
|
|
78
|
+
|
|
79
|
+
With `sandbox-v3`, each run also hides the documentation: the files git
|
|
80
|
+
lists (and does not ignore) named `*.md`, `*.mdx`, `*.rst`, `LICENSE*` or
|
|
81
|
+
`CHANGELOG*` anywhere, and text, image and PDF files (`.txt`, `.adoc`,
|
|
82
|
+
`.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.pdf`) in the root `docs/`
|
|
83
|
+
folder. Code in `docs/` (examples a typecheck includes) is not hidden, and
|
|
84
|
+
folders stay listable. The receipt names the files the run hid. A later edit
|
|
85
|
+
to one of them, or its deletion, leaves the result CURRENT: editing the
|
|
86
|
+
README does not make the tests STALE. A new documentation file, a link named
|
|
87
|
+
like one, or a change made while the run ran still counts. On macOS the
|
|
88
|
+
files with those extensions are refused by name pattern, in any case and in
|
|
89
|
+
`node_modules` too: `README.MD` or `Docs/logo.svg` is refused but not
|
|
90
|
+
listed, so an edit to it counts, and a check that reads one gets
|
|
91
|
+
`sandbox-v2` on macOS. `LICENSE*` and `CHANGELOG*` files are refused by the
|
|
92
|
+
exact paths git lists, so `license-check.ts` stays readable. On Linux each
|
|
93
|
+
listed file is hidden like a git-ignored one. In a checkout with more than
|
|
94
|
+
100 `LICENSE*` and `CHANGELOG*` files (vendored packages), those files are
|
|
95
|
+
not hidden on either platform and count as inputs.
|
|
73
96
|
|
|
74
97
|
With `sandbox-v2`, each run hides what `git ls-files --others --ignored
|
|
75
98
|
--exclude-standard --directory` lists, except `node_modules`, `.venv` and
|
|
@@ -78,25 +101,27 @@ Only the project's rules count: its `.gitignore` files and
|
|
|
78
101
|
`.git/info/exclude`, not your global excludes file. Those files are not
|
|
79
102
|
inputs, so a build or test run that writes them leaves the result CURRENT.
|
|
80
103
|
|
|
81
|
-
When a `sandbox-v2` run fails with a refusal that names a
|
|
82
|
-
missing file counts only when it names one, since Linux tools
|
|
83
|
-
unreadable file as missing), REDUE runs the check again in the
|
|
84
|
-
without hiding anything. That result is kept and compared on every
|
|
85
|
-
git-ignored ones included. Run `redue init` again to
|
|
86
|
-
check that keeps needing them.
|
|
104
|
+
When a `sandbox-v2` or `sandbox-v3` run fails with a refusal that names a
|
|
105
|
+
hidden file (a missing file counts only when it names one, since Linux tools
|
|
106
|
+
report an unreadable file as missing), REDUE runs the check again in the
|
|
107
|
+
sandbox without hiding anything. That result is kept and compared on every
|
|
108
|
+
file, git-ignored ones and documentation included. Run `redue init` again to
|
|
109
|
+
write another recipe for a check that keeps needing them.
|
|
87
110
|
|
|
88
111
|
## What the run sees
|
|
89
112
|
|
|
90
113
|
It may read and execute only beneath:
|
|
91
114
|
|
|
92
|
-
- the checkout, without the files git ignores for `sandbox-v2
|
|
115
|
+
- the checkout, without the files git ignores for `sandbox-v2`, and without
|
|
116
|
+
its documentation too for `sandbox-v3`;
|
|
93
117
|
- the toolchain: Node's installation and the package managers' (with
|
|
94
118
|
Corepack's downloads), or the Python installation the `.venv` was made from;
|
|
95
119
|
- system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
|
|
96
120
|
`/System`, `/Library`, `/opt/homebrew` and the like on macOS);
|
|
97
121
|
- a private home directory and a fresh temporary directory, in REDUE's state.
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
A typecheck or lint has no network: no TCP connection, loopback included
|
|
124
|
+
(a test run on Linux gets loopback; see [tests](#tests)). On macOS its only
|
|
100
125
|
Unix sockets are in its own temporary directory, which is new for each run.
|
|
101
126
|
On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
|
|
102
127
|
run.
|
|
@@ -124,11 +149,56 @@ It gets a controlled environment, not yours:
|
|
|
124
149
|
- `HOME`, `TMPDIR` and the XDG directories in its private directories;
|
|
125
150
|
`VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
|
|
126
151
|
|
|
152
|
+
## Tests
|
|
153
|
+
|
|
154
|
+
A test run gets what a typecheck or lint gets, and also:
|
|
155
|
+
|
|
156
|
+
- **On Linux, loopback, and no remote host.** It may listen on any address
|
|
157
|
+
and accept connections, and connect only to the local host (`127.0.0.0/8`,
|
|
158
|
+
`::1`, `0.0.0.0`, `::`) and Unix sockets.
|
|
159
|
+
- Landlock cannot tell a loopback address from a remote one, so a
|
|
160
|
+
seccomp filter in the test's processes does instead. It refuses sockets
|
|
161
|
+
other than Unix, netlink and TCP when they are created (UDP included),
|
|
162
|
+
TCP Fast Open (`MSG_FASTOPEN`) and `io_uring`. It hands each `connect` to
|
|
163
|
+
REDUE's launcher. The launcher allows a local port no process listens on
|
|
164
|
+
(the test gets "connection refused") or one the run's own processes
|
|
165
|
+
listen on, and a Unix socket file in the checkout, the run's home or its
|
|
166
|
+
temporary directory. It refuses a service started outside the run (your
|
|
167
|
+
local database) and every remote host. Nothing else goes through the
|
|
168
|
+
launcher, so worker processes talk to each other as usual. If the
|
|
169
|
+
launcher's supervisor cannot start, or stops before the run ends, the
|
|
170
|
+
result is UNVERIFIED, never CURRENT.
|
|
171
|
+
- **On macOS, no network.** No TCP or UDP, loopback included: only Unix
|
|
172
|
+
sockets in its own temporary directory (its home is beneath it), as a
|
|
173
|
+
typecheck or lint gets. Seatbelt cannot refuse a service started outside
|
|
174
|
+
the run while allowing the run's own servers: a rule that refuses one
|
|
175
|
+
loopback port under a loopback allow is not applied, and an allow list of
|
|
176
|
+
every other port does not compile. A suite that starts a server on
|
|
177
|
+
loopback, or reads a local database, fails init's trial: init leaves the
|
|
178
|
+
test check out, and the tests run without REDUE, as before. A test check
|
|
179
|
+
set up by alpha.28 on macOS is not CURRENT after the upgrade; if its runs
|
|
180
|
+
now fail inside the sandbox, run `redue init` again.
|
|
181
|
+
- **A fresh home.** Its home directory is new and empty for each run, as on
|
|
182
|
+
CI: what a test leaves there is not observed, so it must not reach a later
|
|
183
|
+
run.
|
|
184
|
+
- **A private `/tmp`.** Its `TMPDIR` is its own, new for each run, and it may
|
|
185
|
+
not write in `/tmp` or `/var/tmp` outside it. A test that writes to a fixed
|
|
186
|
+
path there is refused.
|
|
187
|
+
|
|
188
|
+
`init` compares a test run's exit status and the counts its summary prints
|
|
189
|
+
(passed, failed, skipped), not every line: test runners print in the order
|
|
190
|
+
their workers finish. Git-ignored files are hidden from a test run when the
|
|
191
|
+
trial agrees, as for typecheck and lint. A suite that reads back git-ignored
|
|
192
|
+
files it writes in the same run (a coverage database, a build it starts
|
|
193
|
+
itself) gets the whole checkout. Files a hidden run writes beneath hidden
|
|
194
|
+
folders, or new files git ignores, are not changes to its inputs.
|
|
195
|
+
|
|
127
196
|
## What REDUE claims
|
|
128
197
|
|
|
129
198
|
CURRENT means the last sandboxed run's inputs are unchanged:
|
|
130
199
|
|
|
131
|
-
- every file in the checkout git doesn't ignore
|
|
200
|
+
- every file in the checkout git doesn't ignore but its documentation
|
|
201
|
+
(`sandbox-v3`), every file git doesn't ignore (`sandbox-v2`), or every file
|
|
132
202
|
in it, git-ignored files and git-ignored links to files in it included
|
|
133
203
|
(`sandbox-v1`), except `.git` and runner caches (the general recipe's, plus
|
|
134
204
|
`__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`);
|
|
@@ -154,9 +224,9 @@ every failure twice to find out:
|
|
|
154
224
|
timings, the temporary and home directories, and lines about caches.
|
|
155
225
|
Otherwise `init` writes what it would have without this recipe and says why.
|
|
156
226
|
These are the only runs `init` makes.
|
|
157
|
-
- When a `sandbox-v2` run fails with a refusal that names a hidden file, it
|
|
227
|
+
- When a `sandbox-v2` or `sandbox-v3` run fails with a refusal that names a hidden file, it
|
|
158
228
|
runs again in the sandbox without hiding (see
|
|
159
|
-
[files git ignores](#files-git-ignores)).
|
|
229
|
+
[files git ignores](#files-git-ignores-and-documentation)).
|
|
160
230
|
- When a sandboxed run fails and its output has a line that looks like a
|
|
161
231
|
refusal ("Operation not permitted", "Permission denied", "Read-only file
|
|
162
232
|
system", a name lookup or a connection that failed), REDUE runs the check
|
|
@@ -166,7 +236,7 @@ every failure twice to find out:
|
|
|
166
236
|
|
|
167
237
|
## The private home
|
|
168
238
|
|
|
169
|
-
|
|
239
|
+
A typecheck or lint's home directory is kept between runs, per check, so caches tools
|
|
170
240
|
build there (font caches, a type checker's cache) are not rebuilt every run.
|
|
171
241
|
It starts empty: your configuration files are not in it. What a run leaves
|
|
172
242
|
there can change a later run, and REDUE trusts it like a runner cache. Remove
|
|
@@ -200,17 +270,24 @@ it with `redue remove-state`.
|
|
|
200
270
|
nested example project's).
|
|
201
271
|
- A script that changes files (`--fix`, `--write`) does not get this recipe:
|
|
202
272
|
`init` would run it twice.
|
|
203
|
-
- With `sandbox-v2`, a run cannot read a hidden file, but it can still see
|
|
204
|
-
that one exists
|
|
205
|
-
|
|
206
|
-
also list a hidden folder and see its files' sizes and times: Landlock
|
|
207
|
-
restricts neither beneath a folder the run may list. A tool that acts on
|
|
273
|
+
- With `sandbox-v2` or `sandbox-v3`, a run cannot read a hidden file, but it can still see
|
|
274
|
+
that one exists, its size and its times, and write beneath it. On Linux it
|
|
275
|
+
can also list a hidden folder; on macOS it cannot. A tool that acts on
|
|
208
276
|
whether a git-ignored file exists, without reading it, is not covered: a
|
|
209
|
-
new or removed one does not make the result STALE.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
277
|
+
new or removed one does not make the result STALE. In a census of 136
|
|
278
|
+
test suites with ignored files after their build, none did.
|
|
279
|
+
- On Linux, a test run's UDP is refused, loopback included: the filter
|
|
280
|
+
decides when the socket is made, before its address is known. A test that
|
|
281
|
+
needs it fails its trial and keeps another recipe.
|
|
282
|
+
- On Linux, the launcher decides a `connect` from the address it reads in
|
|
283
|
+
the process's memory, and the kernel reads it again after. A process that
|
|
284
|
+
changes the address in between, from another thread, could reach another
|
|
285
|
+
host. Honest test code does not.
|
|
286
|
+
- On Linux, a typecheck or lint can still connect to a Unix socket file
|
|
287
|
+
outside it (a local database's).
|
|
288
|
+
- On Linux, a file a `sandbox-v2` or `sandbox-v3` run creates in a folder that holds hidden
|
|
289
|
+
files (the checkout's root, when anything in it is hidden; with `sandbox-v3`,
|
|
290
|
+
any folder with a README) cannot be read
|
|
214
291
|
back in the same run. `init`'s trial and the rerun without hiding catch a
|
|
215
292
|
check that needs to.
|
|
216
293
|
- A file whose git status changes without a change to its content (`git add
|
package/docs/user/shell.md
CHANGED
|
@@ -11,9 +11,9 @@ REDUE UNVERIFIED
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
It reads the cached snapshot, checks its expiry and observer liveness, and never
|
|
14
|
-
synchronizes, traverses installed packages, or runs probes/checks. Like
|
|
15
|
-
status
|
|
16
|
-
need a synchronized read. Do not turn that uncertainty into a green badge.
|
|
14
|
+
synchronizes, traverses installed packages, or runs probes/checks. Like
|
|
15
|
+
`status --cached`, it can remain conservative when caller context or installed
|
|
16
|
+
hardlinks need a synchronized read. Do not turn that uncertainty into a green badge.
|
|
17
17
|
|
|
18
18
|
For an existing async/bounded prompt framework, use `redue status --short` from
|
|
19
19
|
the project root as the segment command and display unavailable on errors or
|
|
@@ -19,13 +19,16 @@ build scripts. It prefers familiar script names (for typecheck: `typecheck`,
|
|
|
19
19
|
`tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
|
|
20
20
|
matches are reported as ambiguous rather than guessed. It never creates a new
|
|
21
21
|
project script. Without `--recipe`, `init` tries the strict recipes below first, then,
|
|
22
|
-
for a root typecheck or
|
|
22
|
+
for a root typecheck, lint or test script, the [sandboxed recipe](sandbox.md) on
|
|
23
23
|
macOS and Linux (the script run inside an OS sandbox, with the files git
|
|
24
|
-
ignores hidden when it gives the same result that way,
|
|
25
|
-
|
|
24
|
+
ignores and the documentation hidden when it gives the same result that way,
|
|
25
|
+
else with only the files git ignores hidden, else on the whole checkout; a
|
|
26
|
+
test run also gets a private `/tmp`, and loopback-only network on Linux or no
|
|
27
|
+
network on macOS),
|
|
28
|
+
then, for typecheck and lint, the [general recipe](node-script.md): the script run
|
|
26
29
|
through the package manager with every Node process recorded and the whole
|
|
27
30
|
checkout as its input. Python projects get the sandboxed recipe for mypy,
|
|
28
|
-
pyright, ty and
|
|
31
|
+
pyright, ty, ruff and pytest in their `.venv`. `--recipe typescript-direct`,
|
|
29
32
|
`tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
|
|
30
33
|
a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
|
|
31
34
|
or no typecheck script at all (a proposed `tsc --noEmit`; see
|
|
@@ -56,7 +59,9 @@ typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
|
|
|
56
59
|
recording-only unless a separately justified explicit contract is provided.
|
|
57
60
|
For a single-package project whose test script runs Vitest 4/5 or Jest 29
|
|
58
61
|
directly, `init` uses the direct test recipe, which records the run and guards
|
|
59
|
-
its inputs; see [direct tests](tests-direct.md).
|
|
62
|
+
its inputs; see [direct tests](tests-direct.md). Other root test scripts get
|
|
63
|
+
the sandboxed recipe when its trial gives the same exit status and test counts
|
|
64
|
+
as a plain run.
|
|
60
65
|
In particular, test caches and build-generated inputs are not excluded to
|
|
61
66
|
make evidence green.
|
|
62
67
|
|
package/package.json
CHANGED
package/src/claude-hook.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','tes
|
|
|
27
27
|
sandboxed(check))&&typeof check.script==='string';
|
|
28
28
|
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
29
29
|
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
30
|
-
const sandboxed=check=>['sandbox-v1','sandbox-v2'].includes(check?.qualification)&&['typecheck','lint'].includes(check.kind);
|
|
30
|
+
const sandboxed=check=>['sandbox-v1','sandbox-v2','sandbox-v3'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
|
|
31
31
|
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
32
32
|
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
33
33
|
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
@@ -446,7 +446,7 @@ function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
|
446
446
|
// Claude Code ends a hook that outlives its own timeout; end the status call with it.
|
|
447
447
|
const stop=()=>finish(null);process.once('SIGTERM',stop);
|
|
448
448
|
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
449
|
-
try{child=spawn(process.execPath,[entry,'--config',config,'status'
|
|
449
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status',sync?'--sync':'--cached','--json'],
|
|
450
450
|
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
451
451
|
catch{return finish(null);}
|
|
452
452
|
child.on('error',()=>finish(null));
|
|
@@ -456,12 +456,14 @@ function synchronizedStatus(entry,config,root,timeout,sync=true){
|
|
|
456
456
|
});
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
//
|
|
460
|
-
// completed run
|
|
459
|
+
// Half the last completed run's duration, at least 2 s and at most 10 s; with
|
|
460
|
+
// no completed run, 2 s. A shorter wait misses a status call (70-250 ms here,
|
|
461
|
+
// over 1.5 s on a loaded CI or Windows runner) for a fast check, and the
|
|
462
|
+
// agent's run would go unrecorded.
|
|
461
463
|
export function statusTimeout(status,name){
|
|
462
464
|
const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
|
|
463
465
|
return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
|
|
464
|
-
Math.min(run.durationMs/2,10000):2000;
|
|
466
|
+
Math.min(Math.max(run.durationMs/2,2000),10000):2000;
|
|
465
467
|
}
|
|
466
468
|
// From the observer's last published status, which is small, not the receipts.
|
|
467
469
|
// The longest wait among `names`, read once.
|
|
@@ -500,18 +502,29 @@ export function sessionStart(input,{entry}){
|
|
|
500
502
|
}
|
|
501
503
|
|
|
502
504
|
// Returns the hook's stdout object, or null to allow the command.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
+
// `explain(text)` hears why, also when the command is left as it is.
|
|
506
|
+
export async function claudeHook(input,{entry,timeout,invoke=null,explain=null}){
|
|
507
|
+
// A failing explain never changes the decision.
|
|
508
|
+
const why=(text,value=null)=>{try{explain?.(text);}catch{}return value;};
|
|
509
|
+
if(input?.tool_name!=='Bash')return why('not a Bash command');
|
|
505
510
|
const command=input.tool_input?.command,root=input.cwd;
|
|
506
|
-
if(typeof command!=='string'
|
|
507
|
-
|
|
508
|
-
|
|
511
|
+
if(typeof command!=='string')return why('no command');
|
|
512
|
+
if(/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return why('REDUE_FORCE=1');
|
|
513
|
+
let project;
|
|
514
|
+
try{project=hookProject(root);}catch(error){return why(`no readable setup record in this directory (${error.code??error.message})`);}
|
|
515
|
+
if(!project)return why('REDUE is not set up for Claude Code in this directory');
|
|
516
|
+
// What a status answer said about `names`, when the hook does not skip.
|
|
517
|
+
const said=(status,names,waited)=>!status?`status did not answer within ${waited} ms`:
|
|
518
|
+
status.observation?.healthy!==true?'the observer is not healthy':
|
|
519
|
+
names.map(name=>{const row=Array.isArray(status.checks)?status.checks.find(item=>item?.name===name):null;
|
|
520
|
+
return !row?`${name} not in status`:`${name} ${row.freshness}/${row.result??'-'}${row.freshness==='CURRENT'&&row.result==='PASS'?
|
|
521
|
+
` but not reusable (reuse_eligible ${row.reuse_eligible}, run ${row.invocation?.runId??'none'})`:row.reason?` (${row.reason})`:''}`;}).join('; ');
|
|
509
522
|
const {record,config,parsed}=project;
|
|
510
523
|
// For `redue stats`: the checks a decision skipped, or the ones that were due.
|
|
511
524
|
const tally=(status,names,skipped)=>{let state;try{state=projectState(project.projectRoot,config);}catch{return;}
|
|
512
525
|
for(const name of names){const entry=skipped?skipNote(status,name,'hook'):dueNote(status,name);if(entry)note(state,entry);}};
|
|
513
526
|
const entered=enteredDir(command,root,project.projectRoot);
|
|
514
|
-
if(!entered)return
|
|
527
|
+
if(!entered)return why('the command enters a directory outside the project');
|
|
515
528
|
let scripts={};
|
|
516
529
|
if(entered.dir==='.')try{scripts=JSON.parse(fs.readFileSync(path.join(project.projectRoot,'package.json'),'utf8')).scripts||{};}catch{}
|
|
517
530
|
// The configured config path as the command's directory sees it.
|
|
@@ -527,23 +540,25 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
527
540
|
// The observer's cached health is enough here: each check asks again when it runs.
|
|
528
541
|
const chained=chainCommand(entered.command,parsed,record.config,entered.dir,scripts,invoke??redueInvocation(entry),configPath,project.projectRoot);
|
|
529
542
|
if(chained){
|
|
530
|
-
const cached=await synchronizedStatus(entry,config,root,
|
|
543
|
+
const wait=timeout??5000,cached=await synchronizedStatus(entry,config,root,wait,false);
|
|
531
544
|
return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
|
|
532
|
-
{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:
|
|
545
|
+
why(`a chain: ${chained.names.join(', ')} through redue run`,{hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}):
|
|
546
|
+
why(`a chain left as written: ${said(cached,chained.names,wait)}`);
|
|
533
547
|
}
|
|
534
548
|
const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
|
|
535
|
-
if(!match)return
|
|
549
|
+
if(!match)return why('no check matches the command');
|
|
536
550
|
// A check followed by more than a simple chain runs untouched.
|
|
537
|
-
if(match.rest!==null&&!match.fanout)return
|
|
551
|
+
if(match.rest!==null&&!match.fanout)return why('more commands follow the check');
|
|
538
552
|
// turbo, nx and `pnpm -r` cache their own results: skip only when every
|
|
539
553
|
// check the command runs is CURRENT, and otherwise leave it alone.
|
|
540
554
|
if(match.fanout){
|
|
541
555
|
const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
|
|
542
|
-
if(!names)return
|
|
543
|
-
const status=await synchronizedStatus(entry,config,root,
|
|
556
|
+
if(!names)return why('a fan-out whose checks are not all configured');
|
|
557
|
+
const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
|
|
544
558
|
reason=fanoutVerdict(status,names,match.rest);
|
|
545
559
|
tally(status,names,Boolean(reason));
|
|
546
|
-
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:
|
|
560
|
+
return reason?why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}):
|
|
561
|
+
why(`a fan-out left to run: ${said(status,names,wait)}`);
|
|
547
562
|
}
|
|
548
563
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
549
564
|
// they no longer correspond. One run is an ordinary check. A chain is
|
|
@@ -551,26 +566,26 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
551
566
|
// redue run, in order.
|
|
552
567
|
if(match.row){
|
|
553
568
|
const names=scriptChecks(match.row,parsed,project.projectRoot);
|
|
554
|
-
if(!names)return
|
|
569
|
+
if(!names)return why('the script no longer runs its configured checks');
|
|
555
570
|
if(names.length===1)match.check=names[0];
|
|
556
571
|
else{
|
|
557
|
-
const status=await synchronizedStatus(entry,config,root,
|
|
572
|
+
const wait=timeout??lastRunTimeout(config,parsed,...names),status=await synchronizedStatus(entry,config,root,wait),
|
|
558
573
|
reason=fanoutVerdict(status,names,match.rest);
|
|
559
574
|
tally(status,names,Boolean(reason));
|
|
560
|
-
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
575
|
+
if(reason)return why('every check CURRENT/PASS',{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}});
|
|
561
576
|
const pending=names.filter(name=>!verdict(status,name));
|
|
562
577
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
563
578
|
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
|
|
564
|
-
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
579
|
+
return why(`${rewritten?'through redue run':'left as written'}: ${said(status,names,wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
565
580
|
permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
|
|
566
|
-
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
581
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
567
582
|
}
|
|
568
583
|
}
|
|
569
|
-
const name=match.check,status=await synchronizedStatus(entry,config,root,
|
|
584
|
+
const name=match.check,wait=timeout??lastRunTimeout(config,parsed,name),status=await synchronizedStatus(entry,config,root,wait),
|
|
570
585
|
reason=verdict(status,name,match.rest);
|
|
571
586
|
tally(status,[name],Boolean(reason));
|
|
572
|
-
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
573
|
-
permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
587
|
+
if(reason)return why(`${name} CURRENT/PASS`,{hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
588
|
+
permissionDecision:'deny',permissionDecisionReason:reason}});
|
|
574
589
|
// Rewrite only on a healthy answer for this check: without an observer,
|
|
575
590
|
// `redue run` cannot record, so the command stays as Claude sent it. A chain
|
|
576
591
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
@@ -579,9 +594,9 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
579
594
|
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
|
|
580
595
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
581
596
|
// to the rewritten command; "allow" skips only the prompt.
|
|
582
|
-
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
597
|
+
return why(`${rewritten?'through redue run':'left as written'}: ${said(status,[name],wait)}`,rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
583
598
|
permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
|
|
584
|
-
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
599
|
+
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null);
|
|
585
600
|
}
|
|
586
601
|
|
|
587
602
|
export async function runClaudeHook(entry){
|
|
@@ -590,7 +605,12 @@ export async function runClaudeHook(entry){
|
|
|
590
605
|
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
591
606
|
const input=JSON.parse(text);
|
|
592
607
|
if(input?.hook_event_name==='SessionStart'){sessionStart(input,{entry});return;}
|
|
593
|
-
|
|
608
|
+
// REDUE_HOOK_LOG (an absolute file): one JSON line per decision, with why.
|
|
609
|
+
const log=process.env.REDUE_HOOK_LOG,started=Date.now();let why=null;
|
|
610
|
+
const output=await claudeHook(input,{entry,explain:text=>{why=text;}});
|
|
594
611
|
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
612
|
+
if(log&&path.isAbsolute(log))try{fs.appendFileSync(log,JSON.stringify({at:started,ms:Date.now()-started,cwd:input?.cwd??null,
|
|
613
|
+
command:input?.tool_input?.command??null,decision:!output?'allow':output.hookSpecificOutput?.updatedInput?'rewrite':output.hookSpecificOutput?.permissionDecision??'allow',
|
|
614
|
+
why})+'\n');}catch{}
|
|
595
615
|
}catch{}
|
|
596
616
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {patternMatcher} from './glob.mjs';
|
|
|
8
8
|
import {observedBy} from './installed-observation.mjs';
|
|
9
9
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
11
|
-
import {denialLine,endWithChild,hiddenEntries,hiddenQualification,hiddenRefusal,isSandboxed,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
11
|
+
import {denialLine,docsQualification,endWithChild,hiddenDocs,hiddenEntries,hiddenQualification,hiddenRefusal,hidesIgnored,isSandboxed,runToEnd,supervisorIssue,visibleChange,testRun as sandboxTestRun,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
12
12
|
sandboxInterpretation,sandboxInterpretationOf,sandboxPlaces,sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import net from 'node:net';
|
|
@@ -331,12 +331,24 @@ async function execute() {
|
|
|
331
331
|
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
332
332
|
else{
|
|
333
333
|
const dirs=sandboxDirs(state,name);
|
|
334
|
+
// A test run: loopback networking on Linux, supervised (the launcher
|
|
335
|
+
// reports in REDUE's state, outside what the test may touch), a
|
|
336
|
+
// private /tmp, and a fresh home each run: what a test leaves in its
|
|
337
|
+
// home is not observed, so it must not reach a later run.
|
|
338
|
+
const tests=sandboxTestRun(selected),stats=tests&&support.mode==='landlock'?path.join(state,`launcher-${runId}.json`):null;
|
|
339
|
+
if(tests){dirs.home=path.join(dirs.tmp,'home');fs.mkdirSync(dirs.home,{mode:0o700});}
|
|
334
340
|
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
335
|
-
// sandbox-v2 hides the checkout's git-ignored entries
|
|
336
|
-
// cannot list them, nothing is
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
341
|
+
// sandbox-v2 hides the checkout's git-ignored entries, and sandbox-v3
|
|
342
|
+
// its documentation too. Where git cannot list them, nothing is
|
|
343
|
+
// hidden, and the receipt compares every file.
|
|
344
|
+
const hidden=hidesIgnored(selected.qualification)?hiddenEntries(root):null;
|
|
345
|
+
const docs=hidden&&selected.qualification===docsQualification?hiddenDocs(root):null;
|
|
346
|
+
const options={reads,tmp:dirs.tmp,python:support.python,stats,
|
|
347
|
+
...tests?{...support.mode==='landlock'?{network:'loopback'}:{},writes:[root,dirs.home,dirs.tmp],
|
|
348
|
+
fault:process.env.VSTATE_TEST_FAULTS==='1'?process.env.VSTATE_TEST_SUPERVISOR||null:null}:{}};
|
|
349
|
+
sandbox={mode:support.mode,dirs,reads,hidden,docs,options,stats,
|
|
350
|
+
argv:sandboxArgv(support.mode,command,{...options,hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,''))),
|
|
351
|
+
...docs?{docs:{root,entries:docs}}:{}}),
|
|
340
352
|
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
341
353
|
}
|
|
342
354
|
}
|
|
@@ -481,6 +493,18 @@ async function execute() {
|
|
|
481
493
|
unexplained=touched.filter(file=>!ignored.has(file));
|
|
482
494
|
if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
483
495
|
}
|
|
496
|
+
// A run that hid git-ignored files: what it wrote beneath them (test
|
|
497
|
+
// output, coverage data) is not a change to its inputs (visibleChange);
|
|
498
|
+
// anything else is named.
|
|
499
|
+
let hiddenOnly=false;
|
|
500
|
+
if(sandbox?.hidden&&snap&&end&&snap.planId===end.planId&&snap.observationHealthy&&end.observationHealthy&&
|
|
501
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
502
|
+
const before=snap.files||{},after=end.files||{},events=end.inputEventPaths||[];
|
|
503
|
+
// During the run a new file outside the hidden entries was readable: it counts.
|
|
504
|
+
const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],
|
|
505
|
+
{during:true,docs:sandbox.docs});
|
|
506
|
+
if(found===null){eventsExplained=true;hiddenOnly=true;}else unexplained=[found];
|
|
507
|
+
}
|
|
484
508
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
485
509
|
// With no input event, gap or content change in between, it also holds at the start.
|
|
486
510
|
// A run checked by full probes is compared with it too, for its run facts.
|
|
@@ -519,18 +543,18 @@ async function execute() {
|
|
|
519
543
|
`: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
|
|
520
544
|
}
|
|
521
545
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
522
|
-
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
546
|
+
if(!outputs&&!hiddenOnly&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
523
547
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
524
548
|
// A run that hid git-ignored files and failed with a refusal that names
|
|
525
549
|
// one runs again in the sandbox without hiding them: the check needs
|
|
526
550
|
// them. That run's result is kept, compared on every file, when the
|
|
527
551
|
// inputs held through it too. Its own refusal is handled as below.
|
|
528
552
|
let hiddenRerun=null;
|
|
529
|
-
if(sandbox?.hidden?.length&&result==='FAIL'&&!requestedSignal){
|
|
530
|
-
const line=hiddenRefusal(output.head+'\n'+output.tail,sandbox.hidden);
|
|
553
|
+
if((sandbox?.hidden?.length||sandbox?.docs?.length)&&result==='FAIL'&&!requestedSignal){
|
|
554
|
+
const line=hiddenRefusal(output.head+'\n'+output.tail,[...sandbox.hidden||[],...sandbox.docs||[]]);
|
|
531
555
|
if(line){
|
|
532
|
-
console.error(`redue: the sandboxed run failed with what looks like a refusal of a file
|
|
533
|
-
const again=await runToEnd(sandboxArgv(sandbox.mode,command,
|
|
556
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal of a hidden file (${line}); running it again in the sandbox without hiding files`);
|
|
557
|
+
const again=await runToEnd(sandboxArgv(sandbox.mode,command,sandbox.options),
|
|
534
558
|
{cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
|
|
535
559
|
activeChild=null;
|
|
536
560
|
hiddenRerun={line,exitCode:again.status,signal:again.signal||null,...again.error?{errorCode:again.error.code||again.error.name}:{}};
|
|
@@ -556,7 +580,7 @@ async function execute() {
|
|
|
556
580
|
invocation.hiddenExitCode=invocation.exitCode;invocation.exitCode=again.status;
|
|
557
581
|
result=again.status===0?'PASS':'FAIL';
|
|
558
582
|
output.head=`${again.stdout}\n${again.stderr}`.slice(0,65536);output.tail='';
|
|
559
|
-
sandbox.hidden=null;
|
|
583
|
+
sandbox.hidden=null;sandbox.docs=null;
|
|
560
584
|
}
|
|
561
585
|
}
|
|
562
586
|
}
|
|
@@ -581,11 +605,14 @@ async function execute() {
|
|
|
581
605
|
else if(plain.error||plain.status===null)
|
|
582
606
|
captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
|
|
583
607
|
else if(plain.status!==invocation.exitCode){
|
|
584
|
-
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})
|
|
608
|
+
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line}); `+
|
|
609
|
+
'if this repeats, run redue init again to choose a recipe for this check');
|
|
585
610
|
invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
|
|
586
611
|
}
|
|
587
612
|
}
|
|
588
613
|
}
|
|
614
|
+
// A test run whose network was not supervised throughout is not reused.
|
|
615
|
+
if(sandbox?.stats){const issue=supervisorIssue(sandbox.stats);if(issue)captureIssues.push(issue);fs.rmSync(sandbox.stats,{force:true});}
|
|
589
616
|
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
590
617
|
const stable=captureIssues.length===0;
|
|
591
618
|
// Two runs on the same inputs, environment and plan that disagree make the
|
|
@@ -608,7 +635,7 @@ async function execute() {
|
|
|
608
635
|
// The recipe that made the receipt, which status and the hook name.
|
|
609
636
|
// A sandboxed receipt names what the run did: hid the git-ignored files or not.
|
|
610
637
|
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
|
|
611
|
-
recipe:(fallback?scriptQualification:own?(sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
|
|
638
|
+
recipe:(fallback?scriptQualification:own?(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
|
|
612
639
|
...(recorded||testRun?{runFacts}:{}),
|
|
613
640
|
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
614
641
|
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
@@ -623,8 +650,8 @@ async function execute() {
|
|
|
623
650
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
624
651
|
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
625
652
|
// `hidden`: the git-ignored entries the run did not see, which the observer does not compare.
|
|
626
|
-
...(own?{verificationRecipe:sandbox?.hidden?
|
|
627
|
-
sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
653
|
+
...(own?{verificationRecipe:sandboxInterpretationOf(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification,selected.kind),
|
|
654
|
+
sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...sandbox?.docs?{docs:sandbox.docs}:{},...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
628
655
|
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
629
656
|
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
630
657
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
@@ -718,7 +745,11 @@ async function main(){
|
|
|
718
745
|
if(processAlive(pid)){await call({action:'stop'});
|
|
719
746
|
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
720
747
|
await new Promise(r=>setTimeout(r,100));
|
|
721
|
-
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');
|
|
748
|
+
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');
|
|
749
|
+
// It releases the lock before it exits. On Windows a file it still
|
|
750
|
+
// holds open stays listed after deletion, and removing the
|
|
751
|
+
// directory fails with ENOTEMPTY until the process is gone.
|
|
752
|
+
for(let i=0;i<100&&processAlive(pid);i++)await new Promise(r=>setTimeout(r,100));}
|
|
722
753
|
}
|
|
723
754
|
// A stopping observer can still be closing its files (Windows: ENOTEMPTY, EBUSY).
|
|
724
755
|
fs.rmSync(state,{recursive:true,force:true,maxRetries:10,retryDelay:200});
|