Fixes#849
**Cause:** both `runVega` overflow fakes flood with `"x".repeat(4096)` -
pure ASCII, so `Buffer.byteLength === length` and the `maxOutputBytes:
100` cap trips under either reading. Replacing both
`Buffer.byteLength(chunk, "utf-8")` in `vega-cli.ts` with `chunk.length`
leaves `vega-cli-timeout.test.ts` green 12/12.
**Fix:** flood with 60 `U+2713` instead - 60 characters, 180 UTF-8
bytes. Over the cap on bytes, under it on length, so only a byte-counted
cap still rejects.
Same class: `ndjson-socket`'s dropped-frame `bytes` had the same ASCII
fixture, and asserted it with `Buffer.byteLength` itself, so a
`raw.length` regression passed. Its frame is multi-byte with a literal
expected count.
Left alone: `LogFileWriter.fileSizeBytes` also sums `Buffer.byteLength`,
but its only assertions are `toBeGreaterThan(0)` / `toBe(0)` - the value
is not pinned at all, a wider test gap than this issue.
Tests only - no production code, no tool/CLI/config/flow change, so no
docs update needed.
<details>
<summary>Verification</summary>
All from the worktree root on `@argent/tool-server`, at base
`e353e9bd8`.
Mutation (`stdoutBytes += chunk.length`, `stderrBytes += chunk.length`,
`bytes: raw.length`) with the tests as they stand on `main`:
```
Test Files 2 passed (2)
Tests 18 passed (18)
```
Same mutation, with this change:
```
× reports an unparseable frame with byte length and a sanitised preview, then continues
× rejects + reaps when output exceeds the cap, classified as `subprocess`
× rejects + reaps when STDERR exceeds the cap, classified as `subprocess`
AssertionError: expected 13 to be 15
AssertionError: expected 'timeout' to be 'subprocess'
AssertionError: expected 'timeout' to be 'subprocess'
Tests 3 failed | 15 passed (18)
```
Source restored, everything green:
- `npx tsc --build` - clean
- `npm run typecheck:tests -w @argent/tool-server` - clean
- `npm test -w @argent/tool-server` - `434 passed (434)` files, `6171
passed | 1 skipped`
- `npx prettier --check` on both files - clean
`npx eslint` on the two files could not run locally: it fails resolving
`packages/docs/tsconfig.json` -> `@docusaurus/tsconfig`, a pre-existing
local-install gap unrelated to this diff.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
With #970 no longer under this branch, `main`'s `ScriptLogCapture` is back,
but the split this branch made - `prepare` handing a `ChildRun` to
`runChild` - still carried #970's `StderrSignalWatch` in the run. The
capture was built in one function and read in the other, so the file did
not compile. The run now carries the capture itself.
The three step-run helpers #970 added and this branch's bash tests call
(`markPath`, `markingScript`, `readMark`) come back with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
The `**` placeholder in `entryMatcher` read as a space and was a NUL, so the
file carried a control character into a `RegExp` and `eslint . --max-warnings 0`
refused it with `no-control-regex`. The pattern is now built by splitting on
`**` and `*` rather than by substituting a sentinel, which needs no invisible
character at all. Same two cases, still failing when either glob is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The abort added after `resolveBashInterpreter` was found to hold a step
un-cancellable for about six seconds per candidate was pinned by one case, and
that case calls `pinGlobalConfig` first - so it covers the configured branch and
none of the search. Three deletions left all 517 green:
- the check inside the candidate loop, which is the PATH search every ordinary
install takes. Without it the cancellation is swallowed and the resolver
reports success, or `spawn`/"install bash" on a host where nothing answers.
- the entry check, which makes an already-raised signal cost nothing. Without
it an aborted call replayed is a full probe round per replay, plus the
detached probe children each round leaves.
- `request.signal` in the executor's own call, so nothing asserted the executor
wires it through at all and the `"cancelled" in found` arm was reachable
through no test.
One case each. All three fail on their deletion - 310 ms against 6008 ms and
6016 ms - and the executor one aborts DURING the lookup, because the check at
the top of `runOne` answers a signal already raised and would pass either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The case written for 103a25f94 - the whole-message clamp landing inside the
reason's own marker - could not fail on it, for two reasons.
`pinBash` wrote `<ws>/.argent/config.json`, and `scripts.bash` takes the global
scope alone, so `readScopeValue` returned before that file was read. The 934-
character symlink never reached a message, and the case ran under the host's
ordinary short bash path. It now pins the value in a home of its own, and
asserts the pinned path is IN the message so the setup cannot go inert again.
The arithmetic then pointed the wrong way: `- 50` put the message 50 characters
SHORT of the ceiling, where the premise of the fix is an exit line LONGER than
the room `MAX_REASON_CHARS` reserves for it. `+ 20` passes the ceiling and keeps
the path inside PATH_MAX.
With both, reverting the fix in the runner - the budget back to a fixed
`MAX_REASON_CHARS` and the `budget <= 0` guard dropped - fails the case:
"expected 'The script exited with code 5 (bash: …' to match
/this report keeps the first \d+ chara…/".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
Neither test written for `scripts.bash` taking the global scope alone could
fail on it. Both wrote a valid GLOBAL value alongside the committed project one,
and `merge` is `prioritize-global` - `global ?? local` - so the assertion holds
through the merge whatever `scopes` says, with the project scope fully readable.
`flow-script-interpreter.test.ts` now sets no global value, so the committed
Windows path is the only one there is: read, it would BE the answer, the key
would count as set and there would be no PATH fallback. The search running at
all is the assertion.
`config-access.test.ts` loses "keeps the developer's global pin in front of a
committed project value": with the project scope unread its two lines writing
the file are inert, and what it asserts is what the case two above it already
asserts by writing the project file alone.
All three fail with `scopes: ["global", "project"]` put back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`SCOOP`, `SCOOP_GLOBAL` and `%ProgramData%\scoop` - the override, the
administrator tree and the global Scoop layout - were built by nothing: the one
win32 `fixedLocations` case that could reach them deletes all three before it
asserts, and the Scoop case beside it covers `%USERPROFILE%\scoop` alone. The
`it.each` table now carries a row per rung, on a platform this repo cannot run
unit tests on by default.
The case-insensitive de-duplication had the matching gap. Its only case fed
`ProgramFiles` and `where git`'s answer as byte-identical strings, so a plain
`Set` passed it; the divergent-casing case the `toLowerCase()` is written for -
and which costs the duplicate five-second probe - was pinned by nothing. The
rung is now spelled `c:\program files` against `where git`'s
`C:\Program Files\...`.
All four fail on mutation: dropping the three rungs, and keying the `Set` on the
candidate as written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`readConfigObject` answers an empty document for a file that is absent, one a
`chmod` made unreadable and one an interrupted `updateConfig` left half-written,
so `scripts.bash` read as unset in all four cases. The same file content then
gave opposite outcomes:
readable, bash=/nonexistent/bash -> ok=false "The configured bash … does not exist."
chmod 000, same content -> ok=true notes=[] bash 5.2.37 from the PATH
truncated JSON, same content -> ok=true notes=[] bash 5.2.37 from the PATH
The last two are exactly "a wrong path papered over by a fallback that happens
to exist on this machine" - the outcome the resolver's first rule says it
prevents, and `configuration.mdx` stated to users as a rule.
`configDocumentProblem` in configuration-core says why a document could not be
read; it is additive and changes what no key resolves to. The resolver asks it,
and a step that fell through to the PATH now carries a note naming the file and
the reason. Refusing instead would break every `.sh` step on a host whose
corrupt file never held the key.
The docstring and `configuration.mdx` now separate the two cases: a value that
is READ and unusable errors the step; a document that could not be read at all
is a note.
(The JSON `null` half of this thread was already resolved in 1971840c7 -
`asPresentText` maps `null` to absent, as every other parser in the file does,
so `{"scripts":{"bash":null}}` means unset by decision.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`startedAt` is taken before `resolveBashInterpreter` and the step's own timer is
armed after it, inside `runChild`, so the lookup is inside `durationMs` and
outside `timeoutMs`. It is not the queue either, so `queuedMs` is 0. A step
declared `timeoutMs: 500` measured:
candidate slow only for the version probe durationMs 3398 notes []
candidate that traps SIGTERM and never answers durationMs 6054 notes []
.mjs control durationMs 41 notes []
A `.mjs` step has no such gap - everything between those two points there is a
`statSync` and a `JSON.stringify` - so nothing anywhere said why a `.sh` step
ran seven or twelve times its declared limit.
The step now says so, when the lookup is worth a sentence beside that limit, and
names the remedy the search already has. `flow-yaml.mdx` said the queue was the
one source of an over-run and that the step reports it; it now names both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The window on the candidate's standard output is a window the answer falls out
of, whichever end it is on, because a wrapper prints on both sides of the bash
it runs. The head window was moved to the tail for a wrapper that greets before
`exec`ing bash; a wrapper that RUNS bash and then prints - to clean up, or to
exit with bash's own status - lost the marker off the other end, at exactly
`BASH_PROBE_MAX_CHARS` minus the marker line:
trailing banner 4058 -> pass
trailing banner 4059 -> "is not a bash: running it printed no $BASH_VERSION"
On the search path the same cut is silent rather than an error: the step ran
under the NEXT candidate instead, which on a Mac is Apple's bash 3.2 - the case
`scripts.bash` exists to let an author avoid.
The probe prints the marker alone on a line, so the lines are now read as they
arrive and only the unfinished last line is held, capped at its head. Nothing is
windowed, and no amount of output on either side reaches the answer: measured
PASS at 0, 4000, 4058, 4059, 5000 and 100000 characters, before and after, both
configured and on PATH. The existing wrapper case became two, one per shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`removeExchange`'s `fs.rmSync(dir, { recursive: true, force: true })` and the
sweep's `fs.promises.rm(..., { recursive: true, force: true })` differ only in
being sync and async, so a cause that refused the removal refuses the sweep too.
A script that runs `chmod 500` on its own exchange directory leaves the note
"a later bash step sweeps it" and an `output.json` that is still in $TMPDIR
after the next step's sweep. The note now separates the two causes: one that
clears on its own is swept, and a mode the script put on the directory needs it
removed by hand.
Two lifecycle guarantees stated near here were also asserted by nothing -
deleting the throttle or the `await pendingSweep` left the whole suite green.
Both now have a case: the root is read at most once across four steps inside the
interval, and an abandoned directory is gone the moment `execute` resolves, with
the sweep's own `rm` slowed so that a step which did not wait for it could not
pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`STRICT_UTF8`'s docstring names `toString("utf8")` as the thing it exists to
avoid - "silent, unequal to what the script wrote" - and `readReasonFile` did
exactly that, so the two exchange files had opposite policies for the same
bytes. `printf 'caf\351 unreachable' > "$ARGENT_REASON"; exit 3` reached the
report as `caf<U+FFFD> unreachable`, while the same two bytes in $ARGENT_OUTPUT
were refused. The reason now gets the parallel sentence.
`markReason` had the mirror-image problem. The BYTE read lands on a UTF-8
boundary through `utf8SafeCut`, but the CHARACTER cut after it counts UTF-16
units, so a cut between the halves of an astral character left a lone surrogate
at the end of the message: a reason of 9000 U+1F600 came back with
`isWellFormed()` false, carried through `JSON.stringify` as `\ud83d` and turned
into U+FFFD by any UTF-8 write of the report.
Both pinned in `flow-script-bash.test.ts`, and both cases fail against a runner
copy with the two changes reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`commitOutput` is the whole of a `.sh` document's validation - the document is
JSON text, so it never meets the runner's `walk` - and two values `walk` refuses
reached flow state unchecked.
A number JSON can spell and JavaScript cannot hold. `printf '{"n":1e999}'`
parses to `Infinity`, and the step passed carrying a value every later encode
turns into `null`, while the same `.mjs` document was refused with "output
numbers must be finite". Both interpreters now answer with that sentence.
Depth. The 1 MiB cap does not bound it - nested arrays cost two bytes a level -
and `renderOutput` in `flow-add-script.ts` is a bare `JSON.stringify` reached
AFTER the step has been appended to the flow file, so an over-deep document made
the recorder write the step and then die with an uncaught `RangeError`.
`MAX_OUTPUT_DEPTH` is 4096, measured to sit in the gap between two stack-derived
ceilings on Node 20, 22, 24 and 26: the runner's recursive `walk` gives out at
~3450-3925, so no `.mjs` document that reaches this file is newly refused, and
V8's `JSON.stringify` gives out at ~6100 up to Node 24. A number rather than a
try/catch around a trial encode, because Node 26 encodes any depth a 1 MiB
document can reach, and a verdict that follows the host's Node is one a flow
file cannot be written against.
`findOwnProtoKey` becomes `documentProblem`, one iterative pass for all three
rules. The deep-document case in `flow-script-protocol.test.ts` now runs at 4000
- still past the recursive-walk ceiling it was written for - and a second case
pins that a 20 000-deep document from a mismatched runner is a verdict, not a
throw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The filter's own comment states the rule - "editing one changes what this job
runs without touching any file listed above it" - and naming files one by one
left 18 of the 33 files the three `.sh` test files import outside it. Two of
those decide a Windows outcome: `configuration-core/src/paths.ts` holds the
`USERPROFILE` branch `flow-script-interpreter.test.ts` asserts the global config
path against, and `registry/src/file-inputs.ts` is where
`SCRIPT_FILE_NAME_PATTERN` gained `sh`, so the change that admits `.sh` steps at
all could be edited with this job unrun.
Both packages are small and wholly inside that graph, so each is taken whole.
`utils/capability.ts` joins the named files.
A new test walks the same graph and holds the filter to it, so the list cannot
rot again; it also pins that the three files carrying `taskkill /t` are named.
Dropping either glob fails it. Its scope is the three `.sh` files this job was
extended with - the seven other tests the job runs reach most of the tool server
between them, which the filter never claimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The executor passes the realpath'd file to bash (`realPathOrSelf`), and this
same file endorses symlinked script paths 21 lines earlier - so for exactly the
shape it calls fine, the idiom names a directory the author did not write. Same
script body, run directly and through `linkdir/link.sh -> ../realdir/seed.sh`:
direct : BASH_SOURCE0=…/realdir/seed.sh dirname=…/realdir
symlink: BASH_SOURCE0=…/realdir/seed.sh dirname=…/realdir
so `"$(dirname "${BASH_SOURCE[0]}")"/fixture.json` beside `linkdir/link.sh`
reads from `realdir/`. The reference page makes no such claim; this is the copy
the flow-authoring agent reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The bullet reads as a property of `kill 0` and is false on the bash the same
section tells macOS readers they have. Five runs each of `trap 'kill 0' EXIT` +
`sleep 30 &` + a written document, through `runFlowScriptStep`:
/bin/bash 3.2.57 {"pass": 5} output={"wrote":"document"}
/opt/homebrew/bin/bash 5.2.37 {"fail": 5} "process group was sent SIGTERM"
So a reader on a stock Mac was warned off a pattern that works for them, and the
advice they need on 5.x reads as one they can ignore. Both copies now name the
split, and the reason to signal each job's own pid is that the flow behaves
differently per host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
Four sentences described a symptom the author cannot see, or a rule the code
does not apply. Each was driven through `runFlowScriptStep`.
- "Argent puts the document the flow holds into that file" - `flow-script-step.ts`
passes `output: {}` on every step, so the file is always seeded `{}`. A script
that reads it back gets `{}`, and a script that writes nothing leaves the seed
and passes. The executor docstring that rested on the same claim says so too.
- The section's only copy-paste example used `$id`, which nothing defines and
the allowlist strips, so run as printed it passed while writing
`{"order":{"id":""}}`. It now defines its own value and opens with
`set -euo pipefail`, which neither copy mentioned anywhere.
- "A command that is absent from that PATH exits 127" holds for the last line
alone: Argent runs `bash <file>` with no `-e`, so
`definitely_not_a_command_xyz` followed by `echo done` PASSES the step with an
empty document. With `set -euo pipefail` it fails on 127.
- "The usual CRLF symptom is `$'\r': command not found` and exit code 127" -
Argent discards what the script prints, so that line never reaches the author,
and the section's own example under CRLF exits 1, not 127.
The skill copy also gained the stray-carriage-return case its exit-0 list left
out, the one the reference already carried.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`flow-script-step.ts:100` is `hasScriptExtension(canonical) ? canonical : target`
and `hasScriptExtension` is true for `.mjs` and `.sh` alone, so the target
decides only when it carries one of those two - not "the extension of the file
the path resolves to". Every other target extension keeps the step's spelling,
which both copies left uncovered, and the skill copy put the sentence directly
after "Argent refuses `.bash` and `.js`" so a reader predicts a refusal for the
rows that run.
Four symlinks through `runFlowScriptStep`:
h.sh -> real.bash runs under bash 5.2.37
k.sh -> real.py runs under bash 5.2.37
j.mjs -> real.js runs under Node
i.mjs -> real.bash Unknown file extension ".bash" for …/real.bash
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
Both hard-coded the bash 5.x verdict, so on a stock Mac - Apple's `/bin/bash`
3.2.57, the interpreter the POSIX fallback list exists for - the file was 65/67
with `expected undefined to be 'exit'`, which reads as a source regression. No
job sees it: unit tests run on ubuntu, the macOS jobs run no unit tests, and the
Windows job runs Git bash 5.x.
They now assert the way their SIGQUIT sibling already does. The single case
takes the document when the step passes and the `kill 0` guidance when it fails;
the repeated case asserts ONE verdict over 120 runs and never `signal`, which is
the race it was written for rather than the name of the winner.
Verified under both: `PATH=/usr/bin:/bin` (3.2.57) 67/67, ordinary PATH
(5.2.37) 67/67. Both still fail on mutation - dropping the held-signal branch
gives `kind: signal`, and reporting half of them as `signal` gives
`kinds: ["exit","signal"]`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`bashOutcome` never reads `$ARGENT_OUTPUT` on the held-signal path, so "the step
returned no output document" was a claim about the file rather than a reading of
it - and it is false whenever the script finished its write before the group
signal arrived. A step that writes the document, then runs `trap 'kill 0' EXIT`,
reports that sentence while the file on disk holds `{"order":{"id":"ord_1"}}`.
The message now says what the STEP does: it fails on the signal and reports no
document, whatever the script had already written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`^(\d+)-` gives `Number` a non-negative integer, so a digit string that fails
`Number.isSafeInteger` is at least 2^53 (9.0e15) and always greater than
`Date.now()` (1.79e12). The caller skips `undefined` and "in the future" through
the same `continue`, so the guard could not change an outcome. A comment now
records why no range check is owed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`scopes: ["global"]` makes `readScopeValue` return before a project
`.argent/config.json` is read, so the mixed-OS-team case four comments give as
their reason cannot happen through that file any more. The reason that survives
is a global file that travels: a home directory restored onto a machine of
another family carries a spelling that host refuses.
- `flow-script-interpreter.ts` said the filesystem checks are read-side because
a project file is shared by hosts that do not all have it.
- `config-schema.ts` said `scripts.bash` "takes both scopes", 45 lines above
`scopes: ["global"]`.
- `config-access.test.ts` said "the key lands in a project file a mixed-OS team
shares".
- `flow-script-interpreter.test.ts` said "The resolver reads `scripts.bash` from
BOTH scopes", where the same file says the global scope alone at :67 and in
its describe title; it also carried the last "this PR" in the diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
The header said the extension picks the interpreter and that every control
applies to both. Neither holds: `execute` reads `request.interpreter` and
defaults to `node`, so a `.sh` with no `interpreter` reaches the Node loader
("Unknown file extension \".sh\""); and `--max-old-space-size` is a flag on the
child Node process, so under bash it bounds the runner and not the script - a
`.sh` step allocating a 300 000 000-character variable passes a 32 MiB limit
that fails the equivalent `.mjs`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`scripts.bash` was the one key in `CONFIG_SCHEMA` taking project scope while
being host-specific. Its `expected` requires the value be "spelled the way the
host running the tool server spells one", and `asAbsolutePath` enforces that
against `process.platform` - so a committed `.argent/config.json` could not
satisfy both for a team that is not all on one OS family.
On macOS, with a project config a Windows teammate committed
(`{"scripts":{"bash":"C:\\Program Files\\Git\\bin\\bash.exe"}}`), through
`argent flow run`:
✗ 1 script prove.sh — The configured bash (scripts.bash = C:\Program
Files\Git\bin\bash.exe, from …/.argent/config.json) is not an absolute
path. Point scripts.bash at a bash executable, or unset it to use the one
on this host's PATH.
Every `.sh` step errored, and there was no PATH fallback because the value was
set. `merge: "prioritize-local"` made it worse: the committed value shadowed
the developer's own valid global pin, and the message then named the working
bash it was declining to use. Editing the shared file was the only local fix,
and it un-fixed the teammate who committed it - `config set scripts.bash
'C:\...' --scope project` is refused on macOS, and the POSIX spelling is
refused the same way on Windows, so neither developer could write the other's
value into the file they share.
The key now takes the global scope alone, like `scripts.maxTimeoutMs` and
`scripts.heapLimitMb` above it. `readScopeValue` gates READS on `scopes` as
well as writes, so a committed value is not merely unwritable - it is unread,
and the resolver falls through to its PATH search rather than refusing the
step. Each developer pins their own bash.
That leaves one scope for the resolver to read, so the machinery that existed
to serve two goes with it: `resolveBashInterpreter` no longer takes an anchor
it cannot use, `configuredSource` and `unsetAdvice` are gone, and the message
names the global file and offers PATH - advice that is now always true.
Four behaviours confirmed through `argent flow run`, on an isolated
tool-server and home so the shared ones were untouched:
committed project value PASS (ignored, falls through to PATH)
wrong global pin errors, names the global file, no PATH fallback
good global pin honoured
set --scope project "cannot be set at project scope (allowed: global)"
Full tool-server suite 6277 passed, matching the pre-change baseline;
configuration-core 152; argent-cli 615; lint, knip and the docs build clean.
Three comments on the `taskkill /t` branch narrated the diff that added it
rather than the code:
watchdog-deadline.mjs "the self-kill below used to be the whole reach —
watchdog-lifeline.mjs leaving bash, and a `.mjs` script's own
subprocesses, running"
runner.mjs "neither of which anything reached there before"
Read by someone who never saw that diff, all three are statements about a
Windows behaviour that no longer exists anywhere in the tree. Each now says
what the self-kill on its own would reach, which is the same fact in the
present tense and is what makes the `taskkill` call worth its lines.
Comment-only: all three files emit byte-identical code through the TypeScript
printer with `removeComments`. The path they describe still holds on POSIX
through `argent flow run` - a `.sh` step with a background job, timeout 3000,
errors "its process tree was stopped" at 3 s and leaves no stray `sleep`
behind. The `taskkill` branch itself is Windows-only and unreachable here.
The skill reference stated the rule as "It needs no device." with no exception,
while `flow-yaml.mdx:280` states it with the half that constrains placement
attached: "A `run` step or a `when` step beside it makes the flow resolve one
again, so a script inside a `when: { platform: ios }` block needs a booted iOS
device."
This is the copy the flow-authoring agent reads when it decides where to put a
script step, and it was the copy missing the constraint. A `.sh` step written
inside a `when:` block on the strength of that line does not run.
Both shapes through `argent flow run`, same script, same project:
bare script step device: "", PASS
same step in when: { platform: ios } "not run (no device resolved)"
The wording follows the page's existing voice - short sentences, one rule each
- rather than quoting the reference verbatim.
`BASH_PROBE_FORCE_GRACE_MS` justified itself with "this lookup runs BEFORE the
fork, so the step's own time limit has not started and the request's abort has
nothing to interrupt". The second half is not true of this file.
`askForBashVersion` takes a `signal_` and wires `onAbort` into it, and the
comment beside that listener says the opposite in the same words: "The abort
the request carries, which this lookup is the one place a `.sh` step can wait
before it has a process to time out." `flow-script-executor.ts` passes
`request.signal` into the resolver for exactly that reason.
So the two comments disagreed about whether the probe is interruptible, and
this was the stale side - the one a maintainer reads when deciding whether the
constant still has to exist. On its account the abort wiring 230 lines below is
dead weight.
The grace does still have to exist, and for a reason the abort does not cover:
a request nobody cancels has nothing else to end a candidate that ignores
SIGTERM. Confirmed through `argent flow run` against a `scripts.bash` that
traps SIGTERM and never answers - the step errors "was stopped by SIGKILL"
after 6 s, the probe's 5 s timeout plus this 1 s grace.
`commitOutput` reported a parse failure as `The script's output did not parse:
${errorMessage(err)}`, and V8's `SyntaxError` embeds about ten characters of
the offending document verbatim, mid-sentence:
The script's output did not parse: Unexpected token 's',
"{"auth":s3cr3t-tok"... is not valid JSON
On the `.mjs` path that branch is not reachable by ordinary script behaviour -
the runner's `encodeOutput` always emits valid JSON. On the `.sh` path any
script that writes non-JSON to `$ARGENT_OUTPUT` lands there, and what it wrote
is script-controlled text, so this branch adds the reachability.
The excerpt also defeats the redaction that runs afterwards.
`scrubSecretValues` matches whole values, so the prefix of a secret the
excerpt cut in half matches nothing, and `redactTruncated` repairs a cut
marked at the END of the message - by the runner - not one V8 made in the
middle of it. The same secret on the `.mjs` path comes back
`{"auth":"{{secret:API_KEY}}"}`.
Every double-quoted run is dropped from the message rather than the exact
sentence matched: the wording is V8's and is not a stability contract, but the
quoting is where a document's own bytes are, and the rest of that family
(`… at position 6`, `Unexpected end of JSON input`) quotes only with
apostrophes and survives unchanged. The author has the file; a ten-character
excerpt of it was worth nothing to them.
Reproduced through `argent flow run`: before, the message carried
`s3cr3t-tok`; after, `Unexpected token 's' is not valid JSON`.
The docstring reasoned that "the docs teach writing a sibling and `mv`-ing it
into place, and a `mv` replaces the inode - so the document Argent reads back
carries the script's own umask". No page taught it: `$ARGENT_OUTPUT` had zero
occurrences across `packages/docs` and `packages/skills`, so neither the
pattern nor the variable it applies to was written down anywhere, and a reader
who instead redirected straight into `$ARGENT_OUTPUT` hit the empty-file
failure the runner reports.
The reference now carries both (see the `$ARGENT_OUTPUT` commit). The
docstring names that page rather than "the docs", and says why the pattern
exists, and a test in `flow-skill-docs.test.ts` fails if either the reference
or the flow-authoring skill stops teaching it - which is what the mode on the
document rests on.
`flow-yaml.mdx` stated "A background job ends with the step" with no platform
qualifier, and the skill copy stated the mechanism outright - while the bullet
directly above it does call Windows out where behaviour differs.
On Windows there is no process group, and nothing on the ordinary pass path
reaps the script's background jobs. `runChild` awaits the child's exit and only
then calls `stop()`, so `child.exitCode` is already non-null when
`stopProcessTree` runs; its win32 branch begins `if (hasExited(child)) return`,
so `taskkill /t` is never spawned - and `taskkill /t` walks the LIVE
parent-child tree, so once the runner is gone there is nothing to walk from
either. The three `taskkill` sites on this branch fire only when the tool
server dies, when the deadline passes, or when the IPC channel drops; a passing
step reaches its verdict through the runner's own exit and none of them runs.
The docstring on `stopProcessTree` already says this; the reference did not.
So a Windows author following the docs leaked one process per run for the
ordinary `npm start &` / `adb logcat &` pattern, and the one in-script remedy
they might reach for, `trap 'kill 0' EXIT`, is forbidden by the same bullet.
Both surfaces now scope the guarantee to macOS and Linux and say what Windows
does instead.
"A job survives only after `setsid`, and Argent never stops a `setsid` job" was
not true, and the escape it named is unavailable on the platform the page is
most read on.
Containment rests entirely on the process-group kill, and `set -m` opens that
group with one line of ordinary script: a job-control bash puts every `&` job
in a process group of its own, so it is no longer in the group anything kills.
Two scripts differing only in that line, through the executor:
plain step=pass jobPid=49168 aliveAfterStep=false
jobctl step=pass jobPid=49178 aliveAfterStep=true
`set -m` is the standard way to get `fg`/`bg`/`wait -n` semantics in a
non-interactive script and a common entrypoint-script idiom, and it was not
mentioned. Meanwhile the one survival route the page did document does not
exist here: `command -v setsid` finds nothing on macOS 26.5.
The bullet now names the mechanism, both ways out of it, and where `setsid` is
missing; the `kill 0` warning becomes a bullet of its own rather than a tail on
a sentence about something else. Same change in the flow-authoring skill's
copy, which stated the same guarantee.
The pass rule was stated as the exit code alone, and `$ARGENT_OUTPUT` had zero
occurrences across `packages/docs` and `packages/skills` - while its sibling
`$ARGENT_REASON` was documented in both. The variable is not an internal
detail: the output document is half the step contract, and `flow-add-script`
hands `outputJson` back to the agent.
So an author who wrote to it and then read "the file named by $ARGENT_OUTPUT
is empty" had nothing to look up: nothing said the variable exists, that it
arrives seeded with the flow's own document, that Argent compares what it
reads back against that seed, or that it is capped.
The runner has four exit-0 paths that fail the step on the document - an empty
file, a missing one, an irregular one, and one that is not valid UTF-8 - plus
the stray-carriage-return detector, which is the silent CRLF symptom the
existing bullet does not cover (it names exit 127). All of them are written
down now, with the sibling-and-`mv` pattern that avoids the first, in the
reference and in the flow-authoring skill's copy.
Two sentences in the `script` section did not get extended when the section
did.
"The extension selects the interpreter" is not what `flow-script-step.ts`
does: it resolves the path first and reads the extension off the CANONICAL
(realpath'd) file, falling back to the step's own spelling only when the
canonical path carries no known extension. For a symlinked script the target's
extension decides, and the spelling in the flow file - the only thing a reader
of that line can see - does not.
"The script also runs under the Node binary of the tool-server, not the binary
on the `PATH` of your shell" is false for a `.sh`, and it is the one sentence
in the section that survived unchanged from the Node-only version, so it is
the one a reader would trust for the difference between the two languages.
With a stub `node` first on the tool server's PATH:
.sh => {"which":"…/stubnode/node","says":"I AM THE STUB NODE"}
.mjs => {"execPath":"…/.nvm/versions/node/v26.7.0/bin/node"}
Both are now stated per language, in the reference and in the flow-authoring
skill's copy.
`resolveBashInterpreter` was awaited before `deadlineAt` and the timeout were
armed, and with no signal plumbed into it. Each candidate costs up to
`BASH_PROBE_TIMEOUT_MS` (5 s) plus `BASH_PROBE_FORCE_GRACE_MS` (1 s), and the
resolver is deliberately not memoized - so the step's declared time limit
bounded nothing until after the probe, and an abort was not observed for about
six seconds per step. A flow of N bash steps was un-cancellable for roughly 6N
seconds, which matters against a 30 s MCP client budget.
The request's signal now reaches the probe: an abort stops the candidate at
once and the resolver answers `cancelled`, which the executor reports as the
cancellation it is rather than as a bash that would not answer.
`askForBashVersion` also stops the candidate's whole group on POSIX rather
than the candidate alone. A shim that backgrounded a job left it re-parented
to pid 1 and running after `execute()` returned - the module docstring says
this lookup "costs a few milliseconds against a step that already starts a
process", which is exactly the case it did not cover.
Measured with `#!/bin/sh` + `trap '' TERM` + `sleep 297 &` + `sleep 300` as
the configured bash, aborting at 300 ms: before, `execute()` returned after
6004 ms and the `sleep 297` was still running; after, 302 ms with the
cancelled verdict and nothing left behind. The probe-timeout path leaves
nothing behind either.
`sweepStaleExchanges` ran a `readdirSync` on the tool server's main thread -
it is called synchronously from `createExchange`, which `runOne` calls on the
bash path - and in production its root is `os.tmpdir()`, a directory shared
with every other process on the host and bounded by nothing here.
The 60 s throttle bounds how often the read happens, not what it costs. On
this machine a root holding 60 000 entries cost 33 ms of blocked loop per
sweep: no MCP request, device socket or timer ran during it, and the step's
own wall time went from 53 ms to 89 ms. The stall grows over a machine's life,
since the directory it reads is one the tool server never prunes.
Awaiting a `readdir` is not the fix - the read is scheduled off-thread either
way, and it is building the array of every name that blocks. The sweep now
walks a directory handle in batches of 64, which hands back a little work per
turn, and it runs beside the step rather than in front of it; `runOne` waits
for it at the end, so a step never outlives its own sweep.
Measured with the same rig, one bash step against a 60 000-entry root:
before step 89 ms, worst blocked loop 33 ms
after step 71 ms, worst blocked loop 0 ms
(an empty root is 53 ms / 1 ms either way)
Pinned by a case that plants 40 000 entries and watches a 5 ms heartbeat:
21-22 ms of block before, 0 after.
The comment added beside the two test helpers states the rule the filter is
meant to satisfy - "editing one changes what this job runs without touching
any file listed above it" - and applied it to the helpers but not to
`packages/configuration-core`, which is in the real import graph of all three
newly-listed test files.
That package is not incidental to Windows here: this branch puts
`WINDOWS_ROOTED_PATH_RE` and the win32 arm of `asAbsolutePath` in
`config-schema.ts`, and `flow-script-interpreter.ts` reads that same regex
back - the comment beside it calls it "the one rule, shared with the tool
server's own interpreter check". So a pull request whose only change was to
that shared rule touched none of the listed paths, skipped this job, and was
never exercised against a real Windows host, a real `where.exe` or a real
Git-for-Windows `bash.exe` - though three of the tests this job was extended
to run exist to pin exactly that rule.
`config-access.ts` and `utils/{electron-env,format-error,secrets,timing}.ts`
are in the same graph and are added with it.
Removing the `Math.ceil` left all 2165 tests in `packages/tool-server/test/
flows/` green, so a future simplification there was silent in CI.
The fractional value really reaches that line: `flow-script-step-parse.test.ts`
pins `timeout: 1500.5` as a legal step, and `clampTimeout` returns a wanted
value at or under `maxTimeoutMs` un-rounded. What the guard prevents is what
the comment above it already describes - the directory is named
`argent-flow-script-<n>.5-XXXXXX`, `exchangeOwnedUntil`'s `/^(\d+)-/` cannot
match it, the sweep passes over it forever, and an exchange whose owner could
not remove it keeps an `output.json` that may hold values derived from a
secret under the shared `os.tmpdir()`.
The new case runs a step with `timeoutMs: 1500.5` and has the script report
its own exchange directory - otherwise removed before anything outside the
executor could look at it - then reads the stamp back the way the sweep does.
Removing the `Math.ceil` now fails it.
The fallback the five-line comment above it exists for was pinned by nothing:
replacing the whole expression with `scriptInterpreter(canonical)` left the
entire flow-script suite green, and no test in it used a symlinked script path
in either direction.
The case is reachable. With `scripts/seed.sh` a symlink to an extensionless
`tools/seed`, `resolveFlowRelativeFile` realpaths it, `hasScriptExtension` is
false, and only the step's own spelling can answer - so the regression the
fallback guards against is a passing bash step turning into a `load` failure
with a stack pointing into a shell file.
Both directions are pinned now: a `.sh` link to an extensionless file runs
under bash, and a `.sh` link to a `.mjs` runs under node. The mutant fails the
first.
`GROUP_SIGNAL_SETTLE_MS` is 1000 ms and was weighed against exactly one other
number, the parent's 1500 ms stop grace. It was never weighed against the
step's own time limit, whose legal floor is `MIN_SCRIPT_TIMEOUT_MS` = 100 and
whose entire range up to about 1050 ms sat below that settle.
So when bash died from SIGTERM, SIGINT or SIGHUP without the same signal
reaching the runner, `whenGroupSignalHeld` parked for the full second; the
executor's timer fired first, sealed the interruption and discarded the
terminal message that was already correct, and `classifyOutcome` fell through
to `timedOut`. The step was reported as a time limit that was never exceeded,
about the one fact that explains the failure - and the `.mjs` twin does not
behave this way, because this wait is on the bash path alone. No step-level
`timeout` was needed to reach it: `scripts.maxTimeoutMs` alone did.
The request now carries the parent's own limit, and the wait takes what is
left of it minus the room the verdict needs to travel back. A limit too short
to spare any of it reports at once, which is what the signal alone would have
said.
Measured through the executor with `kill -s TERM $$; sleep 30`:
before {100,timeout,177ms} {500,timeout,562} {900,timeout,965} {1200,signal} {3000,signal}
after {100,signal,64ms} {500,signal,292} {900,signal,695} {1200,signal} {3000,signal}
The `kill 0` group case still classifies as `exit` at the default limit, where
the settle is what it was.
"the pre-2.7 shape" names a version this project has never had:
`packages/tool-server/package.json` is at 0.24.0 and the newest tag in the
repository is v0.9.0. The rule the comment describes is real and the default
is right; the version it hung the rule on gave a reader nothing to check it
against, and no way to know when the shim stops being needed. It now names the
change instead - the shape sent before bash mode existed - and says what would
retire it.
The docstring claimed "a mixed-ending script is the only kind that ever gets
here: a fully CRLF one dies at `set -euo pipefail\r` with exit 2". Neither
half holds.
A fully CRLF script dies there only if it HAS that line. Without one it runs
to completion and leaves exactly the stray sibling this function looks for:
$ printf 'echo hi > "$OUT"\r\n' > noexit.sh
$ OUT=out2 /opt/homebrew/bin/bash noexit.sh ; echo "exit=$?"
exit=0
$ ls -b .
out2\r
The exit code is the bash's own too: the same `set -euo pipefail\r` script
exits 2 under GNU bash 5.3 (`set: pipefail: invalid option name`) and 1 under
the Apple bash 3.2 the docs point macOS users at.
The comment now says both, and a wholly CRLF script that runs to the end is
pinned beside the mixed-ending one.
`carriageReturnProblem` iterated both exchange files, but its only call site
was the exit-0 branch - and the non-zero exit branch is the only one that ever
reads `$ARGENT_REASON`. On a CRLF script, `echo "…" > "$ARGENT_REASON"` writes
`reason.txt\r`, `readReasonFile` reads the empty `reason.txt` the parent
seeded and returns "", and the step reported the bare exit line.
So the author whose script did explain itself got nothing - no reason text, no
note, and no CRLF hint, since `exitCodeHint` names CRLF only for 126 and 127 -
while the identical stray file on the exit-0 path produced a full remediation
message. This is the platform `windows-e2e.yml` is being added for, and CRLF
is git's default checkout there.
The exit path now asks the same question when it read no reason, and says what
the stray file cost: the explanation the script wrote. Reproduced through
`argent flow run` with a CRLF script that writes its reason and exits: before,
`The script exited with code 2 (bash: …).` and nothing else; after, the same
line followed by the CRLF remediation.
The marker was built from `cut` - the budget - rather than from how much text
survived. `readReasonFile` decodes the first `maxBytes`, trims, and then takes
the truncation path whenever the file held more, regardless of how long the
trimmed text is. So when leading whitespace consumed the head, `text.slice(0,
cut)` yielded far fewer characters than `cut` and the marker still reported
`cut`.
A step writing 25 000 spaces, 5000 letters and exiting 9 reported "keeps the
first 7168 characters" over 3672 letters; one writing 100 000 spaces and
nothing else showed no reason text at all and still announced 7168, so its
author went looking for a lost report rather than for a blank reason file.
The same number is what `REASON_KEPT_RE` and `redactTruncated` read back.
The cut now starts at the length of the text there is, so the count is the
head the report really carries. Reproduced through `argent flow run`: before,
"keeps the first 7168 characters"; after, "keeps the first 3672" and "keeps
the first 0".
The same budget was counted in two different units across the exchange: the
runner bounded what it read from `$ARGENT_OUTPUT` in FILE bytes, and the
parent bounded the document it accepts in post-decode UTF-8 bytes. Those agree
only for text that survives decoding unchanged, and `toString("utf8")` turned
every invalid sequence into a three-byte U+FFFD - so a file the runner
accepted at exactly the limit was over it by the time the parent measured, and
the step was refused for a size the script did not write.
Decoding strictly is what closes it: a document that decodes at all decodes to
exactly its own byte count, so the two bounds are the same number. A document
that does not is now refused for what is wrong with it.
Pinned by a case that writes exactly `SCRIPT_MAX_OUTPUT_BYTES` of file, every
padding byte invalid on its own. Before: "The script returned 3.0 MiB of
encoded output; the limit is 1.0 MiB." After: "not valid UTF-8", and no
mention of a limit.
`toString("utf8")` is lossy: an invalid byte sequence becomes U+FFFD. Nothing
downstream re-validated - `commitOutput` in the executor checks the size,
`JSON.parse`, object-ness and an own `__proto__`, none of which notices a
substituted character - so bytes the script wrote were silently rewritten and
the step was a pass.
A step running `printf '{"token":"\xc3\x28abc"}' > "$ARGENT_OUTPUT"` came back
`{"ok":true,"output":{"token":"�(abc"}}`. The corrupted value flowed into
flow state for later steps to read and compare against, with no failure, no
note, and nothing that lets the author see the document differs from what the
script emitted. A `.mjs` step cannot reach that state, because there the
document is a live JavaScript value - so this was a difference between the two
languages the step result did not surface.
The read now decodes with a fatal `TextDecoder` and reports the bytes rather
than rewriting them. Reproduced through `argent flow run`: before, `✓ 1
script`; after, `✗ … the document the script wrote to $ARGENT_OUTPUT is not
valid UTF-8`.
`GROUP_SIGNALS` held SIGTERM, SIGINT and SIGHUP, and the comment called it
"the signals a script can aim at its own process group". SIGQUIT is the
omission where bash and Node disagree in the direction that matters: GNU bash
5.x ignores SIGQUIT in a non-interactive shell, and Node's default kills on
it.
So a script whose body or EXIT trap ran `kill -QUIT 0` killed the runner and
left bash running - the failure `holdGroupSignals` exists to prevent. With no
terminal message the parent fell to the signal row of `classifyOutcome` and
reported "The script process was killed by SIGQUIT before it returned output.
It did not stop itself." Both halves were false: bash was not killed, it
ignored the signal, and the script's own line is what killed the runner. Its
sharpest spelling was `trap 'kill -QUIT 0' EXIT` on a script that had already
written a complete `$ARGENT_OUTPUT` and exited 0 - reported as a fault of the
machine it ran on, its document discarded.
The verdict now follows the bash: 5.x survives and the step passes on the
document it wrote; Apple's 3.2 dies on SIGQUIT and the step reports the same
`kill 0` guidance the SIGTERM case does.
Reproduced through `argent flow run`. Before: `✗ … killed by SIGQUIT … It did
not stop itself`. After, under bash 5.3: `✓ 1 script`, the background job
reaped. Under `scripts.bash = /bin/bash` (3.2): `killed by SIGQUIT … A
`kill 0` in the body of the script ends bash the same way`.
The helper swapped `HOME`/`USERPROFILE` for a directory of its own before
asking the resolver, and restored them before returning. None of the six files
it guards does the same, so every `.sh` step below reached
`resolveBashInterpreter` under the developer's real home and did read
`~/.argent/config.json` - the outcome the swap was written to prevent. The
gate therefore answered "this host has a bash" about a bash the steps never
run under.
Measured on macOS with `/opt/homebrew/bin/bash` 5.3 on PATH, changing only
`~/.argent/config.json` between runs of `test/flows/script/`:
no config 500 passed
{"scripts":{"bash":"/nonexistent/bash"}} 64 failed | 436 passed, 0 skipped
The second row is the case the helper exists for, and it failed instead of
skipping: the failures read as source regressions (`expected 'error' to be
'fail'`) rather than as a machine problem. The swap itself suppressed the
skip - called the way the steps call it, the resolver answers "The configured
bash … does not exist".
The gate now asks the resolver exactly the way a step does: same environment,
same home. The same run is 429 passed | 71 skipped, and an ordinary valid pin
is exercised by the steps and reported by the gate as the same bash.
The probe's `spawn` passed no `env`, so the candidate inherited the tool
server's whole environment. The bash that actually runs the step does not:
`buildChildEnv` copies only `ALLOWED_ENV_NAMES`. The divergence ran both ways.
`BASH_ENV` is the one variable that changes what a non-interactive `bash -c`
does, and it is deliberately outside that allowlist. So a host that exported
it had every `.sh` step refused before the fork, reporting that no bash exists
- on a host with three - and the remedy the refusal names goes through the
same probe, which answered "is not a bash" about a bash 5.3 that answers
correctly when asked without that variable.
In the other direction the candidate is an arbitrary executable named `bash`,
and inheriting handed it `ARGENT_AUTH_TOKEN`, `ARGENT_PORT` and every
`ARGENT_SECRET_*` value - the three things the comment above
`ALLOWED_ENV_NAMES` says the allowlist exists to keep out of a script's reach.
The resolver now takes the environment to probe in, and the executor passes
the step's own. The test helper that gates the bash suites passes the same one
through `buildChildEnv`, which is now exported for it.
Reproduced through `argent flow run` with a shim first on the resolver's path
that records what it was given. Before: BASH_ENV set errored every step with
"No bash was found on this host", and the probe run saw 3 argent
token/port/secret names against the step run's 0. After: the step passes with
BASH_ENV set, and both runs see 0.
"or unset it to use the one on this host's PATH" was true only where exactly
one scope held a value. The key takes both with `merge: "prioritize-local"`,
so unsetting the file the message had just named falls through to the OTHER
scope's value, not to PATH.
With a project `.argent/config.json` of `/bin/ls` over a global one of
`/bin/bash`, following the printed advice silently swapped the interpreter to
the global value on a host whose PATH bash is a different one - and a step
that then fails does not mention `scripts.bash` at all, so nothing points back
at the value now in force, which is the situation this key exists to make
visible.
The refusal now reads "unset it there to fall back to /bin/bash, which
<global file> sets - unset it in both files to use the one on this host's
PATH" when a value is behind it, and is unchanged when none is. Reproduced
through `argent flow run` with exactly those two files.
The refusal explained a leading backslash, and the values that reach it most
often begin with a forward slash: `path.win32.isAbsolute("/usr/bin/bash")` is
true, so a POSIX path passes the check above and lands here. The comment on
`WINDOWS_ROOTED_PATH_RE` names exactly that case as the reason the write gate
exists - `argent config set` stored a POSIX path that every `.sh` step then
refused - so it is the expected input to this branch, not an edge case.
The sentence now says "begins with a slash or a backslash", which is the same
mechanism for both: on Windows either spelling is rooted on whatever drive the
process is on. Pinned by both spellings.