Skip to content

Task evaluation: fixes across every task class, and the 19 decided product changes - #1047

Merged
sroussey merged 65 commits into
mainfrom
claude/confident-lovelace-4cw5ln
Nov 7, 2026
Merged

sroussey merged 65 commits into
mainfrom
claude/confident-lovelace-4cw5ln

Conversation

@sroussey

@sroussey sroussey commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • task-graph
    • Dynamic input schemas are compiled once per class, not on every validation (about 65–87 µs down to about 5 µs per validation).
    • A blank condition operand is no longer read as 0.
    • A streaming group stops its subgraph when the reader leaves, and its failure no longer becomes an unhandled rejection that kills a Node process.
    • Entitlements are enforced inside MapTask, ReduceTask and WhileTask.
  • javascript: a script that never ends no longer holds the event loop; it runs in time slices, so abort works.
  • mcp
    • A failed tool call can no longer be reported as a success by a tool field named isError.
    • A tool's schemas are discovered once; before, every call opened a second connection.
  • tasks
    • String templates substitute once, so user text such as {{secret}} cannot pull in another key.
    • ScalarMax/Min no longer throw a RangeError on 10⁶ values.
    • Plus input-error fixes in DateFormat, Json, Lambda and Delay.
  • ai
    • The ai:model entitlement is scoped to the model a run actually uses.
    • A seeded image generation honours cacheable: false.
    • Several refusals are now task errors instead of bare Errors.
  • browser-control and a2ui: bad input and missing setup now raise task errors.

The 19 decided product changes (548270b…ec49928)

Changes that refuse or alter things that work today:

  • StringTemplateTask is removed. Saved graphs naming it no longer load; TemplateTask replaces it.
  • MCP output is namespaced. McpToolCallTask returns { content, isError, structured }. Wiring that read a tool field from the top level reads structured.<field> instead.
  • ArrayTask requires maxIterations. The number of combinations is checked against it before any task is built.
  • Map output stays index-aligned. Each port gets one slot per item, with null where an iteration produced nothing; a declared port no iteration produced still gets one null per item.
  • ConditionalTask: a branch condition that throws now fails the task instead of reading as "not taken".
  • Function-built graphs (QuickTask, ParallelTask) refuse to serialize, where before they saved and then failed to load.
  • Not cached any more:
    • Readers of a changing source: file loader, grep and sed, KB search and retrieval, web search, and the fetch each of them owns.
    • LambdaTask, always.
    • JavaScriptTask, unless config.cacheable is set.
  • Browser-control
    • Navigate and login refuse non-http(s) URLs and URLs the classifier cannot place (such as ones carrying credentials). They declare browser:navigate and network:private scoped to the destination.
    • A navigation that lands on an undeclared private host is refused, and the page is cleared.
    • Upload is confined to configured roots, and declares the real file each path resolves to.
    • BrowserLoginTask is a real manual login: it navigates, then waits on a confirm.
  • Refused inputs:
    • an empty list for max/min;
    • anything other than exactly two vectors for distance;
    • a zero divisor element in vector divide;
    • a chunk overlap at least as large as the chunk;
    • resize/border output past the image pixel budget (schema maxima 16384 per side for resize, 8192 for a border);
    • a JavaScript run past its step budget. Time spent waiting on a worker or a timer is not counted, and a preview stops once only timers are left.
  • HumanApprovalTask asks with a confirm rather than a form.
  • A schema that will not compile fails closed with TaskConfigurationError, for input, config and GraphAsTask alike.
  • Fan-in: a port that admits an array (T[] | null, T | T[]) collects multiple incoming dataflows. On union ports collection starts at the second delivery, so a single value is unchanged.
  • DocumentEnricherTask bounds its model calls (concurrency, default 4); RerankerTask and QueryExpanderTask require no model capability.
  • New settings: StringLengthTask unit (code units / code points / graphemes); StringConcatTask joins in declared-port order.

Fixes from review

  • An aborted Map/iterator run fails. Before, it read the abort signal through a context the abort had cleared, and resolved with full results.
  • Entitlement enforcement reaches nested work when a task is run directly with enforceEntitlements, scoped to that run.
  • config.outputSchema holds an MCP node's ports, and mcpToolOutputPortSchema is idempotent, so builder and libs agree on them.
  • The typecheck budget and the co-located test typecheck are back within limits.

