---
title: "M2 implementation plan — delivery parity (removes the flag)"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/m2-implementation-plan
markdown: https://docs.nodetool.ai/m2-implementation-plan.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/m2-implementation-plan.md
---

# M2 implementation plan — delivery parity (removes the flag)

# M2 implementation plan — delivery parity (removes the flag)

Task breakdown for milestone M2 of
[sandbox-package-design.md](sandbox-package-design.md). M1 makes declared
sandbox modules importable on the server and CLI behind the
`NODETOOL_SANDBOX_MODULES_V1` parity flag; the browser runner refuses
them. M2 delivers module source to the browser, runs the same
loading/denial contract there, and removes the flag. Parity restored is
the exit criterion.

> **Landed.** Tasks 1, 2 and 5 in `126eceb`; tasks 3, 4 and 6 in the commit
> that carries this banner. The `NODETOOL_SANDBOX_MODULES_V1` flag no longer
> exists; see the M2 checkpoint in
> [sandbox-package-design.md](sandbox-package-design.md).

Grounding: browser runs execute the kernel `WorkflowRunner` client-side
through `runBrowserWorkflow` (`packages/workflow-runner/src/browser.ts`),
routed by `web/src/lib/workflow/browserWorkflowRunner.ts` and
`browserRunnerCore.ts` (worker first, main-thread fallback).
`ProcessingContext` reads the process catalog default, which nothing sets
in the browser — the browser catalog is the missing piece, not the
runner.

## Task 1 — `authorizeDelivery`: the catalog's delivery half

The design deferred this method until a route exists; M2 is that route.

- Extend the `SandboxModuleCatalog` contract
  (`packages/runtime/src/sandbox-module-catalog.ts`) with the
  asynchronous, entitlement-aware operation:
  `authorizeDelivery(moduleId, requester): Promise<AuthorizedSandboxModuleDelivery | SandboxDeliveryRefusal>`.
  Authorization and retrieval are one operation, so the checks cannot
  drift apart.
- `AuthorizedSandboxModuleDelivery` (schema in
  `packages/protocol/src/sandbox-package.ts`) carries browser-safe
  content (JS source now; bytes later for WASM), media type, the
  module-graph digest, and dependency module ids — never a filesystem
  path.
- The node-sdk implementation (`createSandboxModuleCatalog`) resolves
  the opaque module id against the discovery graph. v1 entitlement
  policy: any authenticated client of this server may fetch modules of
  installed packs; the seam exists so a later private-pack policy is a
  policy change, not a plumbing change.

Tests: `sandbox-module-catalog.test.ts` — unknown id, internal file id
(delivered — the browser loader needs the whole graph), digest matches
the resolution's digest.

## Task 2 — Server route: `GET /api/sandbox-modules/:moduleId`

- New route module in `packages/websocket/src/routes/` following the
  existing route pattern (`routes/assets.ts` et al). Delivery is by
  **opaque module id**, never path segments: scoped names contain `/`
  and encoded slashes are router-hostile.
- The route resolves through the catalog's `authorizeDelivery` only —
  no route-to-filesystem translation. Responses carry the content
  digest (header) and long-lived caching headers keyed on it;
  unknown id → 404; refusal → 403.
- The registry summary field (`sandboxModules: string[]`) and
  per-file hashes stay out of scope (ecosystem milestone).

Tests: websocket route tests — happy path, 404, digest header, and the
response never containing an absolute path.

## Task 3 — Browser catalog: fetch, verify, cache

- A web-side `SandboxModuleCatalog` implementation
  (`web/src/lib/workflow/sandboxModuleCatalog.ts`): fetches by module
  id from the route, **verifies the content digest client-side**
  (`crypto.subtle`), and caches by digest (in-memory; digest-keyed, so
  a pack upgrade is a natural miss).
- `resolveForExecution` is synchronous by contract, and fetching is
  not: the run path prefetches. `runBrowserGraphJob` /
  `updateBrowserJobNodeProperties`
  (`web/src/lib/workflow/browserWorkflowRunner.ts`) collect the
  `packages` declarations from the graph's Code nodes, prefetch every
  declared module and its dependency ids before the run starts, then
  hand the warmed catalog to the run's `ProcessingContext` (both the
  worker and the main-thread fallback — the worker needs the
  declarations or the fetched sources passed across the boundary).
- A fetch or digest failure before the run is a job error naming the
  pack, same wording as the server's resolution errors.

Tests: `browserWorkflowRunner.test.ts` (prefetch collection, failure
surfaces as job error), catalog unit tests with a mocked route (digest
mismatch rejects and does not cache).

## Task 4 — One contract, two runtimes: shared fixtures

- Extract the M1 loading/denial fixtures (declared-import happy path,
  intra-pack helper, `node:*` denial, compat-module denial, computed
  dynamic import, path escapes, undeclared specifier, hardening order)
  into data-driven fixture definitions importable from both suites —
  home: `packages/agents` with a browser-safe export, mirroring the
  `js-sandbox` subpath pattern.
- Node side: the M1 vitest suite consumes the shared fixtures.
- Browser side: a Playwright leg in the existing e2e-runner harness
  (`web/src/e2e_runner/`, `npm run test:e2e-runner`) runs the same
  fixtures through a real browser QuickJS, via a fixture pack the
  e2e server has installed.

Exit gate for the milestone: both suites green on identical fixture
data.

## Task 5 — Electron staging for bundled builtins

- `scripts/bundle-backend.mjs` learns to stage sandbox module files of
  **bundled builtin** packs under `_sandbox/<pack>/`, and
  `scripts/verify-backend-bundle.mjs` verifies them. Installed packs
  are unaffected (they resolve through the optional-node root as
  today).
- No builtin ships sandbox modules yet, so this lands as mechanism
  plus a fixture-driven verification test; `npm run backend:smoke`
  stays the proof that staging did not break the artifact.

## Task 6 — Remove the flag

- Delete `NODETOOL_SANDBOX_MODULES_V1`, the validation rollout issue
  ("Sandbox package imports are unavailable in the browser runner…"),
  and the browser-runner refusal from M1's Task 5. The
  property-aware eligibility check stays only if the flag removal
  leaves no other use for it.
- Because M1 wrote nothing persistent (no node platform metadata),
  removal is code-only — workflows saved during M1 need no migration.
- Docs: design doc status line moves to "M2 implemented"; CLAUDE.md
  browser-runner notes updated in the same PR.

Tests: the M1 rollout-issue and refusal tests flip to asserting the
paths are gone; `browser-platform.test.ts` gains the positive case.

## Sequencing

Task 1 → Task 2 → Task 3 form the delivery chain. Task 4 needs 3
(browser loading exists) and is the parity proof. Task 5 is
independent and can run any time. Task 6 lands last — the flag comes
out only after Task 4's both-runtimes gate is green.

## Exit criteria

- The M1 fixture pack's module imports and runs in the browser runner,
  fetched by opaque id, digest-verified, cached by digest.
- The shared denial fixtures fail identically on Node and in the
  browser.
- The parity flag, the rollout validation issue, and the browser
  refusal no longer exist in the tree.
- `npm run check` and the e2e-runner leg green.
