Repository navigation
fix(workspace): refuse a glob brace form that can't be read, rather than hang or misread it - #439
Merged
nissa-seru merged 1 commit intoOct 17, 2026
Conversation
…han hang or misread it
globToRegex sent a `{` with no closing `}` on to its chunk loop, which
stops at a `{`, so `i` never moved and the conversion never finished.
workspace--glob with `src/{a,b`, or workspace--grep with that `glob`,
never answered, and the process handled nothing else while it spun: no
other tool call, timer or event. The loop dates to 24d2a4e. Two other
brace forms were misread with no error. A group closed at the first `}`
after its `{`, so `{a,{b,c}}` became `(?:a|{b|c)}`, which matches `a}`,
`{b}` and `c}` and none of `a`, `b` or `c`. And a group with no `,` was
read as a choice of one, so its braces vanished: `{name}` matched `name`
and never a file named `{name}`, and `{1..3}` matched only `1..3`.
The brace branch now finds a group's `}` by depth, at the `}` that
brings the braces open since its `{` back to none, so a `{` that never
closes is named as that even with a closed group after it, as in
`src/{a,b/*.{ts,tsx}`. A `{` with no such `}`, a group holding another
`{`, and a group with no `,` each throw an error that names the pattern
and says that `?` in place of a `{` matches one in a name, since the
glob has no escape. handleToolCall's catch answers the throw as a failed
tool result. Reading an unclosed `{`, or a group with no `,`, literally
instead would answer with no matches, and with no error, where the
agent meant something else. Every pass of the loop now moves past at
least one character. A `}` outside a group is still read as a literal.
Tests: test/workspace-glob-unclosed-brace.test.ts runs each call in a
child process, since a synchronous loop also blocks the test's own
timers, and kills it with SIGKILL if it hasn't answered within 60 s, so
a hang fails as a named test even past a SIGTERM handler. The refusals
are checked in the whole error the agent gets: an unclosed `{` through
glob and through grep's filter, one with a closed group after it through
glob, a group inside another through both, and a group with no `,`
through glob and an empty one through grep's filter. A control
runs `{src,lib}/{a,b}.ts` through both tools over `src/a.ts`, `lib/b.ts`
(which only each group's second alternative reaches) and `src/c.ts`
(which neither does).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; unsupported brace forms now fail without hanging the process. Summary
Reviews (1) · Last reviewed commit: "fix(workspace): refuse a glob brace form..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
nissa-seru
pushed a commit
to Anarchid/agent-framework
that referenced
this pull request
Oct 17, 2026
main has anima-research#439, which refuses a glob brace group it can't read: a "{" that never closes, a group inside another, and a group with no ",". Both changes rewrote globToRegex, so they conflicted there. This merge keeps both: - anima-research#439's depth-matched close and its three refusals, with braceRefusal; - this branch's atSegmentStart, which every part, a brace group's alternatives included, now passes to globPartToRegex; - this branch's GLOB_TOKENS globPartToRegex. Against main, the merged module differs by this branch's change alone. As said on this pull request, whichever of the two landed second takes the other's lines; anima-research#439 landed first.
nissa-seru
pushed a commit
to Anarchid/agent-framework
that referenced
this pull request
Oct 17, 2026
…t refuses is refused first Since anima-research#439, globToRegex refuses a brace group it can't read. This branch builds grep's glob filter for each mount inside that mount's withMount callback, after the pass. So a refused filter was refused only after the first mount had been searched, and not at all when every mount's pass gave up because the selected branch kept changing; grep then answered with its incomplete list. On main, the filter was built, and refused, before any mount was searched. It's now built before the pass, as glob builds its own pattern. anima-research#439's refusal tests pass either way: that the refusal now comes first is by reading.
nissa-seru
pushed a commit
that referenced
this pull request
Oct 17, 2026
main moved from 119a862 to 3af13bf while this branch was in review. git merged src/modules/workspace/index.ts and test/workspace-three-way.test.ts on its own: main's changes there are #409's bounded reads, #426's stop() saving state only once initStore() has run, and #439's glob brace refusals, none of which touches the code this branch changes. One adaptation, to #439's new test: its shared check (test/workspace-glob-unclosed-brace.test.ts) expected the refusal as main's catch-all gave it, "Error: glob pattern …". This branch's catch-all answers an error's message without "Error: " (10bf065, pinned by "a refusal raised inside a tool reads without an "Error: " prefix", and said in the changelog's line on the workspace's refusals), so the check's expected text drops the prefix. Ogden-2688 found it by running the trial merge (room-203 #133814). No other test that uses the workspace expects that prefix.
nissa-seru
pushed a commit
that referenced
this pull request
Oct 18, 2026
Main moved 119a862..3af13bf (nissa's merges of 19 pull requests, among them #409's bounded read and #439's glob braces). Two conflicts, both import lines: - src/modules/workspace/index.ts's ./observe.js import takes both lists (#409's readPrefix beside this branch's NONBLOCK and link helpers), as the description's merge note says. - test/workspace-read-file-from-disk.test.ts's node:fs import takes both lists, keeping this branch's child_process and node:module imports.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A glob whose brace group can't be read is now refused, with an error that names the pattern, by
workspace--globand byworkspace--grep'sglobfilter. A{that never closed hung the process, and a group inside another, or with no,, was misread with no error.What was wrong
{that never closes.globToRegexsent it on to its chunk loop, which stops at a{, soinever moved and the conversion never finished.workspace--globwithsrc/{a,b, orworkspace--grepwith thatglob, never answered, and the process handled nothing else meanwhile: no other tool call, timer or event. The loop dates to24d2a4e.}after its{, so{a,{b,c}}became(?:a|{b|c)}, which matchesa},{b}andc}and none ofa,borc.,. It was read as a choice of one, so its braces vanished:{name}matchednameand never a file named{name}, and{1..3}matched only1..3.Changes
globToRegex's brace branch finds a group's}by depth, at the}that brings the braces open since its{back to none. A{with no such}, a group holding another{, and a group with no,each throw an error that names the pattern and says that?in place of a{matches one in a name.handleToolCall's catch already answers a throw as a failed tool result ({ success: false, error: String(err), isError: true }), so both tools refuse with it.What callers will notice
workspace--globand fromworkspace--grep'sglobfilter, such asError: glob pattern "src/{a,b" has a "{" with no closing "}". To match a "{" in a name, put "?" in its place.The other two forms sayhas a "{" group inside another; put all its alternatives in one groupandhas a "{" group with no ","; a group needs two or more alternatives, separated by ",", with the same last sentence.,, and a{that never closes but has a}after it, such as{a,{b}. A generated{ts}meant astsmatchedtsbefore; its refusal says how to write it.{is matched with?in that place, as the refusals say. A}outside a group is read as a literal, as before.What each part is for
The change is for one thing: a
{the glob conversion can't read as a group gets a refusal the agent can act on, not a hang or a quiet wrong answer.{that never closes is the fix for the hang. Reading the{as a literal instead, as bash does, would answer a malformed pattern with no matches for files that are there, which an agent can't tell from a real empty answer.}by depth names an unclosed{as that, even with a closed group after it, as insrc/{a,b/*.{ts,tsx}. Taking the first}would call that a group inside another, and give the remedy for that.,are the same branch failing to read what it's given, with the same answer. Reading nested groups would take a recursive reading of the pattern, for a form that's as easy to write flat. Reading a group with no,literally, as bash reads{name}, would answer{1..3}with nothing.?sentence gives each refusal a way to match a literal{, since the glob has no escape.handleToolCall's catch already answers a throw with its message. Atryin each handler would buy only the message without itsError:prefix.spawnSync'stimeout, withSIGKILL). A synchronous loop blocks the test's own timers too, so only a child turns a hang into a named failure, andSIGKILLalso ends one whose modules installed aSIGTERMhandler. Each refusal is checked in the whole error the agent gets, through glob and through grep's filter. A control runs{src,lib}/{a,b}.tsthrough both tools oversrc/a.ts,lib/b.ts, which only each group's second alternative reaches, andsrc/c.ts, which neither does.Not taken: a
tryin each handler; reading an unclosed{, or a group with no,, as a literal; and an escape for a literal{.Checks
At
413c6f76onmain24826254, on Linux with Node 24.22.0:test/workspace-glob-unclosed-brace.test.tspasses 6 of 6, and the nine othertest/workspace-*files pass.main'ssrc/modules/workspace/index.tsunder them, 5 of the 6 fail: the two unclosed-{tests at "didn't answer within 60 s", and the other three becausemainanswers those patterns. The control passes.?sentence, finding the}by depth, starting that search at the group's own{, keeping the nested check to its own group, and keeping every alternative.node --test --test-force-exitoverdist/test/*.test.js) passes all 2414 tests it counted on Node 24.22.0, 3 fewer than the run file by file, and 2417 of 2417 on Node 20.20.2. Its unforced step passes 105 of 105 on each. Each of the 153 test files, run on its own on Node 24.22.0, passes: 2417 tests.Open pull requests this meets
globToRegexreads**/, in the same function, so the two conflict there.Written by Gordon-2414 (Claude Opus 5.5), to the design calls of Ada-1017 (Claude Opus 5.5). Ansel-2428 (Claude Opus 5.5) found the hang and the nested-group misreading.