Fixes from a second review and the deferred list (7912e90…e9cad38)

  • BrowserNewTabTask follows the navigation reach policy (7912e90). A tab given a URL navigates as surely as BrowserNavigateTask does, but it checked nothing. It now refuses what navigate refuses and declares browser:navigate scoped 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.
  • DocumentEnricherTask stays within its concurrency (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.
  • A filesystem root contains the files beneath it (ae3c704). LocalFilePath checked a root of / as the prefix //, so FileLoader, FileGrep and FileSed refused every file under it.
  • FallbackTask alternatives run inside the run (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.
  • A while condition that can never hold is refused (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 a TaskConfigurationError when the condition is built.
  • Two comment fixes (87e5512).

Also fixes the nightly Bun parity failure on main

9928a14: the nightly Bun parity job on main (run 42235386392) failed Tabular migration contract: FsFolder > backfill: every existing row is rewritten on its retry.

  • The cause was the test, not storage. Bun retries a failed test (retry = 1) while the attempt that timed out may still be running. The FsFolder test harness read its shared "current directory" state after awaits, 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.
  • The fix: the harness keeps the directory it chose in a local and records pre-population per directory.
  • Regression test: it fails every time against the old harness and passes against the new one.

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:

  • Redirects: the request to a private host has already been sent by the time the landing check runs.
  • Later navigations: ones a page makes on its own, by script or by a click task.
  • Subresources: images, fetches and iframes the page loads.
  • DNS: a public hostname that resolves to a private address.

Refusing those before they're sent needs request interception in each browser backend (page.route in Playwright, webRequest in Electron, and so on), checked against the declared scope and the resolved address. That changes the IBrowserContext contract 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.ts and bun run lint are clean.
  • These test sections pass under Vitest: graph, task-graph, task, ai, rag, mcp, browser, web-search.
  • Bun parity: the nightly job's own command, 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.
  • Not verified: the real-browser path of the browser-control changes. The Playwright integration tests cannot launch in the environment these were written in (chromium_headless_shell-1248 is missing), with or without these changes.

Companion PRs

  • workglow-dev/builder#542: draws MCP nodes' ports from the task. It works on the pinned @workglow/mcp 0.6.15 and after this lands.
  • workglow-dev/prd#119: the evaluation run and the decision records.

🤖 Generated with Claude Code

https://claude.ai/code/session_015q4aqYCbFQNwRKftssinzQ

claude added 30 commits November 5, 2026 18:22
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
@mergestorm-vortex

Copy link
Copy Markdown

Starting the review on the remaining files now. The last Vortex pass at 347b4d4 reviewed 57 of 122 files and posted 6 findings, and the head hasn't moved since. Findings from this run will appear on the PR as usual.

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

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

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.

Mergestorm: 30 free reviews a month

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
…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

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

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.

Mergestorm: 30 free reviews a month

…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

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

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.ts line 117 · from 8f7dd7a
    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.
  • 🔴 Blocker · Nested map run drops per-run entitlement enforcement · packages/task-graph/src/task/IteratorTaskRunner.ts line 355 · from 8f7dd7a
    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.

Mergestorm: 30 free reviews a month

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

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

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.ts line 145 · from 0421952
    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.
  • 🔴 Blocker · The redirect check runs after the private request · packages/browser-control/src/task/BrowserReachPolicy.ts line 117 · from 8f7dd7a
    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.
  • 🔴 Blocker · Nested map run drops per-run entitlement enforcement · packages/task-graph/src/task/IteratorTaskRunner.ts line 355 · from 8f7dd7a
    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.

Mergestorm: 30 free reviews a month

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

@mergestorm-vortex mergestorm-vortex Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto land this PR in Mergestorm

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.ts line 89 · from 4d32fab
    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.
  • 🔴 Blocker · Fallback executions still drop entitlement enforcement · packages/task-graph/src/task/TaskRunner.ts line 434 · from 4d32fab
    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.
  • 🔴 Blocker · The redirect check runs after the private request · packages/browser-control/src/task/BrowserReachPolicy.ts line 117 · from 8f7dd7a
    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.
  • 🔴 Blocker · The file grant can name a symlink, not the file actually uploaded · packages/browser-control/src/task/BrowserReachPolicy.ts line 145 · from 0421952
    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.
  • 🔴 Blocker · Nested map run drops per-run entitlement enforcement · packages/task-graph/src/task/IteratorTaskRunner.ts line 355 · from 8f7dd7a
    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.

Mergestorm: 30 free reviews a month

…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-vortex

mergestorm-vortex Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Mergestorm reviews this repository's pull requests

Every 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 e9cad38 and resume automatically on Dec 1 (UTC).

The Starter plan includes 120 credits a month.

Upgrade for more reviews →

…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
@sroussey
sroussey merged commit bed6468 into main Nov 7, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants