Repository navigation
Task evaluation: fixes across every task class, and the 19 decided product changes - #1047
Conversation
validateInput compiled the instance schema afresh on every call when the class sets hasDynamicSchemas, and on every call with stream-wired ports skipped. Compiling costs ~164 us against ~6 us to validate, and the dynamic path is taken by InputTask, OutputTask, GraphAsTask, IteratorTask/MapTask, FetchUrlTask and the MCP tasks, once per run and once per preview. Those schemas are now compiled once per class per distinct schema, keyed by the schema's JSON text so a schema edited in place is never validated against its old shape, with a 32-entry LRU bound. Measured on an 8-port schema: validateInput 65-87 us -> ~5 us; a dynamic task's run() 172 us -> 102 us. DynamicSchemaValidation.test.ts pins the single compile (instance schema and stream-skipped schema) and that edited and replaced schemas still validate against their current content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
evaluateCondition read operands with Number(), which turns "", " " and [] into 0. So in ConditionalTask and WhileTask a field nobody filled equalled "0", a blank field was less_than 1, and an unfilled compare value made `x greater_than ""` true for every positive x -- WhileTask passes `conditionValue ?? ""`, so a loop with no value set compared against 0. Blank strings and empty arrays now read as non-numeric: equals/not_equals fall through to the string comparison, and the ordering operators take the existing non-numeric path (false, with the warning). Padded numerals such as " 42 " still compare numerically. ConditionUtils.test.ts pins each case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
GraphAsTask.executeStream started the subgraph run and only awaited it after draining its events. When the consumer stopped reading first -- StreamProcessor breaks out of its loop when a sink, a listener or an error event throws, and any caller can close the generator -- the subgraph kept running with nobody waiting for it, still spending on whatever it ran, and a later failure in it surfaced as an unhandled rejection, which ends a Node process by default. The subgraph now runs under its own AbortController, forwarded from the caller's signal. If the generator exits before the run settles, the run is aborted and its promise marked handled. GraphAsTaskStreamAbandon.test.ts reads one event, closes the stream, and asserts the child saw the abort and that no rejection went unhandled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…Task A run with enforceEntitlements checks each task as the scheduler reaches it, and nested graphs inherit the flag only when the compound task hands it to its own subGraph.run(). GraphAsTask and FallbackTask's data mode did; IteratorTaskRunner (MapTask, ForEachTask, ReduceTask) and WhileTask did not, so every task inside them was scheduled unchecked. Under a policy denying the cloud-metadata address, a url computed inside a MapTask, ReduceTask or WhileTask subgraph was fetched; the same subgraph inside a GraphAsTask was refused. Pre-flight cannot see such a url, which only exists once the subgraph runs. Each iteration clone and each WhileTask pass now carries the outer task's enforceEntitlements. WhileTask also passes the run's registry and resource scope (subGraphRunContext), as GraphAsTask does: its passes resolved services, the enforcer included, from the global registry instead of the run's. NestedEntitlementEnforcement.test.ts gains one case per compound task -- MapTask, ReduceTask, WhileTask and FallbackTask in both modes -- each asserting the denial and that nothing was fetched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
resolveA2UIConnector threw a bare Error, so a host that never registered a connector saw the run fail as a generic task failure (TaskFailedError in the task's error) instead of the TaskConfigurationError every other "the host must set this up" refusal in the task raises. The message is unchanged. The existing missing-connector test now also asserts the class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Fourteen refusals across the browser tasks threw a bare Error -- a click, fill, hover or select naming no element, a javascript: url, an unknown or disconnected session id, a close-tab or extract-html config missing its target, a selector wait with no selector, and unregistered browser deps. The runner records a bare Error as a generic TaskFailedError, so a caller could not tell "you passed nothing to click" or "this host never registered a browser" from a browser that failed. Input problems now throw TaskInvalidInputError and setup problems TaskConfigurationError, with the messages unchanged. The "not yet implemented" login modes and the extract-text "no body" failure are left as they are: the first is a product question, the second a genuine failure. BrowserTaskErrors.test.ts pins one case of each kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
The one setup refusal the previous commit missed: BrowserSessionTask asked for a backend the host does not offer threw a bare Error. It now throws TaskConfigurationError, message unchanged, and BrowserTaskErrors.test.ts covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
JavaScriptTask called Interpreter.run(), which is one synchronous
`while (step()) {}`. A script that never finished -- `while (true) {}` --
held the event loop for good: an abort could not run, no timer fired, and a
preview, which runs on every edit, froze its host. Reproduced: a run with an
abort scheduled 200 ms later never returned and had to be killed.
The interpreter now steps in 50 ms slices and yields to the event loop
between them; execute() checks the run's signal at each yield and throws
TaskAbortedError (no longer wrapped as invalid input). Results are
unchanged -- run() is the same step loop. Measured on a 1M-iteration loop
(~18M steps, ~2 s): 50 ms slices are within run-to-run noise of run()
(-2% to +4% over three rounds).
There is still no limit on how long a script may run: a preview, which has
no signal, now stays responsive but spins until the script ends. A step or
time budget is a separate change.
JavaScriptTask.test.ts: an endless script is aborted within the test's
timeout, and a timer ticks while a long script runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Two defects in McpToolCallTask:
- The output was built as { content, isError, ...parsedFromText,
...structuredContent }, so a tool result carrying its own `isError` field
overwrote the protocol's flag. Reproduced: a call the server answered with
isError: true and structuredContent { isError: false } reported success to
the graph -- and to an agent reading the result. isError now goes last.
- discoverSchemas() returned early only once BOTH schemas were known. Most
tools declare no output schema, so every call listed the server's tools
again before calling: two client connections per call, which for a stdio
server is two processes spawned. A completed listing is now remembered for
the instance; a failed one is still retried.
mcp.test.ts: the protocol's isError survives a same-named structured field,
and three calls open one connection for discovery plus one per call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Two findings across the scalar and vector math family:
- ScalarMaxTask and ScalarMinTask computed Math.max(...values), passing every
element as an argument. Engines cap argument counts, so a large array threw
"RangeError: Maximum call stack size exceeded" instead of returning --
reproduced at 1,000,000 values under both Bun and Node. They now fold with
Math.max / Math.min pairwise, which keeps NaN propagation and signed zero
(pinned).
- None of the twenty math tasks implemented executePreview, while every
string task and most other utility tasks do -- so a builder showed a live
value for `upper("a")` and none for `2 + 3`. Each now previews by running
its own pure execute(); execute's unused context parameter became optional
so the preview needs no cast. run() results are unchanged.
MathTaskFamily.test.ts runs all twenty tasks and asserts runPreview() equals
run(), plus the large-array and NaN/-0 cases. Only ScalarAdd and ScalarSum had
scalar tests before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…erally
- StringTemplateTask replaced key by key with replaceAll, each pass scanning
what the previous ones had inserted. A value holding a placeholder was
expanded again, so user text "{{secret}}" put another key's value into the
output -- or did not, depending only on the order of `values`. Reproduced
both ways. It now substitutes in one pass with an exact key lookup; a
placeholder with no value still stays as written.
- StringReplaceTask passed the replacement to replaceAll as a string, which
expands `$&`, `$$` and `$1`: replacing "10" with "$& USD ($$5)" produced
"10 USD ($5)". The port is documented as the replacement string; it is now
inserted as is.
StringTask.test.ts pins the order-independent single pass, the literal `$`
replacement, and that an unknown placeholder survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
An unknown IANA zone ("Mars/Olympus") or a malformed locale tag reached
toLocale*String and surfaced as a raw RangeError, recorded as a generic task
failure. It is now a TaskInvalidInputError naming the locale and time zone
passed -- Node's own message for a bad locale ("Incorrect locale information
provided") does not say which one.
DateFormatTask.test.ts covers both, under Bun and Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Its constructor took only `config` and called super(config), so a run config passed as the second argument -- an output cache, cacheable, a timeout -- was dropped without a word, unlike every other task. It is now forwarded. LambdaTask.test.ts asserts the constructed task carries it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
A delay over 100 ms sleeps in up to 100 chunks to report progress, and the abort was checked only between chunks -- so a one-minute delay noticed it up to 600 ms late and an hour's up to 36 s. Each chunk now ends early when the signal aborts, and the task throws TaskAbortedError straight away. DelayTask.test.ts: a 60 s delay aborted at 50 ms rejects in under 300 ms (was ~600 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Text that was not JSON surfaced as a raw SyntaxError from the constructor's regenerateGraph(), and graph-format JSON of the wrong shape went straight into createGraphFromGraphJSON. Both now throw TaskInvalidInputError with the reason, and graph JSON is checked with taskGraphJsonShapeError first -- the structural check task-graph provides for exactly this. JsonTask.test.ts (new) covers both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
The module doc claimed the undici connect.lookup pin "defeats DNS rebinding" unconditionally. Measured: under Node the hook is called and a pinned fetch to an unresolvable name reaches the pre-resolved IP; under Bun the hook is never called (0 calls) and the same fetch fails with ENOTFOUND -- Bun's undici does its own lookup. So under Bun the address checked and the address connected can differ. Comment only: it now says which protections hold under Bun and which do not, so nobody reading it concludes the TOCTOU is closed on every runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
AiTask.entitlements() added `ai:model` scoped to the model id only when runInputData.model was a string, falling back to a string default. But the runner resolves the model to its ModelConfig (resolveSchemas) before the run-time entitlement check, so: - a model that arrived through a dataflow or run input produced no ai:model requirement at all -- a policy restricting which models may run never saw it; - with a string default, a different model arriving at run time was checked as the default -- a grant for model A admitted a run on model B. The id is now read from a resolved ModelConfig as well as a string, and the run's model is preferred to the default. AiTaskEntitlements.test.ts covers the string default, a resolved model with no default, and a resolved model replacing a default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
AiTask.execute registers the provider's "model.dispose" run-fn with the run's ResourceScope, which is how a local runtime (llama.cpp, HF Transformers) releases a resident model when the scope ends. StreamingAiTask never reaches execute -- TextGeneration, Chat, Summary, ToolCalling and the other streaming tasks run through executeStream -- so a model they loaded was never registered. Reproduced with a mock provider: the streamed run produced its text, and disposing the scope unloaded nothing. The registration moved into AiTask.registerModelDisposal() and is called from both paths; on the streaming path in its existing `finally`, so a stream stopped early or failed still hands the model over. StreamingModelDisposal.test.ts pins it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
ChunkVectorUpsertTask (chunks and vectors not 1:1, chunks from more than one document) and DocumentUpsertTask (no title anywhere) refused with a bare Error, which the runner records as a generic task failure. They now throw TaskInvalidInputError, messages unchanged, like the other input refusals. The existing refusal tests in DocumentChunkUpsertTask.test.ts and DocumentUpsertTask.test.ts now also assert the class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…k errors Six refusals threw a bare Error, recorded by the runner as a generic task failure: - ModelInfoTask / ModelSearchTask: the provider registers no run-fn for the operation -- a host-setup problem, now TaskConfigurationError; - HierarchicalChunkerTask: no doc_id or documentTree, reservedTokens not below maxTokens, overlap not below the effective budget -- the caller's input, now TaskInvalidInputError. Messages unchanged. ModelOpsRefusals.test.ts (new) asserts each class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
AiImageOutputTask.getCachePolicy() returned "deterministic" whenever a seed was set and "private" otherwise, without consulting the base resolution -- whose documented first rule is that a caller's `cacheable: false` (config or run config) wins. TaskRunner reads getCachePolicy(), so ImageGenerateTask and ImageEditTask cached a seeded result the caller had asked not to cache. Reproduced through both config and run config. It now starts from super.getCachePolicy() and returns its "none" untouched, as TextGenerationTask's and ToolCallingTask's overrides already do. AiImageOutputTask.test.ts pins both opt-out routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
The output cache keys on task type and input; the function a lambda runs is
not part of that key. Two lambdas sharing a cache therefore answered for each
other -- `square(3)` returned `double(3)`'s cached 6. LambdaTask now declares
cachePolicy { kind: "none" }, as QuickTask (a function passed to pipe()) does
for the same reason.
LambdaTask.test.ts runs two lambdas against one cache and expects each its
own answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
BrowserNavigateTask and BrowserLoginTask refused only javascript: urls, so a graph could open file:///etc/passwd or http://169.254.169.254/ and read the page back through the extract and screenshot tasks -- a local-file read and an SSRF past the rules FetchUrlTask and FileLoaderTask enforce. BrowserUploadTask uploaded any local path under an unscoped filesystem:read. - Navigation (Navigate, Login) accepts http(s), about:blank and relative urls only; file:, data:, javascript:, chrome: and the rest are invalid input. - Their entitlements are FetchUrlTask's for the same url: browser:navigate scoped to the destination, plus network:private scoped to it when the host is private; unknown (or relative) destinations fail closed, unscoped. - Upload takes FileLoaderTask's `roots` / `allowAnyRoot` (default: the process working directory), resolves symlinks before the check, and declares filesystem:read scoped to the paths. node:fs is loaded at execute time through an opaque specifier, because this package is also bundled for the browser, where an upload never executes. classifyUrl and its helpers moved from @workglow/tasks to @workglow/task-graph (with ipaddr.js, already in the workspace) so browser-control classifies with the same code; @workglow/tasks re-exports them unchanged. The shared browser test suite loaded its fixture page from a data: url; it now serves it from a local http server. BrowserReach.test.ts covers the refused schemes, the scoped and fail-closed declarations, an enforced run denied on the metadata host without navigating, and upload containment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
FileLoaderTask, FileGrepTask and FileSedTask (a file or url), KbSearchTask and
ChunkRetrievalTask (a knowledge base the upsert and delete tasks change), and
WebSearchTask (the live web) were cached by their input alone, so a repeat
answered with the source as it was. Reproduced: a file rewritten from
"version 1" to "version 2" between two loads still loaded as "version 1".
FetchUrlTask, reading the same web, was already uncached.
Each now declares cachePolicy { kind: "none" }. Not `cacheable = false`: the
file tasks' server variants extend these classes, and resolveCachePolicy
honours `cacheable` only where a class declares it itself, so the variants
that actually run would have kept caching.
Validators that would restore safe hits (mtime/ETag, a knowledge-base
revision, a search TTL) are later work.
ChangingSourcesNotCached.test.ts asserts the policy on all six and the
rewritten-file case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
collectResults pushed each port's values as iterations produced them, and the runner dropped iterations that produced nothing before collecting. A port only some iterations emit came back shorter and out of step with the input: n: [1,2,3,4] with `even` for even items gave even: [2,4], so even[0] belonged to item 1. Every port now has one slot per iteration, null where that iteration did not produce it (or produced nothing). With preserveOrder: false the slots follow completion order, but every port follows the same order, so ports still pair up by index. `flatten` flattens each slot as before. Not changed: the wrapped output schema still describes items with the port's own schema, so a null slot is outside it; making items nullable changes how a builder matches port types and is left as follow-up. MapTaskAlignment.test.ts pins both orders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
A script that never ended could already be aborted, but nothing ended it on its own, and a preview -- which has no abort signal -- spun in the background until its page closed. A run now stops after `maxSteps` interpreter steps (new config, default 50M, ~5 s here) and fails as invalid input naming the budget; a preview takes at most 1M steps (~0.1 s), whatever maxSteps says. A step count rather than a time limit, so a script stops at the same point on every machine. The check sits in the existing slice loop and costs one comparison per step. JavaScriptTask.test.ts: an endless script with maxSteps 10000 fails naming the budget; an endless preview ends on its own; a script inside its budget is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
It sent kind "elicit" with an { approved, reason } schema, which
humanPromptModel draws as a form: a person had to tick "Approved" and press
Accept, Accept alone was a denial, and a host treating Accept as yes -- the
way tool approvals work -- reported every approval as declined.
It now sends kind "confirm", drawn everywhere as an approval: Accept is
approved, Decline refuses, Cancel walks away undecided. The input `context`
goes in contentData, shown as read-only details. A `reason` is passed on when
a host returns one with the answer. An explicit `approved: false` in the
content (a host still drawing the old form) is still a denial, so the change
of kind cannot turn a "no" into a yes.
Not in this commit: a comment box on confirm prompts. That needs the request
model and every host renderer (Ink panel, web console, MCP elicitation,
builder) to offer one; until then `reason` is filled only by hosts that send it.
The approval tests in HumanInputTask.test.ts move to the confirm contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
A condition function that threw was logged and treated as "not taken", so a bug in it -- or a branch list where only the first entry has a condition -- routed the data down the next branch or the default, on a run reported as a success. WhileTask already fails on a throwing condition. ConditionalTask now throws a TaskFailedError naming the branch and the cause (original stack chained), before any branch is activated. ConditionalTask.test.ts and ConditionalBuilder.test.ts pinned the old route-on semantics; they now assert the failure, and that the builder's else arm still propagates the error rather than returning true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…e their branches
pipe() and parallel() mint an unregistered class per call (QuickTask,
ParallelTask). Their toJSON() succeeded, writing types like "𝑓 double" and
"‖𝑓‖𝑓‖", and the graph then failed at the next load ("Task type 𝑓 double not
found"). Both now answer canSerializeConfig() false, so toJSON() throws
TaskSerializationError where the graph is saved -- as WhileTask and
ConditionalTask holding functions already do.
ParallelTask's type labelled every branch 𝑓, tasks and workflows included; it
is now built from each branch's own type ("‖𝑓 double‖Workflow‖").
FunctionGraphSerialization.test.ts covers both refusals and the label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
The default "manual" mode reported "Waiting for manual login..." and returned at once, so the next task ran before anyone had logged in; the "credential" and "ai" modes navigated and then threw "not yet implemented", while the schema offered all three and credential mode declared browser:credential. Manual mode now opens the page and asks the person, through the host's HUMAN_CONNECTOR, to log in and then continue (a confirm); it returns when they accept and fails with TaskFailedError when they decline or cancel. A host with no connector is refused before the page is touched. The unimplemented modes and `credentialName` are gone from the config, so a graph naming them is refused at construction instead of failing mid-run. BrowserLogin.test.ts covers accept, decline and cancel, a missing connector, and the removed modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
|
Starting the review on the remaining files now. The last Vortex pass at |
typecheck-budget failed on packages/test: 2,804,934 instantiations against a budget of 2,392,779 (+18%, limit +15%). Most of the growth was this test leaving pipe() and parallel() to infer their generics through every overload of Taskish; naming the port type and the function's type takes the package to 2,676,424, within budget. The test checks the same things. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
There was a problem hiding this comment.
Review progress ██████████ 118/122 files
Comment — found 2 issue(s) at 8a7acd8.
Actionable comments posted: 2
Coverage is incomplete: reviewed 118/122 files; 0 skipped; 4 deferred and still unreviewed.
Comment @mergestorm-vortex review on this PR to review the remaining files.
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1047: https://github.com/workglow-dev/libs/pull/1047 (head branch `claude/confident-lovelace-4cw5ln`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/tasks/src/task/FileLoaderTask.ts` (line 130, Important):
`cachePolicy: { kind: "none" }` disables only this task's output cache. `execute()` still owns a `FetchUrlTask` at line 174, which has no `cachePolicy` override and therefore uses the default deterministic policy. With a `CacheRegistry` configured, running the loader twice for the same URL can still return the first fetch's cached body after the remote file changes, defeating this change's no-cache behavior. `FileGrepTask` has the same owned-fetch path.
2. In `@packages/tasks/src/task/vector/VectorMultiplyTask.ts` (line 90, Important):
`return this.execute(input)` makes `runPreview()` perform the complete O(vector length × vector count) multiplication and allocate the result synchronously; vector inputs have no size bound. Large vectors can therefore block the preview/UI path, contrary to the lightweight-preview contract. The same forwarding pattern is used by the other vector preview methods in this diff.
Co-located tests are typechecked only by typecheck:tests, which the full type build does not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Where the interpreter has no vm (Bun, browsers), a regex runs in a Worker and the interpreter pauses until it answers. step() reports progress while paused without making any, so the sliced loop spun, charged every spin to maxSteps, and yielded only every 50 ms: a three-step split failed a 2000-step budget under Bun. While paused the loop now yields to the event loop, honouring abort, and counts nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…its slots The iterator runner read the abort signal through currentCtx, which is cleared once a run is aborted, so after an abort the checks found no signal and the remaining iterations ran on: the map resolved with full results while its status read ABORTING. The run's signal is now held for the whole execution, and checked again before collecting, since workers stop picking up items and an abort inside the last batch leaves slots unfilled. A declared port that no iteration produced was missing from the output; it now has one null per item, as the empty result has an empty array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…ate host Entitlements are declared for the url a navigation asks for, but a redirect decides where the page lands: a public url answering 302 to a metadata endpoint handed that page to the extract and screenshot tasks. Navigate and login now check the landing url as safeFetch checks each redirect hop: a private host is allowed only on the private origin the navigation declared, and anything else is refused with the page cleared to about:blank. Only the landing page is checked; navigations a page makes later, by script or a click, are not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…hed either FileLoader, FileGrep, FileSed and the web-search HTTP adapters are uncached, but each owns a FetchUrlTask for http(s), and that child kept the default policy: with a CacheRegistry on the run, a second load of a url whose body had changed came back with the first body. The owned fetch now runs cacheable: false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
There was a problem hiding this comment.
Review progress ██████████ 124/124 files
Request changes — found 4 issue(s) at 8f7dd7a.
Actionable comments posted: 4
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1047: https://github.com/workglow-dev/libs/pull/1047 (head branch `claude/confident-lovelace-4cw5ln`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 79, Blocker):
`classifyUrl` returns `invalid` for URLs with username/password, but this branch adds `network:private` only for `kind === "private"`; `assertNavigableUrl` accepts such HTTP(S) URLs. For example, `http://user:pass@169.254.169.254/latest/meta-data` therefore declares only scoped `browser:navigate`, so a policy granting that scope while denying `network:private` passes the preflight and the browser contacts the private host. Refuse invalid classifications or require the private entitlement for them.
2. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 101, Blocker):
Both navigation tasks call this only after `ctx.navigate()` returns. The Playwright backend implements that with `page.goto(..., { waitUntil: "load" })`, which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates to `about:blank`. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent.
3. In `@packages/task-graph/src/task/IteratorTaskRunner.ts` (line 355, Blocker):
`enforceEntitlements: this.task.runConfig?.enforceEntitlements`
When a caller invokes `map.run(input, { registry, enforceEntitlements: true })`, `TaskRunner` checks the MapTask using that run's `effectiveConfig` but does not copy the option into `task.runConfig`. This therefore passes `undefined` to the cloned subgraph's `run()`, which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
4. In `@packages/javascript/src/task/JavaScriptTask.ts` (line 71, Important):
`if (steps >= maxSteps)` counts every loop iteration, including iterations where `interpreter.step()` makes no progress because `getStatus()` is `TASK` while a `setTimeout` callback is pending. The interpreter supports `setTimeout`; for example, a script scheduling a callback after 6 seconds can exhaust the default 50M-step budget (and fail as `TaskInvalidInputError`) before the callback runs, despite executing very little user code. The same issue can make shorter timers exceed the much smaller preview budget.
…s subgraph A graph run stamps enforceEntitlements onto each task it schedules, and the subgraph runners (GraphAsTask, the iterators, the streaming path) read it from task.runConfig. A task run directly with the option was checked itself but never stamped, so its subgraph ran unchecked: a GraphAsTask whose inner fetch computed a denied private url reached it. TaskRunner.run now stamps the flag the way the graph runner does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
… preview stops While a setTimeout callback is queued but not due, step() reports progress without running anything, so the sliced loop spun and charged each spin to maxSteps: one setTimeout ran a 2000-step budget out. A run now sleeps until the timer is due, in slices so an abort is still noticed. A preview, which must be quick and has no signal, stops once only timers are left, so a setInterval cannot keep it waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
classifyUrl calls a url carrying credentials (or no host) invalid, and navigationEntitlements added network:private only for private ones, so http://user:pass@169.254.169.254/ declared a scoped browser:navigate alone and a policy denying the private host never saw it. Navigation now refuses such a url, as a fetch does, and declares it fail-closed if asked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
There was a problem hiding this comment.
Review progress ██████████ 125/125 files
Request changes — found 6 issue(s) at 0421952.
Actionable comments posted: 6
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1047: https://github.com/workglow-dev/libs/pull/1047 (head branch `claude/confident-lovelace-4cw5ln`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 141, Blocker):
`resources: filePaths as string[]`
The entitlement is checked against these original path strings, but `containUploadPaths()` resolves symlinks and passes the resolved paths to `ctx.uploadFile()` afterward. For example, a grant scoped to `/work/input.txt` allows that path when it is a symlink to `/work/.env`; if both are inside the configured root, the root check passes and the upload reads `.env` without a grant for that resolved resource. Ensure the entitlement scope covers the canonical paths that are actually uploaded.
2. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 193, Important):
`p.startsWith(root + path.sep)` turns a configured root of `/` into the prefix `//`, so paths such as `/tmp/file.txt` fail containment even though they are inside `/`. Windows drive roots have the same trailing-separator issue. Handle filesystem roots as containing their descendants (or otherwise normalize this boundary) so a valid root configuration does not reject every upload.
3. In `@packages/javascript/src/task/JavaScriptTask.ts` (line 183, Important):
`JavaScriptTaskConfig` now declares `maxSteps`, but `javaScript()` and `Workflow.prototype.javaScript` still type their config argument as the base `TaskConfig`. TypeScript callers therefore cannot pass `{ maxSteps: ... }` through these public entry points (an excess-property error); only constructing `JavaScriptTask` directly exposes the new setting.
4. In `@packages/task-graph/src/task/TaskRunner.ts` (line 429, Important):
`this.task.runConfig` outlives this call, so a direct `task.run(..., { enforceEntitlements: true })` permanently sets this flag. A later `task.run(..., { enforceEntitlements: false })` makes the outer run's `effectiveConfig` false, but `GraphAsTaskRunner`, `IteratorTaskRunner`, and `WhileTask` pass `this.task.runConfig?.enforceEntitlements` to nested runs, so they still enforce (and can reject work or fail when no enforcer is registered). Keep this propagation scoped to the current run rather than leaving it on the task instance.
Additional findings outside changed lines:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 117, Blocker):
The redirect check runs after the private request
Both navigation tasks call this only after `ctx.navigate()` returns. The Playwright backend implements that with `page.goto(..., { waitUntil: "load" })`, which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates to `about:blank`. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent.
2. In `@packages/task-graph/src/task/IteratorTaskRunner.ts` (line 355, Blocker):
Nested map run drops per-run entitlement enforcement
`enforceEntitlements: this.task.runConfig?.enforceEntitlements`
When a caller invokes `map.run(input, { registry, enforceEntitlements: true })`, `TaskRunner` checks the MapTask using that run's `effectiveConfig` but does not copy the option into `task.runConfig`. This therefore passes `undefined` to the cloned subgraph's `run()`, which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
Still open from earlier reviews (2) — the lines they point at have not changed:
- 🔴 Blocker · The redirect check runs after the private request ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 117 · from8f7dd7a
Both navigation tasks call this only afterctx.navigate()returns. The Playwright backend implements that withpage.goto(..., { waitUntil: "load" }), which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates toabout:blank. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent. - 🔴 Blocker · Nested map run drops per-run entitlement enforcement ·
packages/task-graph/src/task/IteratorTaskRunner.tsline 355 · from8f7dd7a
enforceEntitlements: this.task.runConfig?.enforceEntitlements
When a caller invokesmap.run(input, { registry, enforceEntitlements: true }),TaskRunnerchecks the MapTask using that run'seffectiveConfigbut does not copy the option intotask.runConfig. This therefore passesundefinedto the cloned subgraph'srun(), which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
The stamp outlived the run, so a later task.run(..., { enforceEntitlements:
false }) still enforced in the subgraph, and failed when no enforcer was
registered. The flag is now set for the run, whichever way it was asked,
and the previous value is restored when the run ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
…oot contains uploadEntitlements declared the paths as given, while the upload read the file each resolves to, so a grant naming a symlink covered the file behind it. The paths are now declared by real path, as FileLoaderTask declares, where the runtime hands over fs synchronously (process.getBuiltinModule); a browser, which never uploads a local file, keeps the paths as given. A root of / (or a drive root) already ends in a separator, so the containment prefix became // and every upload was refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
maxSteps could be set only by constructing JavaScriptTask; the javaScript() function and Workflow.javaScript typed their config as TaskConfig. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
There was a problem hiding this comment.
Review progress ██████████ 125/125 files
Request changes — found 6 issue(s) at 4d32fab.
Actionable comments posted: 6
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1047: https://github.com/workglow-dev/libs/pull/1047 (head branch `claude/confident-lovelace-4cw5ln`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 89, Blocker):
`classifyUrl(url)` only classifies the URL's literal hostname; it does not resolve DNS. A URL such as `http://attacker.example` therefore gets only `browser:navigate` even when its A/AAAA record resolves to `127.0.0.1` or `10.0.0.5`, and the browser backend resolves and connects to that private address without `network:private`. The landing check has the same limitation because it classifies the URL text. Unlike `SafeFetch.server.ts`, this path does not check the resolved addresses, so private services reachable from the browser host can be accessed without their entitlement.
2. In `@packages/task-graph/src/task/TaskRunner.ts` (line 434, Blocker):
`this.task.runConfig` is stamped here, but `FallbackTaskRunner` does not pass that value into its nested runs: task mode calls `alternativeTask.run(input, this.streamRunOptions)` and data mode calls `subGraph.run(...)` without `enforceEntitlements`. With a runtime-only URL such as `http://169.254.169.254/...` reaching a fallback fetch from upstream, the child runs without the parent enforcer and can make the denied request. The added FallbackTask tests exercise these paths, but this stamp alone cannot make their nested executions enforce the policy.
3. In `@packages/javascript/src/task/JavaScriptTask.ts` (line 89, Important):
`interpreter.step()` has already executed at line 88 before this check. When `steps === maxSteps`, the next interpreter instruction still runs and only then throws, so a run configured for N steps can execute N+1 steps (including any work or side effects in that extra step) before rejection. Check the budget before advancing the interpreter when more work is pending.
Additional findings outside changed lines:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 145, Blocker):
The file grant can name a symlink, not the file actually uploaded
`resources: filePaths as string[]`
The entitlement is checked against these original path strings, but `containUploadPaths()` resolves symlinks and passes the resolved paths to `ctx.uploadFile()` afterward. For example, a grant scoped to `/work/input.txt` allows that path when it is a symlink to `/work/.env`; if both are inside the configured root, the root check passes and the upload reads `.env` without a grant for that resolved resource. Ensure the entitlement scope covers the canonical paths that are actually uploaded.
2. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 117, Blocker):
The redirect check runs after the private request
Both navigation tasks call this only after `ctx.navigate()` returns. The Playwright backend implements that with `page.goto(..., { waitUntil: "load" })`, which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates to `about:blank`. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent.
3. In `@packages/task-graph/src/task/IteratorTaskRunner.ts` (line 355, Blocker):
Nested map run drops per-run entitlement enforcement
`enforceEntitlements: this.task.runConfig?.enforceEntitlements`
When a caller invokes `map.run(input, { registry, enforceEntitlements: true })`, `TaskRunner` checks the MapTask using that run's `effectiveConfig` but does not copy the option into `task.runConfig`. This therefore passes `undefined` to the cloned subgraph's `run()`, which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
Still open from earlier reviews (3) — the lines they point at have not changed:
- 🔴 Blocker · The file grant can name a symlink, not the file actually uploaded ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 145 · from0421952
resources: filePaths as string[]
The entitlement is checked against these original path strings, butcontainUploadPaths()resolves symlinks and passes the resolved paths toctx.uploadFile()afterward. For example, a grant scoped to/work/input.txtallows that path when it is a symlink to/work/.env; if both are inside the configured root, the root check passes and the upload reads.envwithout a grant for that resolved resource. Ensure the entitlement scope covers the canonical paths that are actually uploaded. - 🔴 Blocker · The redirect check runs after the private request ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 117 · from8f7dd7a
Both navigation tasks call this only afterctx.navigate()returns. The Playwright backend implements that withpage.goto(..., { waitUntil: "load" }), which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates toabout:blank. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent. - 🔴 Blocker · Nested map run drops per-run entitlement enforcement ·
packages/task-graph/src/task/IteratorTaskRunner.tsline 355 · from8f7dd7a
enforceEntitlements: this.task.runConfig?.enforceEntitlements
When a caller invokesmap.run(input, { registry, enforceEntitlements: true }),TaskRunnerchecks the MapTask using that run'seffectiveConfigbut does not copy the option intotask.runConfig. This therefore passesundefinedto the cloned subgraph'srun(), which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
The budget was checked after step() had already run, so the step that exceeded it executed before the refusal. It is now checked before stepping, on the status the wait loop already read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Pins that the nested runs of a fallback run directly with enforcement on are refused a denied private url, in data mode and in task mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
There was a problem hiding this comment.
Review progress ██████████ 125/125 files
Request changes — found 6 issue(s) at 8f94ee5.
Actionable comments posted: 6
🤖 Prompt for AI agents
Pull request workglow-dev/libs#1047: https://github.com/workglow-dev/libs/pull/1047 (head branch `claude/confident-lovelace-4cw5ln`)
Verify each finding against current code. Fix only still-valid concrete bugs, skip the
rest with a brief reason, keep changes minimal, and validate. Skip Decision required,
policy forks, and "consider X" alternatives. Do not add new features, refactors, or
architecture beyond the fix; prefer the smallest diff.
Findings to address:
1. In `@packages/test/src/test/task/NestedEntitlementEnforcement.test.ts` (line 286, Important):
`expectDenied()` is also used by the data-mode fallback test, but `FallbackTaskRunner` catches each alternative's `TaskEntitlementError` and throws a `TaskFailedError` from `buildAggregateError()` after attempts are exhausted. Thus this assertion (and the direct fallback assertions at lines 330 and 344) fails on a correctly denied request. Assert the aggregate failure while retaining the `fetched` check, or otherwise inspect the wrapped cause/message.
Additional findings outside changed lines:
1. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 89, Blocker):
DNS-resolved private destinations bypass the private entitlement
`classifyUrl(url)` only classifies the URL's literal hostname; it does not resolve DNS. A URL such as `http://attacker.example` therefore gets only `browser:navigate` even when its A/AAAA record resolves to `127.0.0.1` or `10.0.0.5`, and the browser backend resolves and connects to that private address without `network:private`. The landing check has the same limitation because it classifies the URL text. Unlike `SafeFetch.server.ts`, this path does not check the resolved addresses, so private services reachable from the browser host can be accessed without their entitlement.
2. In `@packages/task-graph/src/task/TaskRunner.ts` (line 434, Blocker):
Fallback executions still drop entitlement enforcement
`this.task.runConfig` is stamped here, but `FallbackTaskRunner` does not pass that value into its nested runs: task mode calls `alternativeTask.run(input, this.streamRunOptions)` and data mode calls `subGraph.run(...)` without `enforceEntitlements`. With a runtime-only URL such as `http://169.254.169.254/...` reaching a fallback fetch from upstream, the child runs without the parent enforcer and can make the denied request. The added FallbackTask tests exercise these paths, but this stamp alone cannot make their nested executions enforce the policy.
3. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 117, Blocker):
The redirect check runs after the private request
Both navigation tasks call this only after `ctx.navigate()` returns. The Playwright backend implements that with `page.goto(..., { waitUntil: "load" })`, which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates to `about:blank`. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent.
4. In `@packages/browser-control/src/task/BrowserReachPolicy.ts` (line 145, Blocker):
The file grant can name a symlink, not the file actually uploaded
`resources: filePaths as string[]`
The entitlement is checked against these original path strings, but `containUploadPaths()` resolves symlinks and passes the resolved paths to `ctx.uploadFile()` afterward. For example, a grant scoped to `/work/input.txt` allows that path when it is a symlink to `/work/.env`; if both are inside the configured root, the root check passes and the upload reads `.env` without a grant for that resolved resource. Ensure the entitlement scope covers the canonical paths that are actually uploaded.
5. In `@packages/task-graph/src/task/IteratorTaskRunner.ts` (line 355, Blocker):
Nested map run drops per-run entitlement enforcement
`enforceEntitlements: this.task.runConfig?.enforceEntitlements`
When a caller invokes `map.run(input, { registry, enforceEntitlements: true })`, `TaskRunner` checks the MapTask using that run's `effectiveConfig` but does not copy the option into `task.runConfig`. This therefore passes `undefined` to the cloned subgraph's `run()`, which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
Still open from earlier reviews (5) — the lines they point at have not changed:
- 🔴 Blocker · DNS-resolved private destinations bypass the private entitlement ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 89 · from4d32fab
classifyUrl(url)only classifies the URL's literal hostname; it does not resolve DNS. A URL such ashttp://attacker.exampletherefore gets onlybrowser:navigateeven when its A/AAAA record resolves to127.0.0.1or10.0.0.5, and the browser backend resolves and connects to that private address withoutnetwork:private. The landing check has the same limitation because it classifies the URL text. UnlikeSafeFetch.server.ts, this path does not check the resolved addresses, so private services reachable from the browser host can be accessed without their entitlement. - 🔴 Blocker · Fallback executions still drop entitlement enforcement ·
packages/task-graph/src/task/TaskRunner.tsline 434 · from4d32fab
this.task.runConfigis stamped here, butFallbackTaskRunnerdoes not pass that value into its nested runs: task mode callsalternativeTask.run(input, this.streamRunOptions)and data mode callssubGraph.run(...)withoutenforceEntitlements. With a runtime-only URL such ashttp://169.254.169.254/...reaching a fallback fetch from upstream, the child runs without the parent enforcer and can make the denied request. The added FallbackTask tests exercise these paths, but this stamp alone cannot make their nested executions enforce the policy. - 🔴 Blocker · The redirect check runs after the private request ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 117 · from8f7dd7a
Both navigation tasks call this only afterctx.navigate()returns. The Playwright backend implements that withpage.goto(..., { waitUntil: "load" }), which follows redirects and runs the landing page before this check; a public URL redirecting to a private service can therefore reach it and execute its response (including script-triggered requests) before the task throws and navigates toabout:blank. Clearing the page prevents downstream extraction but cannot undo that access; the private destination must be blocked before its redirected request is sent. - 🔴 Blocker · The file grant can name a symlink, not the file actually uploaded ·
packages/browser-control/src/task/BrowserReachPolicy.tsline 145 · from0421952
resources: filePaths as string[]
The entitlement is checked against these original path strings, butcontainUploadPaths()resolves symlinks and passes the resolved paths toctx.uploadFile()afterward. For example, a grant scoped to/work/input.txtallows that path when it is a symlink to/work/.env; if both are inside the configured root, the root check passes and the upload reads.envwithout a grant for that resolved resource. Ensure the entitlement scope covers the canonical paths that are actually uploaded. - 🔴 Blocker · Nested map run drops per-run entitlement enforcement ·
packages/task-graph/src/task/IteratorTaskRunner.tsline 355 · from8f7dd7a
enforceEntitlements: this.task.runConfig?.enforceEntitlements
When a caller invokesmap.run(input, { registry, enforceEntitlements: true }),TaskRunnerchecks the MapTask using that run'seffectiveConfigbut does not copy the option intotask.runConfig. This therefore passesundefinedto the cloned subgraph'srun(), which disables its entitlement checks; an inner task requiring a denied entitlement can execute unchecked. Propagate the active run's enforcement setting here.
…irectory The nightly Bun parity run failed "Tabular migration contract: FsFolder > backfill: every existing row is rewritten" on its retry, in 13 ms, with no row rewritten. Bun retries a failed test (bunfig retry = 1) while the attempt that timed out may still be running, and the FsFolder handle read its shared "current directory" and "already pre-populated" state after awaits. The stale attempt followed the retry into its new directory, marked it pre-populated and applied the migration there, so the retry's backfill was skipped as already applied and its rows stayed unmigrated (or, in another interleaving, were doubled twice). The handle now keeps the directory a call chose in a local before any await, and records pre-population per directory. It moves to its own module so a regression test can drive it: a stale attempt left running must not write into the retry's directory, and the retry's backfill must run. That test fails every time against the old handle and passes against this one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Mergestorm reviews this repository's pull requestsEvery PR gets AI code review that flags real bugs before they merge. This account's 30 free credits for this monthly window are used, so reviews are paused at The Starter plan includes 120 credits a month. |
…policy A new tab given a url navigates as surely as BrowserNavigateTask, but it declared no entitlement and checked nothing, so file:, data: and private hosts were reachable through it. It now refuses a url assertNavigableUrl refuses, declares browser:navigate (and network:private for a private host) scoped to its url, and closes a tab that landed on a private host it did not declare. A blank tab declares nothing; the class declares the unscoped family, since it is read before any url is known. The page reset after a refused landing (navigate, login) is no longer allowed to throw over the refusal it follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
createSlots freed a slot and woke the next waiter, which took it a tick later; a caller arriving in that tick took it too, so the limit was exceeded. The finishing call now hands its slot to the waiter, and frees it only when nobody waits. Moved to its own module so the handover is tested directly, on both runners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
FetchUrlTask is cached by default; what runs uncached is the fetch an HTTP search provider owns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
A root of / was checked as the prefix //, which no path has, so FileLoader, FileGrep and FileSed refused every file under it. A root already ending in its separator is used as the prefix as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
In task mode an alternative is run directly rather than through the subgraph runner, and was handed only the stream options: cancelling the fallback stopped the next attempt but not the one in flight, and the alternative resolved services from the global registry and its own resource scope. It is now handed the run's signal, registry, resource scope, output cache and entitlement enforcement, as a subgraph is. Data mode gains the resource scope and enforcement it also lacked, and both modes read the signal from the run context they were given rather than the runner's current one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
An ordering comparison (less_than and friends) against a compare value that is not a number always evaluates false, and false ends a WhileTask: the loop ran one pass and the run succeeded with that pass's output, marked only by a warning. The compare value is fixed by the config, so the condition is now refused with a TaskConfigurationError when it is built, before any pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ
Every task class in libs (178 across 9 packages) was evaluated for developer friendliness, correctness and performance. This branch holds what came out of it. The findings, grades and decision records are in the prd repo under
analysis/task-evals/2026-11-05/.Each commit is one change, with a test written to fail first.
Fixes made during the evaluation (21 commits,
9a88e17…11e6a96)Bugs with no product question attached. The ones with the most effect:
0.isError.{{secret}}cannot pull in another key.RangeErroron 10⁶ values.ai:modelentitlement is scoped to the model a run actually uses.cacheable: false.Errors.The 19 decided product changes (
548270b…ec49928)Changes that refuse or alter things that work today:
StringTemplateTaskis removed. Saved graphs naming it no longer load;TemplateTaskreplaces it.McpToolCallTaskreturns{ content, isError, structured }. Wiring that read a tool field from the top level readsstructured.<field>instead.ArrayTaskrequiresmaxIterations. The number of combinations is checked against it before any task is built.nullwhere an iteration produced nothing; a declared port no iteration produced still gets onenullper item.QuickTask,ParallelTask) refuse to serialize, where before they saved and then failed to load.LambdaTask, always.JavaScriptTask, unlessconfig.cacheableis set.browser:navigateandnetwork:privatescoped to the destination.BrowserLoginTaskis a real manual login: it navigates, then waits on a confirm.HumanApprovalTaskasks with aconfirmrather than a form.TaskConfigurationError, for input, config andGraphAsTaskalike.T[] | null,T | T[]) collects multiple incoming dataflows. On union ports collection starts at the second delivery, so a single value is unchanged.concurrency, default 4); RerankerTask and QueryExpanderTask require no model capability.StringLengthTaskunit(code units / code points / graphemes);StringConcatTaskjoins in declared-port order.Fixes from review
enforceEntitlements, scoped to that run.config.outputSchemaholds an MCP node's ports, andmcpToolOutputPortSchemais idempotent, so builder and libs agree on them.Fixes from a second review and the deferred list (
7912e90…e9cad38)BrowserNewTabTaskfollows the navigation reach policy (7912e90). A tab given a URL navigates as surely asBrowserNavigateTaskdoes, but it checked nothing. It now refuses what navigate refuses and declaresbrowser:navigatescoped to its URL. A tab that lands on an undeclared private host is closed; on a single-page backend (Electron, bun-webview) closing it ends the session. Navigate and login no longer let a failing page reset replace the refusal it follows.86b11a2). A slot that changed hands was briefly open to a second caller. The slot is now handed straight to the waiter; the test fails on the old code under both runners.ae3c704).LocalFilePathchecked a root of/as the prefix//, so FileLoader, FileGrep and FileSed refused every file under it.f482d33). In task mode an alternative got none of the run's context: cancelling the fallback left the attempt in flight running, and it used the global registry and its own resource scope. It now gets the run's signal, registry, resource scope, output cache and entitlement enforcement. Data mode gains the scope and enforcement it also lacked.e9cad38). An ordering comparison against a non-numeric value is always false, which ended the loop after one pass with the run reported as successful. It is now aTaskConfigurationErrorwhen the condition is built.87e5512).Also fixes the nightly Bun parity failure on
main9928a14: the nightly Bun parity job onmain(run 42235386392) failedTabular migration contract: FsFolder > backfill: every existing row is rewrittenon its retry.retry = 1) while the attempt that timed out may still be running. The FsFolder test harness read its shared "current directory" state afterawaits, so the stale attempt followed the retry into its directory and applied the migration there first. The retry's backfill was then skipped as already applied.Known gap: browser request interception
Browser navigation is checked against the URL asked for and the URL the page lands on. It does not stop these:
Refusing those before they're sent needs request interception in each browser backend (
page.routein Playwright,webRequestin Electron, and so on), checked against the declared scope and the resolved address. That changes theIBrowserContextcontract across all four backends, so it is a separate follow-up by decision.Verification
bun run build:types,bun run typecheck:tests,bun scripts/typecheck-budget.tsandbun run lintare clean.bun scripts/test.ts bun unit --except rag,browser,provider-hft,provider-nodellama,provider-api,provider-cactus, passes: 9,611 tests across 691 files, 0 failing. The touched sections run under Bun also pass: 4,604 tests across 370 files. The later commits' tests pass under both runners.chromium_headless_shell-1248is missing), with or without these changes.Companion PRs
@workglow/mcp0.6.15 and after this lands.🤖 Generated with Claude Code
https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