jeo-code 0.12.25 → 0.12.27
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 +22 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +3 -2
- package/src/agent/engine.ts +140 -22
- package/src/agent/loop-guards.ts +391 -46
- package/src/agent/sandbox/docker.ts +15 -1
- package/src/agent/sandbox/hands-source.text +731 -731
- package/src/agent/snapshot-target.ts +188 -0
- package/src/agent/workspace-snapshot.ts +163 -24
- package/src/ai/types.ts +12 -0
- package/src/commands/launch.ts +2 -1
package/src/agent/loop-guards.ts
CHANGED
|
@@ -36,6 +36,13 @@ export type GuardState =
|
|
|
36
36
|
* Kept in one frozen object so the limits are discoverable and testable instead of
|
|
37
37
|
* sprinkled as bare literals through the loop body.
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* Prefix of the launch-level `onBeforeDone` nudge that merely restates the mutation-verification gate. The engine's
|
|
41
|
+
* own `classifyDoneGate` already blocks (and explains) that case, so the engine must not count the restatement as an
|
|
42
|
+
* independent block — otherwise the shell-only correction cap could never apply in the interactive loop.
|
|
43
|
+
*/
|
|
44
|
+
export const MUTATION_NUDGE_PREFIX = "Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification.";
|
|
45
|
+
|
|
39
46
|
export const GUARD_LIMITS = Object.freeze({
|
|
40
47
|
/** Identical step repeats tolerated before a consolidated stop (with corrections en route). */
|
|
41
48
|
MAX_REPEAT: 4,
|
|
@@ -43,6 +50,10 @@ export const GUARD_LIMITS = Object.freeze({
|
|
|
43
50
|
MAX_FAILURES: 5,
|
|
44
51
|
/** Rejected completion attempts that may request a correction before stopping incomplete. */
|
|
45
52
|
MAX_DONE_CORRECTIONS: 3,
|
|
53
|
+
/** Same, when the only mutation evidence is a shell/computer snapshot bracket (no built-in mutator
|
|
54
|
+
* succeeded). An unrelated process writing in the cwd looks identical to the command's own writes, so
|
|
55
|
+
* one correction states the paths and asks for verification; a second full-history retry only burns tokens. */
|
|
56
|
+
MAX_SHELL_ONLY_DONE_CORRECTIONS: 1,
|
|
46
57
|
/** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
|
|
47
58
|
* recovery switches to unbounded backoff resends (gjc parity: never terminal). */
|
|
48
59
|
MAX_REFUSAL_RETRIES: 3,
|
|
@@ -71,57 +82,308 @@ export const GUARD_LIMITS = Object.freeze({
|
|
|
71
82
|
/**
|
|
72
83
|
* Recognized standalone verification commands. Matching the full command prevents
|
|
73
84
|
* filenames, prose, and shell output from impersonating verification evidence.
|
|
85
|
+
*
|
|
86
|
+
* Two tables share one argument grammar: TEST_CORES run tests and print a pass summary, so output can
|
|
87
|
+
* corroborate them; SILENT_CORES (type checkers, linters, formatters in check mode, builds) report only through
|
|
88
|
+
* their exit status and print little or nothing on success.
|
|
89
|
+
*/
|
|
90
|
+
// Argument tail: plain tokens or balanced quoted strings (`pytest -k "foo and bar"`); `$`, backticks and
|
|
91
|
+
// backslashes never appear inside it, so nothing in a quoted argument can expand or escape.
|
|
92
|
+
const ARG = String.raw`(?:\s+(?:[A-Za-z0-9_./:@+*?,=-]+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*'))*`;
|
|
93
|
+
const PM_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^-]+`;
|
|
94
|
+
// Workspace / directory selection that may sit between a package manager and its script.
|
|
95
|
+
const PM_OPT = String.raw`(?:\s+(?:-r|--recursive|--workspaces|--silent|-s|--quiet|-q)|\s+(?:--filter|-F|--prefix|--cwd|-C|--workspace|-w|--dir)(?:=|\s+)${PM_VALUE})*`;
|
|
96
|
+
const PM = String.raw`(?:(?:bun|npm|pnpm|yarn)${PM_OPT}|yarn\s+workspace\s+[A-Za-z0-9_./@-]+)(?:\s+(?:run|exec|x|dlx))?${PM_OPT}`;
|
|
97
|
+
// make/just/rake/gradle/mvn take TARGETS (further commands), not script arguments: `make ci deploy` would run
|
|
98
|
+
// `deploy`. Their tails are therefore a closed grammar: any flag (judged by TASK_DENY below) together with the value a
|
|
99
|
+
// value-taking flag consumes, NAME=VALUE, and allow-listed targets. Any other bare token rejects the command.
|
|
100
|
+
const TARGETS = ["test", "tests", "check", "ci", "verify", "lint", "typecheck", "type-check", "vet", "clean", "build", "compile", "all", "unit", "integration", "e2e", "coverage", "format-check", "spec"];
|
|
101
|
+
const BUILD_TEST_TARGETS = ["test", "tests", "ci", "verify", "spec", "unit", "integration", "e2e", "coverage"];
|
|
102
|
+
const BUILD_SILENT_TARGETS = ["check", "lint", "typecheck", "type-check", "vet", "build", "compile", "format-check"];
|
|
103
|
+
// NAME=VALUE: never an update/snapshot/skip switch and never one of make's program-selecting variables.
|
|
104
|
+
const ASSIGN = String.raw`(?![A-Za-z0-9_]*(?:UPDATE|SNAPSHOT|GOLDEN|SKIP|IGNORE)[A-Za-z0-9_]*=)(?!(?:SHELL|MAKE|MAKEFLAGS|MAKEFILES)=)[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_./:@+,%=-]*`;
|
|
105
|
+
const TASK_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^#!%=-]`;
|
|
106
|
+
const QUOTED_VALUE = String.raw`(?:${TASK_VALUE}+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*')`;
|
|
107
|
+
const DOTNET_PROJECT = String.raw`(?!-)[A-Za-z0-9_~-]*[./][A-Za-z0-9_./~-]*`;
|
|
108
|
+
interface TaskSpec {
|
|
109
|
+
/** Flags that consume the next token (or `=value`) as their argument, as a regex alternation. */
|
|
110
|
+
value: string;
|
|
111
|
+
/** Flags whose argument is an OPTIONAL number (`make -j` vs `make -j 4`). */
|
|
112
|
+
numeric?: string;
|
|
113
|
+
/** Whether bare `NAME=VALUE` tokens are accepted (make, just, rake). */
|
|
114
|
+
assign?: boolean;
|
|
115
|
+
/** Whether targets may carry a Gradle project path (`:app:test`); only the LAST segment must be allow-listed. */
|
|
116
|
+
path?: boolean;
|
|
117
|
+
/** Value token shape; defaults to unquoted TASK_VALUE. */
|
|
118
|
+
token?: string;
|
|
119
|
+
}
|
|
120
|
+
const MAKE_SPEC: TaskSpec = { value: "-C|--directory|-f|--file|--makefile|-I|--include-dir", numeric: "-j|--jobs|-l|--load-average|--max-load", assign: true };
|
|
121
|
+
const JUST_SPEC: TaskSpec = { value: "-f|--justfile|-d|--working-directory", assign: true };
|
|
122
|
+
const RAKE_SPEC: TaskSpec = { value: "-f|--rakefile|-I|--libdir|-R|--rakelibdir", numeric: "-j|--jobs", assign: true };
|
|
123
|
+
const GRADLE_SPEC: TaskSpec = { value: "-p|--project-dir|-b|--build-file|-c|--settings-file|-g|--gradle-user-home|--tests|--max-workers|--console|--warning-mode|--project-cache-dir", path: true };
|
|
124
|
+
const MVN_SPEC: TaskSpec = { value: "-pl|--projects|-f|--file|-T|--threads|-P|--activate-profiles|-s|--settings|-gs|--global-settings|-t|--toolchains|-rf|--resume-from|-l|--log-file" };
|
|
125
|
+
const DOTNET_SPEC: TaskSpec = { value: "-c|--configuration|-f|--framework|-v|--verbosity|-l|--logger|--filter|-r|--runtime|-a|--arch|--os|-s|--settings" };
|
|
126
|
+
/**
|
|
127
|
+
* One flag token: a value-taking flag with its argument, an optional-number flag, any other `-x`/`--long[=v]` flag, or
|
|
128
|
+
* (if enabled) NAME=VALUE. The generic alternative excludes the exact names handled by the first two, so every token
|
|
129
|
+
* has ONE parse (`-C test` consumes `test` as the directory) and matching stays linear. Whether a flag is acceptable is
|
|
130
|
+
* decided afterwards by TASK_DENY on the whole command, never by this grammar.
|
|
131
|
+
*/
|
|
132
|
+
function taskFlag(spec: TaskSpec): string {
|
|
133
|
+
const token = QUOTED_VALUE;
|
|
134
|
+
const names = spec.numeric ? `${spec.value}|${spec.numeric}` : spec.value;
|
|
135
|
+
const alts = [
|
|
136
|
+
String.raw`(?:${spec.value})(?:=|\s+)${token}`,
|
|
137
|
+
String.raw`(?!(?:${names})(?=[=\s]|$))--?[A-Za-z][\w.:-]*(?:=${TASK_VALUE}*)?`,
|
|
138
|
+
];
|
|
139
|
+
if (spec.numeric) alts.push(String.raw`(?:${spec.numeric})(?:(?:=|\s+)\d+(?:\.\d+)?)?`);
|
|
140
|
+
if (spec.assign) alts.push(ASSIGN);
|
|
141
|
+
return alts.join("|");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `head (flag | non-primary target)* PRIMARY (flag | allow-listed target)*`. The leading run excludes the primary
|
|
145
|
+
* targets so the split point is unique; every token after `head` must be a flag, NAME=VALUE or an allow-listed target.
|
|
74
146
|
*/
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
147
|
+
function taskCore(head: string, spec: TaskSpec, primary: readonly string[]): string {
|
|
148
|
+
const flag = taskFlag(spec);
|
|
149
|
+
const pre = spec.path ? String.raw`(?:(?!-)[\w.-]*:)*` : "";
|
|
150
|
+
const rest = TARGETS.filter(target => !primary.includes(target)).join("|");
|
|
151
|
+
return String.raw`${head}(?:\s+(?:${flag}|${pre}(?:${rest})))*\s+${pre}(?:${primary.join("|")})(?:\s+(?:${flag}|${pre}(?:${TARGETS.join("|")})))*`;
|
|
152
|
+
}
|
|
153
|
+
const PRETTIER = String.raw`(?:(?:npx|bunx)\s+|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx)\s+)?prettier`;
|
|
154
|
+
const TEST_CORES: readonly string[] = [
|
|
155
|
+
String.raw`${PM}\s+(?:test|tests|spec|vitest|jest)(?::[\w-]+)?`,
|
|
156
|
+
String.raw`(?:npx|bunx)\s+(?:vitest|jest|pytest)`,
|
|
157
|
+
String.raw`(?:(?:npx|bunx)|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx))\s+playwright\s+test`,
|
|
158
|
+
String.raw`(?:vitest|jest|pytest|phpunit|playwright\s+test)`,
|
|
159
|
+
String.raw`(?:bundle\s+exec\s+)?rspec`,
|
|
160
|
+
String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+unittest)`,
|
|
161
|
+
String.raw`node\s+--test`,
|
|
162
|
+
String.raw`deno\s+test`,
|
|
163
|
+
String.raw`cargo\s+test`,
|
|
164
|
+
String.raw`go\s+test`,
|
|
165
|
+
];
|
|
166
|
+
const SILENT_CORES: readonly string[] = [
|
|
167
|
+
String.raw`${PM}\s+(?:typecheck|lint|build|check|tsc|eslint)(?::[\w-]+)?`,
|
|
168
|
+
String.raw`(?:npx|bunx)\s+(?:tsc|eslint)`,
|
|
169
|
+
String.raw`(?:tsc|eslint|mypy|flake8|staticcheck|ruff\s+check|golangci-lint\s+run)`,
|
|
170
|
+
String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+-m\s+(?:py_compile|mypy|flake8|ruff\s+check)`,
|
|
171
|
+
String.raw`node\s+(?:--check|-c)`,
|
|
172
|
+
String.raw`deno\s+(?:check|lint)`,
|
|
173
|
+
String.raw`cargo\s+(?:check|build|clippy)`,
|
|
174
|
+
String.raw`cargo\s+fmt(?=(?:\s+\S+)*\s+--check(?:\s|$))`,
|
|
175
|
+
String.raw`go\s+(?:vet|build)`,
|
|
176
|
+
String.raw`${PRETTIER}(?=(?:\s+\S+)*\s+(?:--check|-c|--list-different|-l)(?:\s|$))`,
|
|
177
|
+
];
|
|
178
|
+
// Task-runner cores carry their own closed tail (see taskCore); VERIFY_SIGNAL_RE must not append ARG to them.
|
|
179
|
+
const STRICT_TEST_CORES: readonly string[] = [
|
|
180
|
+
taskCore("make", MAKE_SPEC, BUILD_TEST_TARGETS),
|
|
181
|
+
taskCore("just", JUST_SPEC, BUILD_TEST_TARGETS),
|
|
182
|
+
taskCore(String.raw`(?:bundle\s+exec\s+)?rake`, RAKE_SPEC, ["test", "tests", "spec"]),
|
|
183
|
+
taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["test"]),
|
|
184
|
+
taskCore("(?:mvn|mvnw)", MVN_SPEC, ["test", "verify"]),
|
|
185
|
+
String.raw`dotnet\s+test(?:\s+(?:${taskFlag(DOTNET_SPEC)}|${DOTNET_PROJECT}))*`,
|
|
186
|
+
];
|
|
187
|
+
const STRICT_SILENT_CORES: readonly string[] = [
|
|
188
|
+
taskCore("make", MAKE_SPEC, BUILD_SILENT_TARGETS),
|
|
189
|
+
taskCore("just", JUST_SPEC, BUILD_SILENT_TARGETS),
|
|
190
|
+
taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["check", "build"]),
|
|
191
|
+
];
|
|
192
|
+
// Loose cores match case-insensitively; the task-runner cores stay case-sensitive because their flags differ by case
|
|
193
|
+
// (`mvn -B` batch vs `-b` builder, `-P` profile vs `-p`, `make -C` vs `-c`): `i` would let one swallow the target.
|
|
194
|
+
/** Anchored match of every recognized verifier core (test runners and silent checkers) with its tail. */
|
|
195
|
+
export const VERIFY_SIGNAL_RE = new RegExp(`^(?:(?i:${[...TEST_CORES, ...SILENT_CORES].join("|")})${ARG}|${[...STRICT_TEST_CORES, ...STRICT_SILENT_CORES].join("|")})$`);
|
|
196
|
+
/** Cores that run tests and print a pass summary: the only ones whose output can stand in for a trustworthy exit status. */
|
|
197
|
+
const TEST_CORE_RE = new RegExp(`^(?:(?i:${TEST_CORES.join("|")})${ARG}|${STRICT_TEST_CORES.join("|")})$`);
|
|
198
|
+
// `-x test` (gradle skip) and `-u` (jest update) are lowercase-only: mvn's `-X` (debug) and `-U` (update dependencies) are not them.
|
|
199
|
+
const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--show-settings|--show-files|--init|--dry-run|--no-run|--passWithNoTests|--if-present|-DskipTests|-Dmaven\.test\.skip|-Dmaven\.test\.failure\.ignore|-Dtest\.failure\.ignore|-DfailIfNoTests|-Dsurefire\.failIfNoSpecifiedTests|(?-i:-x)\s+test)(?=$|\s|=)/i;
|
|
200
|
+
const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--fix-only|--unsafe-fixes|--add-noqa|--install-types|-fix|--write|--update|(?-i:-u)|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
|
|
201
|
+
// Judged on the first script token after any manager options, so `pnpm --filter web test:fix` is still caught.
|
|
202
|
+
const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\b(?:\s+\S+)*?\s+[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
|
|
203
|
+
// Any short-flag cluster containing `w` (`-w`, `-cw`, `-wc`) writes files; `--write` is caught by MUTATING_VERIFY_FLAG_RE.
|
|
204
|
+
const PRETTIER_WRITE_RE = new RegExp(String.raw`^${PRETTIER}\b.*(?:^|\s)-[A-Za-z]*w[A-Za-z]*(?=\s|$)`, "i");
|
|
205
|
+
// Per-tool flag deny-list, judged on the command text with quotes turned into spaces. The same letter means different
|
|
206
|
+
// things per tool (`-t` touches in make, selects toolchains in mvn; `-i` ignores errors in make, logs in gradle), so
|
|
207
|
+
// each tool has its own list. Denied: dry-run / listing / touch / "what if" modes and anything that skips tests, masks a
|
|
208
|
+
// failing exit status, or rewrites lock files. make/just/rake may bundle short flags (`-kn`), so those match clusters.
|
|
209
|
+
const PROP_DENY_RE = /(?:^|\s)(?:-D|-P|--define|--project-prop|--system-prop|--activate-profiles|-p:)\s*\S*?(?:skip|ignore|failure|dry.?run|update|snapshot|golden|exec=true)/i;
|
|
210
|
+
const TASK_DENY: readonly { head: RegExp; deny: readonly RegExp[] }[] = [
|
|
211
|
+
{ head: /^make\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[ntqiWo][A-Za-z]*|--(?:dry-run|just-print|recon|touch|question|what-if|new-file|assume-new|old-file|assume-old|ignore-errors|eval))(?=\s|=|$)/] },
|
|
212
|
+
{ head: /^just\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nlse][A-Za-z]*|--(?:dry-run|list|show|evaluate|edit|init|dump|summary|choose|fmt|variables|changelog|completions|command))(?=\s|=|$)/] },
|
|
213
|
+
{ head: /^(?:bundle\s+exec\s+)?rake\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nTPDWperEhHV][A-Za-z]*|--(?:dry-run|tasks|prereqs|describe|where|execute|execute-print|execute-continue|require|help|version))(?=\s|=|$)/] },
|
|
214
|
+
{ head: /^(?:gradle|gradlew)\b/, deny: [/(?:^|\s)(?:-[mta](?=\s|$)|-[xI]\S*|--(?:dry-run|exclude-task|continuous|init-script|scan|write-locks|update-locks|write-verification-metadata|refresh-dependencies|export-keys|no-rebuild)(?=\s|=|$))/, PROP_DENY_RE] },
|
|
215
|
+
{ head: /^(?:mvn|mvnw)\b/, deny: [/(?:^|\s)(?:-v|--version|-h|--help|-fn|--fail-never)(?=\s|=|$)/, PROP_DENY_RE] },
|
|
216
|
+
{ head: /^dotnet\b/, deny: [/(?:^|\s)(?:-t|--list-tests|-h|--help|-\?)(?=\s|=|$)/, PROP_DENY_RE] },
|
|
217
|
+
];
|
|
79
218
|
|
|
80
219
|
// ── Verifier command grammar ──────────────────────────────────────────────────
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
220
|
+
// The done-gate trusts the shell's exit status of a verifier. A form whose status is NOT the verifier's own can
|
|
221
|
+
// report success while the verifier failed, so it is "weak": it counts ONLY when the OUTPUT shows positive pass
|
|
222
|
+
// evidence AND no failure marker (see verificationProducedEvidence). Direct forms keep tool success + evidence.
|
|
223
|
+
//
|
|
224
|
+
// command := "(" body ")" | body one outer subshell level, always weak; `2>&1` tokens removed
|
|
225
|
+
// body := ( (set|cd) ";" )* [cdSeg "&&"] pipeline ( "&&" pipeline )* [ ("||" | ";") trailer ]
|
|
226
|
+
// pipeline := verifierSeg ( "|" display )* display := tail [-N | -n N] | tee [-a] (never a file, never head)
|
|
227
|
+
// trailer := true | ":" | echo <plain words> echo only at the top level; its text must hold no digit/pass/ok/success
|
|
228
|
+
// verifierSeg:= prefix* core | (bash|sh|zsh|dash) -c "<body>" nested -c is judged on the inner body, one level deep
|
|
229
|
+
// prefix := `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`, `timeout N[s|m]`, `time [-p]`,
|
|
230
|
+
// `uv|poetry|pipenv run`, `rtk [test]` each propagates the verifier's exit status
|
|
86
231
|
// core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
|
|
87
232
|
// non-executing / mutating flag, no mutating script, and no short no-op form.
|
|
88
|
-
//
|
|
89
|
-
//
|
|
233
|
+
// Weak = any pipe, `||`/`;` trailer, `set`/`cd ;` prefix or subshell. A weak form is recognized only when EVERY core
|
|
234
|
+
// is a test runner (TEST_CORE_RE): a silent linter has no output to corroborate it, and `grep`/`awk`/`sed`/`sort`/
|
|
235
|
+
// `cut`/`head` stages, `rm`, `git commit`, installers or any other command never qualify as display or trailer.
|
|
236
|
+
// make/just/rake/gradle/mvn/dotnet cores use a closed tail (see taskCore): any flag not on that tool's TASK_DENY list,
|
|
237
|
+
// its value, NAME=VALUE and allow-listed targets; any other bare target (deploy, install, publish, db:drop) rejects.
|
|
238
|
+
// Only plain tokens or balanced quotes are accepted: `$`, backticks, backslashes, redirections and grouping
|
|
239
|
+
// outside the single subshell are rejected before any stage is read.
|
|
90
240
|
const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
|
|
91
241
|
const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
|
|
92
242
|
const ENV_PREFIX_RE = /^env(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\s+([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*)*\s+/;
|
|
93
243
|
const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
|
|
94
244
|
const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
|
|
95
245
|
const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
|
|
96
|
-
const
|
|
246
|
+
const TIME_PREFIX_RE = /^time\s+(?:-p\s+)?/;
|
|
247
|
+
const CD_SEGMENT_RE = /^cd\s+(?:[A-Za-z0-9_./:@+,=~-]+|"[^"$`\\]*"|'[^'$`\\]*')$/;
|
|
248
|
+
const SET_STMT_RE = /^set(?:\s+(?:-[A-Za-z]+|-o\s+pipefail|pipefail))+$/;
|
|
97
249
|
const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
|
|
250
|
+
const SHELL_C_RE = /^(?:ba|z|da)?sh\s+(?:-o\s+pipefail\s+)?-[elux]*c\s+(?:"([^"]*)"|'([^']*)')$/;
|
|
251
|
+
// `head` is not display-only (it closes the pipe early, hiding a later failure) and `tee <file>` could overwrite a
|
|
252
|
+
// tracked file through a symlink: a verified call is never snapshot-bracketed, so that write would be invisible.
|
|
253
|
+
const DISPLAY_STAGE_RE = /^(?:tail(?:\s+(?:-\d+|-n\s*\d+))?|tee(?:\s+-a)?)$/;
|
|
254
|
+
/** Echo text that could read as a pass summary (or contains a count) is refused rather than discounted. */
|
|
255
|
+
const ECHO_PASSLIKE_RE = /\d|\bpass(?:ed|ing)?\b|\bok\b|success/i;
|
|
256
|
+
const TRAILER_RE = /^(?:true|:|echo((?:\s+(?:[A-Za-z0-9_.,:@%+=\/][A-Za-z0-9_.,:@%+=\/-]*|"[^"$`\\]*"|'[^'$`\\]*'))*))$/;
|
|
98
257
|
/** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
|
|
99
258
|
const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
|
|
100
259
|
const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
|
|
101
260
|
|
|
261
|
+
/** Result of {@link parseVerifier}: the verifier invocations and how far the shell's exit status can be trusted. */
|
|
262
|
+
export interface VerifierParse {
|
|
263
|
+
/** Normalized verifier invocations (wrapper prefixes, `cd`, pipes and trailers stripped). */
|
|
264
|
+
cores: string[];
|
|
265
|
+
/** True when the shell's exit status IS the verifier's own, so tool success is itself the pass signal. */
|
|
266
|
+
trusted: boolean;
|
|
267
|
+
/** Literal text a trailing `echo` prints; the evidence check discounts it so it cannot fake a pass line. */
|
|
268
|
+
echoed: string | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface Chain { cores: string[]; weak: boolean; echoed: string | null }
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse a command as a recognized verification, or null when it is not one. See the grammar above for what is
|
|
275
|
+
* accepted and which forms are weak (`trusted: false`).
|
|
276
|
+
*/
|
|
277
|
+
export function parseVerifier(cmd: string): VerifierParse | null {
|
|
278
|
+
let command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
|
|
279
|
+
if (!command) return null;
|
|
280
|
+
let wrapped = false;
|
|
281
|
+
if (command.startsWith("(") && command.endsWith(")")) {
|
|
282
|
+
command = command.slice(1, -1).trim();
|
|
283
|
+
wrapped = true;
|
|
284
|
+
}
|
|
285
|
+
const chain = parseChain(command, false);
|
|
286
|
+
if (!chain) return null;
|
|
287
|
+
const weak = wrapped || chain.weak;
|
|
288
|
+
if (weak && !chain.cores.every(core => TEST_CORE_RE.test(core))) return null;
|
|
289
|
+
return { cores: chain.cores, trusted: !weak, echoed: chain.echoed };
|
|
290
|
+
}
|
|
291
|
+
|
|
102
292
|
/**
|
|
103
293
|
* Normalized verifier invocations (cores) of a command, or null when the command is not a
|
|
104
|
-
*
|
|
294
|
+
* recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
|
|
105
295
|
* so flag/script exclusions are judged on the verifier itself, never on the wrappers.
|
|
106
296
|
*/
|
|
107
297
|
export function verifierCores(cmd: string): string[] | null {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
298
|
+
return parseVerifier(cmd)?.cores ?? null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Split on top-level `&&`, `||`, `|` and `;` while leaving quoted text intact. Rejects anything that could change
|
|
303
|
+
* what runs: expansion, escapes, newlines, redirections, grouping and a lone `&` outside quotes.
|
|
304
|
+
*/
|
|
305
|
+
function splitShell(command: string): { stages: string[]; ops: string[] } | null {
|
|
306
|
+
const stages: string[] = [];
|
|
307
|
+
const ops: string[] = [];
|
|
308
|
+
let start = 0;
|
|
309
|
+
let quote = "";
|
|
310
|
+
for (let i = 0; i < command.length; i++) {
|
|
311
|
+
const ch = command[i];
|
|
312
|
+
if (ch === "$" || ch === "`" || ch === "\\" || ch === "\n" || ch === "\r") return null;
|
|
313
|
+
if (quote) {
|
|
314
|
+
if (ch === quote) quote = "";
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
318
|
+
if ("<>(){}".includes(ch)) return null;
|
|
319
|
+
let op: string;
|
|
320
|
+
if (ch === "&") {
|
|
321
|
+
if (command[i + 1] !== "&") return null;
|
|
322
|
+
op = "&&";
|
|
323
|
+
} else if (ch === "|") op = command[i + 1] === "|" ? "||" : "|";
|
|
324
|
+
else if (ch === ";") op = ";";
|
|
325
|
+
else continue;
|
|
326
|
+
stages.push(command.slice(start, i).trim());
|
|
327
|
+
ops.push(op);
|
|
328
|
+
i += op.length - 1;
|
|
329
|
+
start = i + 1;
|
|
330
|
+
}
|
|
331
|
+
if (quote) return null;
|
|
332
|
+
stages.push(command.slice(start).trim());
|
|
333
|
+
return stages.some(stage => !stage) ? null : { stages, ops };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseChain(command: string, nested: boolean): Chain | null {
|
|
337
|
+
const shell = splitShell(command);
|
|
338
|
+
if (!shell) return null;
|
|
339
|
+
const { stages, ops } = shell;
|
|
340
|
+
const last = stages.length - 1;
|
|
341
|
+
let p = 0;
|
|
342
|
+
let weak = false;
|
|
343
|
+
// `set -e;` / `cd x;` change state only; they cannot mask the next command's status, but are not the verifier's own.
|
|
344
|
+
while (p < last && ops[p] === ";" && (SET_STMT_RE.test(stages[p]) || CD_SEGMENT_RE.test(stages[p]))) { weak = true; p++; }
|
|
345
|
+
if (p < last && ops[p] === "&&" && CD_SEGMENT_RE.test(stages[p])) p++;
|
|
115
346
|
const cores: string[] = [];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
347
|
+
let echoed: string | null = null;
|
|
348
|
+
for (;;) {
|
|
349
|
+
const stage = parseStage(stages[p], nested);
|
|
350
|
+
if (!stage) return null;
|
|
351
|
+
cores.push(...stage.cores);
|
|
352
|
+
if (stage.weak) weak = true;
|
|
353
|
+
p++;
|
|
354
|
+
while (p <= last && ops[p - 1] === "|") {
|
|
355
|
+
if (!DISPLAY_STAGE_RE.test(stages[p])) return null;
|
|
356
|
+
weak = true;
|
|
357
|
+
p++;
|
|
358
|
+
}
|
|
359
|
+
if (p > last) break;
|
|
360
|
+
if (ops[p - 1] === "&&") continue;
|
|
361
|
+
// `||` or `;`: exactly one trailing stage that cannot run anything else.
|
|
362
|
+
if (p !== last) return null;
|
|
363
|
+
const trailer = TRAILER_RE.exec(stages[p]);
|
|
364
|
+
if (!trailer) return null;
|
|
365
|
+
if (trailer[1] !== undefined) {
|
|
366
|
+
if (nested) return null; // only a top-level echo is the tail of the whole output, where it can be discounted
|
|
367
|
+
echoed = [...trailer[1].matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(t => t[1] ?? t[2] ?? t[3]).join(" ");
|
|
368
|
+
if (ECHO_PASSLIKE_RE.test(echoed)) return null;
|
|
369
|
+
}
|
|
370
|
+
weak = true;
|
|
371
|
+
break;
|
|
120
372
|
}
|
|
121
|
-
return cores
|
|
373
|
+
return { cores, weak, echoed };
|
|
122
374
|
}
|
|
123
375
|
|
|
124
|
-
function
|
|
376
|
+
function parseStage(segment: string, nested: boolean): Chain | null {
|
|
377
|
+
const rest = unwrap(segment);
|
|
378
|
+
if (rest === null) return null;
|
|
379
|
+
const shell = SHELL_C_RE.exec(rest);
|
|
380
|
+
if (shell) return nested ? null : parseChain(shell[1] ?? shell[2], true);
|
|
381
|
+
const core = verifierCore(rest);
|
|
382
|
+
return core ? { cores: [core], weak: false, echoed: null } : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Strip exit-code-propagating wrapper prefixes and normalize a path-qualified binary; null on a mutating env. */
|
|
386
|
+
function unwrap(segment: string): string | null {
|
|
125
387
|
let rest = segment;
|
|
126
388
|
for (;;) { // terminates: every successful match strictly shortens `rest`
|
|
127
389
|
const assign = ASSIGN_PREFIX_RE.exec(rest);
|
|
@@ -136,7 +398,7 @@ function verifierCore(segment: string): string | null {
|
|
|
136
398
|
rest = rest.slice(env[0].length);
|
|
137
399
|
continue;
|
|
138
400
|
}
|
|
139
|
-
const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
|
|
401
|
+
const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest) ?? TIME_PREFIX_RE.exec(rest);
|
|
140
402
|
if (other) {
|
|
141
403
|
rest = rest.slice(other[0].length);
|
|
142
404
|
continue;
|
|
@@ -144,37 +406,110 @@ function verifierCore(segment: string): string | null {
|
|
|
144
406
|
break;
|
|
145
407
|
}
|
|
146
408
|
const bin = BINARY_PATH_RE.exec(rest);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
409
|
+
return bin ? bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length) : rest;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function verifierCore(rest: string): string | null {
|
|
413
|
+
// Flag exclusions are judged with quote characters turned into spaces: `bun test "--update-snapshots"` must not hide
|
|
414
|
+
// the flag behind a quote. A quoted `-k` expression that merely contains a flag-like word fails closed.
|
|
415
|
+
const flat = rest.replace(/["']/g, " ");
|
|
416
|
+
const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(flat)
|
|
417
|
+
|| /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(flat)
|
|
418
|
+
|| PRETTIER_WRITE_RE.test(flat);
|
|
419
|
+
const task = TASK_DENY.find(entry => entry.head.test(rest));
|
|
420
|
+
return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(flat)
|
|
421
|
+
&& !MUTATING_VERIFY_FLAG_RE.test(flat) && !MUTATING_VERIFY_SCRIPT_RE.test(flat)
|
|
422
|
+
&& !task?.deny.some(re => re.test(flat)) ? rest : null;
|
|
152
423
|
}
|
|
153
424
|
|
|
154
425
|
/**
|
|
155
426
|
* True only when the command itself is a recognized verifier invocation (or a pure `&&`
|
|
156
427
|
* chain of them, optionally led by `cd <path> &&` and wrapped in exit-code-propagating
|
|
157
|
-
* prefixes — see {@link
|
|
158
|
-
* print words such as "tests passed".
|
|
428
|
+
* prefixes — see {@link parseVerifier}, which also admits the weak forms). Output is never what
|
|
429
|
+
* RECOGNIZES a command: arbitrary commands can print words such as "tests passed".
|
|
159
430
|
*/
|
|
160
431
|
export function isVerificationSignal(cmd: string): boolean {
|
|
161
|
-
return
|
|
432
|
+
return parseVerifier(cmd) !== null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Output evidence. Every pattern is anchored to a summary position so a test TITLE cannot impersonate a summary.
|
|
436
|
+
const PASS_LINE_RES: readonly RegExp[] = [
|
|
437
|
+
// bun "12 pass", mocha "12 passing", jest "Tests: 1 failed, 4 passed", vitest "Tests 5 passed", pytest "== 5 passed",
|
|
438
|
+
// playwright "5 passed (3s)", deno "ok | 3 passed"
|
|
439
|
+
/^[ \t=]*(?:ok[ \t]*\|[ \t]*)?(?:Tests?:?[ \t]+)?(?:\d+ [A-Za-z]+(?:[ \t]*[,|][ \t]*|[ \t]+))*[1-9]\d* pass(?:ed|ing)?\b/,
|
|
440
|
+
/^\s*(?:\u2139|#)\s*pass\s+[1-9]\d*\b/, // node --test
|
|
441
|
+
/^ok\s+\S+\s+(?:\(cached\)|[\d.]+s)/, // go (the `[no tests ...]` forms are excluded by the caller)
|
|
442
|
+
/^\s*--- PASS:/, // go test -v
|
|
443
|
+
/^\s*[1-9]\d* examples?, 0 failures?/, // rspec
|
|
444
|
+
/Tests run:\s*[1-9]\d*,\s*Failures:\s*0,\s*Errors:\s*0/, // maven
|
|
445
|
+
/^\s*Passed!.*\bPassed:\s*[1-9]\d*/, // dotnet
|
|
446
|
+
/^OK \([1-9]\d* tests?,/, // phpunit
|
|
447
|
+
];
|
|
448
|
+
const FAILURE_LINE_RES: readonly RegExp[] = [
|
|
449
|
+
/(?:^|[^\d])[1-9]\d*\s+(?:fail(?:ed|ing|ures?)?|errors?)\b/i,
|
|
450
|
+
/\b(?:Failures|Errors|Failed):\s*[1-9]/,
|
|
451
|
+
/^\s*\(fail\)/,
|
|
452
|
+
/^\s*(?:FAIL|FAILED|ERROR)\b/,
|
|
453
|
+
/^\s*--- FAIL/,
|
|
454
|
+
/^\s*panic:|panicked at/,
|
|
455
|
+
/test result: FAILED|\.\.\. FAILED\b|^failures:\s*$/, // cargo
|
|
456
|
+
/^\s*error: .*(?:target|test).*failed|\bcould not compile\b/i, // cargo: the FAILED line can scroll out of a `tail` window
|
|
457
|
+
/^\[ERROR\]|BUILD (?:FAILED|FAILURE)|^FAILURE:/, // maven / gradle
|
|
458
|
+
/Tests run:.*(?:Failures|Errors): [1-9]/, // maven
|
|
459
|
+
/\b(?:ERRORS|FAILURES)!/, // phpunit
|
|
460
|
+
/^\s*Failed!/, // dotnet
|
|
461
|
+
/^\s*(?:\u2139|#)\s*fail\s+[1-9]/,
|
|
462
|
+
/\bSegmentation fault\b|\bBus error\b|\bKilled\b|\btimed out\b/,
|
|
463
|
+
/\berror TS\d+/,
|
|
464
|
+
];
|
|
465
|
+
/** A line a runner prints for a test that PASSED: its title may legitimately mention "errors" or "failed". */
|
|
466
|
+
const PASSED_TITLE_RE = /^\s*(?:\((?:pass|skip|todo)\)|[\u2713\u2714\u221a])/;
|
|
467
|
+
/** Explicit "nothing ran" output; only consulted when no positive pass summary is present. */
|
|
468
|
+
const NO_TESTS_RE = /(?:^|[^\d])0 pass(?:ed|ing)?\b|(?:^|[^\d])0 examples?\b|\bno tests? (?:ran|found|to run|executed)\b|\bno test files found\b|\bNo test is available\b|\bcollected 0 items\b|\bRan 0 tests\b|\bTests run:\s*0\b|(?:\u2139|#)\s*pass\s+0\b/i;
|
|
469
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
470
|
+
|
|
471
|
+
function hasPassSummary(text: string): boolean {
|
|
472
|
+
if (/\bBUILD SUCCESSFUL\b/.test(text) && !/\bNO-SOURCE\b/.test(text)) return true; // gradle prints no counts
|
|
473
|
+
if (/test result: ok\.\s+[1-9]\d* passed/.test(text)) return true;
|
|
474
|
+
return text.split(/\r?\n/).some(line => PASS_LINE_RES.some(re => re.test(line)) && !/\[no tests to run\]|\[no test files\]/i.test(line));
|
|
162
475
|
}
|
|
163
476
|
|
|
164
477
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
478
|
+
* Drop the text a trailing `echo` printed so it cannot supply (or hide) a pass line. The bash tool joins stdout then
|
|
479
|
+
* stderr, so the echo can sit mid-output: every whole-line occurrence goes, and so does a glued trailing suffix.
|
|
480
|
+
*/
|
|
481
|
+
function withoutEchoText(text: string, echoed: string | null): string {
|
|
482
|
+
if (!echoed) return text;
|
|
483
|
+
const kept = text.split(/\r?\n/).filter(line => line.trim() !== echoed).join("\n");
|
|
484
|
+
const trimmed = kept.trimEnd();
|
|
485
|
+
return trimmed.endsWith(echoed) ? trimmed.slice(0, trimmed.length - echoed.length) : kept;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Output-side judgment of a verifier run the caller already knows succeeded (exit 0).
|
|
490
|
+
* - Go and Cargo need real test-run evidence, judged on the verifier cores so a wrapped
|
|
491
|
+
* `timeout 60 go test ./...` or `cd x && cargo test` still needs it.
|
|
492
|
+
* - A direct test runner whose output says nothing ran (`0 pass`, "no tests ran") is not evidence unless a positive
|
|
493
|
+
* pass summary is also present; silent linters and empty output stay evidence on tool success alone.
|
|
494
|
+
* - A weak form (see the grammar above) has no trustworthy exit status: it needs a positive pass summary and no
|
|
495
|
+
* failure marker anywhere in the output.
|
|
168
496
|
*/
|
|
169
497
|
export function verificationProducedEvidence(cmd: string, output: string): boolean {
|
|
170
|
-
const
|
|
498
|
+
const parsed = parseVerifier(cmd);
|
|
499
|
+
const cores = parsed?.cores ?? [cmd.trim()];
|
|
500
|
+
const text = withoutEchoText(output.replace(ANSI_RE, ""), parsed?.echoed ?? null);
|
|
171
501
|
if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
|
|
172
|
-
const summaries =
|
|
502
|
+
const summaries = text.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
|
|
173
503
|
if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
|
|
174
504
|
}
|
|
175
505
|
if (cores.some(c => /^cargo\s+test(?:\s|$)/.test(c))
|
|
176
|
-
&& ![...
|
|
177
|
-
return true;
|
|
506
|
+
&& ![...text.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
|
|
507
|
+
if (!parsed) return true;
|
|
508
|
+
if (!parsed.trusted) {
|
|
509
|
+
const failed = text.split(/\r?\n/).some(line => !PASSED_TITLE_RE.test(line) && FAILURE_LINE_RES.some(re => re.test(line)));
|
|
510
|
+
return hasPassSummary(text) && !failed;
|
|
511
|
+
}
|
|
512
|
+
return !(cores.some(c => TEST_CORE_RE.test(c)) && !hasPassSummary(text) && NO_TESTS_RE.test(text));
|
|
178
513
|
}
|
|
179
514
|
|
|
180
515
|
/** Marker that prefixes a candidate answer the completion gate never accepted. */
|
|
@@ -185,7 +520,7 @@ export const UNVERIFIED_MARKER = "UNVERIFIED";
|
|
|
185
520
|
* model's last candidate answer, never presents it as accepted, and keeps the gate reason.
|
|
186
521
|
*/
|
|
187
522
|
export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
|
|
188
|
-
const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
|
|
523
|
+
const stop = `Stopped: completion rejected after ${corrections} correction${corrections === 1 ? "" : "s"}. ${rejection}`;
|
|
189
524
|
const answer = candidate.trim();
|
|
190
525
|
if (!answer) return stop;
|
|
191
526
|
return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
|
|
@@ -239,6 +574,11 @@ export interface DoneGateInput {
|
|
|
239
574
|
* instead of claiming files were modified.
|
|
240
575
|
*/
|
|
241
576
|
unmeasured?: string;
|
|
577
|
+
/**
|
|
578
|
+
* Paths a shell call's before/after snapshot saw change (`modified a.json; added b.txt`), when the evidence
|
|
579
|
+
* came from a shell bracket. Lets the model and user judge whether the command or an unrelated process wrote them.
|
|
580
|
+
*/
|
|
581
|
+
shellChanged?: string;
|
|
242
582
|
}
|
|
243
583
|
|
|
244
584
|
/** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
|
|
@@ -263,10 +603,15 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
|
263
603
|
const stale = input.sawVerification && input.verificationStale === true;
|
|
264
604
|
const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
|
|
265
605
|
if (!block) return { state: "done_ok", block: false, message: "" };
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
606
|
+
// A reply alone never clears this gate: only a passing verification does, or the budget running out.
|
|
607
|
+
const escape = "Only a passing verification command clears this check; if none applies, the answer is returned labelled UNVERIFIED once the correction budget is spent.";
|
|
608
|
+
const changedNote = input.shellChanged
|
|
609
|
+
? ` Note: around a shell/computer call the host saw these paths change: ${input.shellChanged}. ` +
|
|
610
|
+
`Something other than your commands may have written them (another process, or your own background job). ${escape}`
|
|
269
611
|
: "";
|
|
612
|
+
const unmeasuredNote = changedNote + (input.unmeasured
|
|
613
|
+
? ` Note: the host could not measure a shell/computer call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change. ${escape}`
|
|
614
|
+
: "");
|
|
270
615
|
if (hookFailing) {
|
|
271
616
|
return {
|
|
272
617
|
state: "done_hook_failing",
|
|
@@ -168,7 +168,21 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
|
|
|
168
168
|
if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
|
|
169
169
|
const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
|
|
170
170
|
if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
|
|
171
|
-
|
|
171
|
+
let source: string;
|
|
172
|
+
try { source = await canonicalSandboxPath(normalizedSource); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
// A leaked/exited managed container whose temp workspace was deleted must not wedge every later launch.
|
|
175
|
+
// Still guard identical-or-disjoint overlap: resolve its nearest existing ancestor (so aliases such as
|
|
176
|
+
// macOS /var -> /private/var compare equal to canonical candidates), and compare lexically when even that
|
|
177
|
+
// cannot resolve (ENOTDIR). Every other failure (EACCES, loops, invalid metadata) still fails closed.
|
|
178
|
+
const lost = (value: unknown): boolean => { const code = (value as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; };
|
|
179
|
+
if (!lost(error)) throw error;
|
|
180
|
+
try { source = await canonicalSandboxPath(normalizedSource, false); }
|
|
181
|
+
catch (inner) {
|
|
182
|
+
if (!lost(inner)) throw inner;
|
|
183
|
+
source = path.resolve(normalizedSource);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
172
186
|
if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
|
|
173
187
|
throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
|
|
174
188
|
}
|