diff --git a/APPS_DESIGN.md b/APPS_DESIGN.md new file mode 100644 index 000000000..9d1a7f3a9 --- /dev/null +++ b/APPS_DESIGN.md @@ -0,0 +1,339 @@ +# Executor apps — self-hosted build (design record) + +Durable record of the architecture, seam signatures, package layout, key +decisions, verification commands, and known gaps for the executor apps +subsystem built into the self-hosted deployment. + +Note: the brief asked for `DESIGN.md`, but the repo already tracks a +design-system doc at `design.md`, and this repo lives on a case-insensitive +filesystem (`DESIGN.md` and `design.md` collide). To avoid clobbering the +design system, this apps architecture record lives at `APPS_DESIGN.md`. + +## What this is + +User-authored, git-backed, published units — **custom tools**, **durable +workflows**, **UI views**, **skills** — published into a per-scope store and +served/executed by the self-hosted platform. Identity is the file path +(`tools/issues-sync.ts` IS the tool `issues-sync`). Publish is the compiler +(FDI): catalog entries, schedules, ui resources and the skills index are +projections of a versioned descriptor extracted from source at publish. + +The subsystem lives in one package, `@executor-js/plugin-apps`, wired into the +self-host app the same way every other plugin is (a source plugin in +`executor.config.ts`, HTTP routes as an extension, MCP tools/resources through +the MCP build hook). Everything substrate-specific sits behind a **seam** with +a substrate-neutral interface and a conformance suite that runs against the +interface, so future Cloudflare backings drop in without touching the +subsystem's logic. + +## The five seams + +Each seam is a substrate-neutral interface. Self-hosted backings are built now; +cloud backings are future. Everything crossing `ToolSandbox` is serializable +(the cloud version is RPC). + +| Seam | Self-hosted backing (built) | Cloud backing (future) | +| ---------------- | -------------------------------------------------------------------------------- | -------------------------------- | +| `ArtifactStore` | bare git repo per scope on disk (git CLI subprocess); `SnapshotId` = commit hash | Cloudflare Artifacts | +| `ScopeDb` | one libSQL/SQLite file per scope + per-table version counters | DO facets | +| `ToolSandbox` | QuickJS kernel (collect + invoke via the `SandboxToolInvoker` bridge) | Worker Loaders | +| `WorkflowRunner` | SQLite event-sourced journal replay runner + in-process scheduler | CF Workflows + dynamic-workflows | +| `LiveChannel` | in-process emitter + SSE | DO/facet socket owner | + +See `src/seams/*.ts` for the exact interfaces and `src/seams/*.conformance.ts` +for the suite each backing must pass. + +## Decisions (and why) + +- **Sandbox = QuickJS** (`packages/kernel/runtime-quickjs`). Its + `CodeExecutor.execute(code, toolInvoker)` already gives the serializable + handle bridge: `SandboxToolInvoker.invoke({path, args})` crosses as JSON, + `tools.<...>()` is a Proxy in the sandbox, `fetch` is disabled, there is a + deadline interrupt and a memory cap. secure-exec evaluated and rejected + (pre-1.0, per-arch native sidecar, flat string bridge fighting the Proxy + pattern); the Deno subprocess kernel is the documented harder-isolation + escalation behind the same seam. Because QuickJS evaluates a _string_, the + collect/invoke wrappers own the module shape: the published bundle is a + self-executing script that either records `define*()` descriptors (collect) + or calls one handler with injected clients (invoke). +- **Storage via host facades, not new tables.** Executor plugins deliberately + do not contribute FumaDB tables (`collectTables()` is fixed and + plugin-independent). App metadata (descriptors, snapshot pointers, schedules, + workflow journal, ui metadata) lives in `pluginStorage` collections; large + opaque blobs (compiled bundles, snapshot manifests) live in the `blobs` + facade, content-addressed by SHA-256. +- **ScopeDb is separate from the executor DB.** App _data_ (the `issues` table + authors read/write) is one libSQL file per scope, independent of the + executor's own DB. Per-table version counters live alongside it and drive + `LiveChannel`. +- **Apps are a plugin source.** A published app maps to one executor + _integration_ per scope (`apps`); a _connection_ to it makes the published + tools catalog citizens through `resolveTools`/`invokeTool`, so + policy/approval/audit/toolkits/tools.list all apply unchanged. +- **No @effect/workflow.** The local runner is a purpose-built SQLite + event-sourced journal modeled on vercel/workflow's `World` Storage contract + (append-only events, materialized run/step views, replay-on-resume). + +## Package layout + +``` +packages/plugins/apps/ + src/ + seams/ ArtifactStore, ScopeDb, ToolSandbox, WorkflowRunner, LiveChannel + + one .conformance.ts per seam (runs against the interface) + backing/ the self-hosted backing for each seam + their *.test.ts wiring + (git-artifact-store, libsql-scope-db, quickjs-tool-sandbox, + sqlite-workflow-runner, in-process-live-channel, + sqlite-apps-store) + the SIGKILL kill test + pipeline/ discover -> bundle -> collect -> project (publish = the compiler) + plugin/ runtime (the substrate-neutral core), bindings (connection DI), + store, apps-plugin (source: resolveTools/invokeTool), + self-host-runtime + self-host (one-call wiring) + http/ publish + invoke + ui-bundle + SSE + workflow routes (web handler) + mcp/ publish door, skills list/read, ui:// resources over MCP + testing/ in-memory store, github REST resolver, daily-brief fixtures, + kill-child harness, the e2e proof +``` + +The subsystem is wired into `apps/host-selfhost/src/apps.ts` and mounted in +`app.ts` (extension route `/api/apps/*` + close hook). `apps.node.test.ts` boots +the real self-host server and drives publish -> ui-bundle -> tool-invoke. + +## Seam signatures (the substrate-neutral contracts) + +- `ArtifactStore.forScope(scope) -> ScopeArtifactStore { commit(files,msg) -> +SnapshotMeta; read(id) -> FileSet; readFile; list; latest; log }`. SnapshotId = + git commit hash. +- `ScopeDb.forScope(scope) -> ScopeDbHandle { sql`...`; exec; tableVersion; +versions }` + `onWrite(listener)` (write events carry per-table versions). +- `ToolSandbox { collect(bundle) -> CollectResult; invoke(bundle, request, +HandleBridge) -> InvokeResult }`. `HandleBridge.call({root, path, args}) -> +Effect` is the ONLY thing crossing the boundary — all JSON. +- `WorkflowRunner { start(input, execute, bindings); resume; signal; cancel; get; +list; listSteps }`. `execute(DurableSteps) -> Promise` is the body; `bindings` + = `{ runTool, notify }` reach the outside for `step.tool`/`step.notify`. +- `LiveChannel { publish(Invalidation); subscribe(scope, listener) }`. + +## Two decisions worth review + +1. **Workflow orchestration runs in-process; tool handlers run in the sandbox.** + The durable body (`step.do`/`step.tool`/`step.sleep`/`step.waitForEvent`) is + evaluated in-process via a trusted `new Function` shim because `step.do(name, +() => ...)` closures cannot cross the sandbox boundary (the same reason CF + runs the orchestrator in a constrained isolate with the journal as an external + service). The real side-effectful work — every custom tool a `step.tool` calls + — runs in the QuickJS sandbox with bound clients. The durability guarantee + (journal + replay, proven by the SIGKILL kill test) is fully real. Flagging + for review: hardening the orchestrator into the sandbox too (a `step` proxy + bridged like the injected clients) is the natural follow-up if workflow bodies + must be as isolated as tool handlers. + +2. **The `ClientResolver` seam is where "policy/audit applies".** A tool's + injected clients route method calls through `ClientResolver.call({integration, +connection, path, args})`. In the e2e this is a real authenticated HTTP call + to the emulate GitHub (proven via the emulator's request ledger). In the + running self-host server the resolver returns a typed NotImplemented for + external integrations because wiring it to the executor catalog needs + per-request executor context the boot-time plugin construction does not hold. + The scope-database path (`db.sql`) is fully live in the running server. + Flagging for review: the clean fix is a host-provided per-request invoke + function (executor.execute by address) handed to the resolver. + +## Verification gates — exact commands + results + +Run from the workspace root +(`usefulsoftwareco/.rifts/executor/apps-build-b`): + +- **Typecheck (repo root):** `bun run typecheck` -> 43/43 tasks green. +- **Apps package (conformance + kill + pipeline + integration + e2e):** + `bun run --filter='@executor-js/plugin-apps' test` -> 11 files, 44 tests pass. + - ArtifactStore conformance (round-trip, snapshot immutability, log, isolation) + - ScopeDb conformance (isolation, version bumps, tagged-template sql, + LiveChannel delivery) + - ToolSandbox conformance (determinism catches Math.random, network denial, + timeout kill, handle-bridge round-trip incl. fan-out arrays) + - WorkflowRunner conformance (memoization, sleep, waitForEvent+signal, retry, + step.tool journaling) + the SIGKILL kill test (side-effect file written once) + - publish pipeline (daily-brief -> descriptor; rejects npm imports + bad skill) + - AppsRuntime end-to-end (publish -> invoke into scope db -> workflow) + - the package e2e proof (real GitHub emulator: publish MCP, invoke HTTP, + workflow, ui MCP-Apps HTML document + raw bundle, SSE invalidation, skills MCP) +- **Self-host suite (existing + the new booted wire e2e):** + `bun run --filter='@executor-js/host-selfhost' test` -> 20 files, 82 tests pass + (75 original + 7 in the new `apps-wire.node.test.ts`). + +The package e2e is the single-command in-package proof: +`bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts`. + +### Proof over the wire (Fix 5) — booted host, real MCP client + +`apps/host-selfhost/src/apps-wire.node.test.ts` boots the ACTUAL self-host app +(the same composition `serve.ts` uses), connects a REAL MCP client +(`@modelcontextprotocol/sdk` `Client` + `StreamableHTTPClientTransport`) to the +served `/mcp` endpoint, and drives the whole subsystem over the wire (no +`FakeMcpServer`): + +- publish the daily-brief file set over the `apps_publish` MCP door; +- wire the scope into the catalog via the `executor.apps.connect_catalog` + built-in (registers the `apps` integration + creates the `apps/` + connection through the caller's request context); the published `issues-sync` + tool becomes a searchable catalog citizen (`tools.apps.user.appsdefault.…`); +- invoke it through the catalog path (`execute` sandbox) — the GitHub emulator's + request ledger proves the upstream call landed and the scope db is written; +- start `morning-sync` (manual) through the `executor.apps.start_workflow` + built-in — it completes with a journaled `tool:issues-sync` step; +- read the `ui:///dashboard` resource over MCP (`resources/read`); +- observe the SSE `invalidate` frame over HTTP after a scope-db write; +- list + read the published skill over MCP. + +Single command: +`bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts`. + +### Proof of the widget mounting (MCP Apps host simulation) + +`e2e/mcp-apps/` is a [sunpeak](https://github.com/Sunpeak-AI/sunpeak) harness +that mounts the published dashboard in a headless replica of the Claude and +ChatGPT MCP-Apps host runtimes (sandboxed iframe + real host bridge, no VM, no +account). `apps/host-selfhost/scripts/mcp-apps-serve.ts` boots the real self-host +in-process, publishes the daily-brief app, populates the scope-db `issues` table +from a GitHub emulator, and serves `/mcp` on a fixed loopback port with the +Better-Auth bearer injected. The spec renders the `apps_open_ui` tool (whose +`_meta.ui.resourceUri` links to the ui resource) and asserts the widget MOUNTS +and RENDERS the scope-db rows (`2 open issues`, `…/app#`) in BOTH host sims. + +Single command (isolated from the bun workspace; uses the latest sunpeak with NO +patch script — sunpeak now advertises the MCP-Apps UI client capability +upstream): + +``` +cd e2e/mcp-apps && npm install && npm test +``` + +### Shape fixes made to the MCP Apps serving (in scope, Fix 5) + +Bringing the serving up to what a real host (per sunpeak) expects surfaced three +shape bugs, now fixed: + +1. **ui resource was a fixed URI, not a template.** `registerResource` was + passed the literal string `ui:///`, which only matches itself, so + `resources/read` of `ui:///` 404'd. Now a real + `ResourceTemplate("ui:///{name}")` (mcp/register.ts). +2. **ui resource served raw compiled JS, not a document.** A real MCP-Apps host + mounts an HTML document, not a CJS blob. `mcp/ui-shell.ts` now wraps the + compiled bundle into a complete, self-booting `text/html;profile=mcp-app` + document: React + a minimal `executor:ui` runtime (`useQuery`/`useTool`/ + `config`) + component primitives + the current scope-db rows, all inlined + (hermetic — renders under a strict sandbox CSP with no network). Served by the + new `AppsRuntime.getUiDocument`. +3. **no tool linked a host to the ui resource.** MCP-Apps hosts render a view + when a tool declaring `_meta.ui.resourceUri` runs. Added `apps_open_ui` + (mcp/register.ts) carrying that extension. + +The scope <-> apps-connection mapping was also fixed: the executor normalizes +connection names to camelCase identifiers, so the old `apps/` form did +not survive create (`apps/default` -> `appsDefault`) and `resolveTools` could not +recover the scope. The mapping is now identifier-native (`apps` + PascalCase +scope), round-tripping cleanly (apps-plugin.ts). + +The running-server external-integration routing (previously a documented +NotImplemented gap) is now LIVE: `apps/host-selfhost/src/apps-resolver.ts` +resolves an integration's base URL from its registered record's config, so a +published tool's `github.*` calls dispatch through the caller's connection + +credentials to the real upstream (proven end-to-end against the emulator in the +wire e2e). The per-request resolver is threaded into both the catalog tool-invoke +path AND the workflow `step.tool` path (via `startWorkflow`'s new `resolver`). + +## Security model + limits (pre-PR hardening) + +The apps subsystem is authored content executed and served by the platform, so +its surfaces are hardened as follows. + +- **Authenticated HTTP surface.** The apps HTTP routes (`/api/apps/*` — + publish / tool-invoke / workflow lifecycle / ui bundle + document / SSE) sit + behind the SAME Better Auth identity the rest of `/api` requires. `app.ts` + builds an `authenticate(request)` from the resolved Better Auth instance + (session cookie, Bearer session token, or Bearer-as-`x-api-key`, matching + `betterAuthIdentityLayer`) and threads it into the apps handler; the handler + answers any unauthenticated request with `401` before any route logic runs, + SSE included. The subsystem is a boot singleton first built (auth-less) by + `executor.config.ts` then reconfigured by `app.ts`, so the handler reads a live + auth ref per request and is **fail-closed** (denies) until the ref is set. The + `mcp-apps-serve.ts` demo keeps its own bearer injection. +- **UI data-island XSS.** The mounted document inlines rows/title into an inline + `` can never break out of the element. +- **Workflow single-driver lease.** A run driven concurrently + (start/resume/signal racing) takes a per-run SQLite lease (atomic + `INSERT .. ON CONFLICT DO UPDATE .. WHERE expired`) so exactly one driver + executes a run; a second waits then re-drives (replaying the journal). An + unjournaled side-effecting `step.tool` runs exactly once. +- **Strict credential binding.** The resolver matches the bound connection by + name EXACTLY — never a `conns[0]` fallback — and fails a missing/misnamed + binding with a typed `BindingError` (role + surface) and makes no upstream + call. The binding contract is: a role defaults to a connection of the same name + as its integration. +- **Pinned resume.** A run persists its `snapshotId` AND its start-time bindings; + `resume`/`signal` rebuild the descriptor from that pinned snapshot and reuse the + stored bindings, so a republish between start and signal never runs different + code or drops credentials. +- **Publish atomicity + CAS.** The git ref advance is a compare-and-swap + (`git update-ref `); a raced writer gets a typed conflict + rather than a silent clobber. Publishes to one scope are additionally + serialized in-process so the committed head and the descriptor pointer always + agree. +- **Publish payload limits.** Enforced before any work (`enforcePublishLimits`): + 256 files, 1 MiB/file, 4 MiB total. An oversized set fails with a typed + diagnostic and nothing is persisted. +- **Cron validation.** Cron fields are validated at PUBLISH time (`validateCron`: + step >= 1, bounded/non-reversed ranges) and defensively in the scheduler + (`cronMatches` never throws or loops on adversarial input like `*/0`, negative + steps, or huge ranges). +- **Scope ↔ connection mapping.** An explicit `connection-name -> scope` mapping + is stored at connect time (keyed by the executor-normalized name), so scopes + that differ only by a normalized-away character ("my-scope" vs "my_scope") route + correctly instead of colliding on a reverse-parsed name. Scope-db filenames + encode collision-free (safe scopes verbatim, others hex under an `x-` prefix), + so distinct scopes never share a database. +- **`apps_open_ui` capability honesty.** The tool checks the client's MCP-Apps UI + capability (`extensions["io.modelcontextprotocol/ui"]`). A capable client gets + the `_meta.ui` resource link; a non-capable client gets a `fallback_url` to the + authenticated `?document=html` variant of the ui route (or `fallback_unavailable` + when no base URL is configured), never overpromising a widget it cannot render. + +## Known gaps (honest list) + +- **catalog() open-world proxy**: parsed + recorded in the descriptor; execution + throws NotImplemented (in scope per the brief). +- **Running-server external-integration routing** (was NotImplemented; now + LIVE): a published tool's external calls dispatch through the caller's + connection + credentials to the real upstream, resolved per request from the + integration record's base URL (apps-resolver.ts). Remaining: the dotted + method-path -> REST-endpoint mapping is GitHub-style (`repos.listForAuthenticatedUser` + -> `/user/repos`, `issues.listForRepo` -> `/repos/{owner}/{repo}/issues`), enough + for the daily-brief fixture and REST-shaped integrations whose method paths + mirror their URLs; a fully general per-integration operation table (and + GraphQL/non-REST) fails with a typed BindingError rather than guessing. +- **Live SSE refetch INTO the mounted widget**: the ui document's `useQuery` + renders the rows the server inlines at read time and re-renders on a host + `executor:ui/rows` postMessage, and the SSE `invalidate` frame is proven over + HTTP (wire e2e) — but wiring the SSE stream through the MCP-Apps host bridge so + the mounted widget auto-refetches on a scope-db write is a follow-up. The + sunpeak spec proves mount + first-paint row rendering; live update is not yet + asserted in a host sim. +- **Workflow orchestrator isolation**: in-process (decision 1 above). +- **Scheduler**: schedules are extracted to the descriptor (IaC-visible) and a + workflow can be started manually or by a caller; a standalone cron daemon that + auto-fires due schedules is a thin wrapper over `startWorkflow` and is not + built (the e2e/tests start runs explicitly). `step.sleep` timers are recorded + but a wake-timer that auto-resumes sleeping runs is likewise a thin follow-up. +- **MCP registration into the shared server** (was "not hooked in"; now LIVE): + the self-host MCP seams take an `onServer` hook (apps/host-selfhost/src/mcp), + and `app.ts` registers the apps MCP surface (publish door, skills, ui:// + resources, `apps_open_ui`) on every per-session MCP server. The wire e2e drives + all of it through a real MCP client over the served `/mcp` endpoint. +- **Effect-lint suggestions**: a handful of `preferSchemaOverJson` / + `unnecessaryFailYieldableError` suggestions and `globalErrorInEffectFailure` + warnings remain (non-fatal; the tsconfig plugin excludes them from the tsc exit + code). Boundary `try/catch` in web handlers / subprocess callbacks would want + targeted `oxlint-disable` comments before a lint gate. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..90af5ee12 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,412 @@ +--- +version: alpha +name: Executor +description: Executor's design system across the app, marketing site, and docs. A + registry-grade minimal language: Geist and Geist Mono, a near-neutral grayscale ramp, + hairline borders, and color held back to a single role (destructive red). The app uses + shadcn-style semantic tokens (this frontmatter); the marketing site mirrors them as + --color-ink/surface/rule (see the marketing block). Semantic tokens invert between Light + and Dark; values below are Light, with the Dark equivalent noted inline. Reference the + token, never a raw literal. +colors: + # Hierarchy comes from tone and hairlines, not hue. There is no brand color; the only + # color in the system is destructive (red), and it is reserved for irreversible actions. + background: "#ffffff" # dark: #0a0a0a page and app root + foreground: "#111111" # dark: #ededed primary text and icons + card: "#ffffff" # dark: #0f0f0f cards, dialogs, dropdowns, menus + card-foreground: "#111111" # dark: #ededed + popover: "#ffffff" # dark: #141414 surfaces stacked on other surfaces + popover-foreground: "#111111" # dark: #ededed + primary: "#0a0a0a" # dark: #ffffff solid fill for the one key action + primary-foreground: "#ffffff" # dark: #0a0a0a + secondary: "#fafafa" # dark: #141414 quiet fills + secondary-foreground: "#0a0a0a"# dark: #ededed + muted: "#fafafa" # dark: #141414 + muted-foreground: "#666666" # dark: #9a9a9a secondary text, metadata + accent: "#f5f5f5" # dark: #1a1a1a hover and selected surface + accent-foreground: "#0a0a0a" # dark: #ededed + destructive: "#b4261a" # dark: #e0726a errors and destructive actions + border: "#eaeaea" # dark: #1f1f1f default hairline + input: "#d4d4d4" # dark: #333333 field stroke + ring: "#888888" # dark: #777777 focus + sidebar: "#ffffff" # dark: #0a0a0a app chrome + sidebar-foreground: "#666666" # dark: #9a9a9a + sidebar-border: "#eaeaea" # dark: #1f1f1f + sidebar-active: "#f5f5f5" # dark: #141414 selected nav item +typography: + # Geist sets UI and prose. Geist Mono sets code, tool slugs, IDs, counts, keyboard + # shortcuts, section labels, and the wordmark. Keep to two weights per view (400, 500; + # 600 for headings). font-display maps to Geist; headings are sans, the wordmark is mono. + font-sans: "Geist, ui-sans-serif, system-ui, sans-serif" + font-mono: "Geist Mono, ui-monospace, SF Mono, Menlo, monospace" + font-display: "Geist, ui-sans-serif, system-ui, sans-serif" + heading: { fontFamily: font-sans, fontSize: 17-50px, fontWeight: 600, tracking: -0.04em } + body: { fontFamily: font-sans, fontSize: 14-16px, fontWeight: 400, lineHeight: 1.55 } + label: { fontFamily: font-sans, fontSize: 13-14px, fontWeight: 500 } + mono: { fontFamily: font-mono, fontSize: 11-13px, fontWeight: 400 } + sec-label: { fontFamily: font-mono, fontSize: 11px, fontWeight: 500, tracking: 0.08em, transform: uppercase, color: muted-foreground } +spacing: + 1: 4px + 2: 8px + 3: 12px + 4: 16px + 6: 24px + 8: 32px + 10: 40px + base: 4px +rounded: + sm: 5px # calc(radius * 0.6), inline controls + md: 6px # calc(radius * 0.8), buttons and inputs + lg: 8px # radius (0.5rem), cards and menus + xl: 11px # calc(radius * 1.4), large or overlay surfaces + full: 9999px +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px + button-secondary: + backgroundColor: transparent + border: "1px solid {colors.input}" + textColor: "{colors.foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px + button-ghost: + backgroundColor: transparent + textColor: "{colors.muted-foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 11px" + height: 32px # tints to {colors.accent} on hover + button-danger: + backgroundColor: transparent + border: "1px solid {colors.input}" + textColor: "{colors.destructive}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px # border tints to {colors.destructive} on hover + input: + backgroundColor: "{colors.background}" + border: "1px solid {colors.input}" + textColor: "{colors.foreground}" + typography: "{typography.body}" + rounded: "{rounded.md}" + padding: "0 12px" + height: 34px + chip: + backgroundColor: "{colors.secondary}" + border: "1px solid {colors.border}" + textColor: "{colors.muted-foreground}" + typography: "{typography.mono}" + rounded: "{rounded.sm}" + padding: "3px 9px" + card: + backgroundColor: "{colors.card}" + border: "1px solid {colors.border}" + rounded: "{rounded.lg}" + padding: "16px" +marketing: + # Marketing site tokens (apps/marketing/src/styles/global.css), Light only. + # Same ramp as the app under different names; see the mapping table in the prose. + surface: "#ffffff" + surface-2: "#fafafa" + ink: "#111111" + ink-2: "#666666" + ink-3: "#888888" + rule: "#eaeaea" + rule-strong: "#d4d4d4" + accent: "#0a0a0a" + accent-hover: "#333333" +--- + +# Executor + +The integration layer for AI agents, drawn so the tool catalog is what stands out and the +chrome disappears. This file is the canonical, serialized design system: a human guide and an +agent-readable contract. It covers every surface, the app (console / local / cloud / desktop), +the marketing site, and the docs. + +## Overview + +Identity comes from restraint, not decoration: + +- **One typeface family.** Geist for UI and prose, Geist Mono for everything machine: code, + tool slugs, IDs, counts, keyboard shortcuts, section labels, and the wordmark. No serif. +- **Grayscale.** A near-neutral gray ramp carries all hierarchy through tone and hairline + borders. There is no brand hue. The single allowed color is destructive red. +- **Hairlines over shadows.** Depth is a 1px border and a tonal step, not a drop shadow. + Marketing frames its content column with full-height hairline guides. +- **Authentic marks, not an icon pack.** Brand marks are real favicons (app) or real brand + SVGs (marketing). Generic UI affordances are hand-drawn. A uniform icon set in rounded gray + squares reads as generated; we do not use one. +- **Mono is the voice of metadata.** If it is a label, a count, an ID, a shortcut, or an + index, it is Geist Mono, uppercase and tracked when it labels a section. + +The same restraint applies to copy: it is part of the design (see Voice and content). + +## Where it lives (sources of truth) + +| Surface | Tokens / styles | Notes | +| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------- | +| App (console, local, cloud, desktop) | `packages/react/src/styles/globals.css` | shadcn semantic tokens via Tailwind `@theme inline`; Light + Dark | +| Marketing | `apps/marketing/src/styles/global.css` | `--color-*` tokens; Light only | +| Docs | `apps/docs/docs.json` (colors) + `apps/docs/style.css` (wordmark) | Mintlify theme | + +The CSS is the runtime source of truth; this file is its serialization. Keep them in sync. + +**Fonts** are loaded from Google Fonts where each surface boots: `apps/marketing/src/layouts/Layout.astro`, +`apps/cloud/src/routes/__root.tsx`, `apps/host-selfhost/web/index.html`, +`apps/host-cloudflare/web/index.html` (Geist + Geist Mono), and `apps/docs/style.css` (Geist Mono, +for the wordmark). The desktop renderer self-loads nothing and inherits the stacks from the app +CSS. Stacks: `--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif`; +`--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace`. `--font-display` and the +marketing `--font-serif` both resolve to Geist (there is no serif). + +## Two token vocabularies + +The app and marketing express the same ramp under different names. Use the app's shadcn +semantic names in product code; use the `--color-*` names in marketing. They map one to one: + +| Role | App token | Light | Marketing token | Light | +| ----------------------------- | --------------------- | --------- | ----------------------- | --------- | +| Page background | `background` | `#ffffff` | `surface` | `#ffffff` | +| Quiet fill / hover tint | `secondary` / `muted` | `#fafafa` | `surface-2` | `#fafafa` | +| Primary text | `foreground` | `#111111` | `ink` | `#111111` | +| Secondary text, metadata | `muted-foreground` | `#666666` | `ink-2` | `#666666` | +| Tertiary text, eyebrows | `ring` (closest) | `#888888` | `ink-3` | `#888888` | +| Hairline border | `border` | `#eaeaea` | `rule` | `#eaeaea` | +| Field / heavy stroke | `input` | `#d4d4d4` | `rule-strong` | `#d4d4d4` | +| Primary CTA fill (near-black) | `primary` | `#0a0a0a` | `accent` | `#0a0a0a` | +| Primary CTA text | `primary-foreground` | `#ffffff` | `surface` | `#ffffff` | +| Destructive / error | `destructive` | `#b4261a` | (not used in marketing) | | + +The app additionally carries `card` / `popover` (stacked surfaces), `accent` (`#f5f5f5`, hover +or selected surface), and the `sidebar-*` family for app chrome. Marketing is Light only; the +app inverts every token in Dark (values inline in the frontmatter). + +## Colors + +Pick a surface by what an element is, not how it looks: + +- `background` / `surface` is the page and app root. `card` and `popover` are containers on top + of it (cards, dialogs, dropdowns, menus); in Dark, `popover` lifts one step above `card`. +- `sidebar` is the persistent chrome, one shade off `background`, with its own border and + `sidebar-active` for the selected nav item. +- `secondary` / `muted` / `accent` are quiet fills and hover or selected states, not page + backgrounds. +- `foreground` / `ink` is primary text and icons; `muted-foreground` / `ink-2` is secondary + text and metadata; `ink-3` is tertiary text and eyebrows. + +Rank information with tone: primary text at `foreground`, secondary at `muted-foreground`, +separation with `border`. `primary` is a near-black solid (white in Dark) used only for the +single most important action on a view. `destructive` is the one hue; pair it with text or an +icon, never signal state with color alone. + +### Color exceptions (illustration only) + +Two marketing demos use muted secondary hues, confined to illustration and never used as UI or +semantic tokens. They are the only color outside destructive red: + +- **Code window syntax** (`.tok-*` in `global.css`): keyword `#8250df`, string `#2f7d52`, + number `#b4690e`. Comments, functions, and punctuation stay grayscale. +- **Connection matrix status dots**: ok `#2f9e6f`, warn `#d9883a`. + +Treat these as the documented exception, not license to add hues elsewhere. (Open question: +whether even these should go grayscale; if so, syntax highlighting drops to ink + one muted +tone and the status dots become `foreground` / `muted-foreground`.) + +## Typography + +Geist sets all UI and prose; Geist Mono sets everything machine. Keep to no more than two +weights per view (400 and 500, 600 for headings), and apply the type tokens rather than setting +size, weight, or line height by hand. + +The **mono metadata system** is the system's voice. Geist Mono, usually uppercase and tracked, +is used for: + +- the wordmark `executor` (always Geist Mono); +- section eyebrows / labels (`.eyebrow`: 12px, uppercase, `0.18em`, `ink-3`; the app `sec-label` + token: 11px, uppercase, `0.08em`, `muted-foreground`); +- index numerals on feature grids (`.cap-num`: 11px, `0.08em`, `ink-3`, values `01`-`06`); +- counts, durations, IDs, tool slugs (`github.search_issues`), and keyboard shortcuts. + +Headings are Geist 600 with tight tracking (down to `-0.04em` at display sizes). Marketing +`.section-title` is `clamp(2rem, 4.2vw, 3rem)`; the hero headline goes to +`clamp(2.8rem, 7.2vw, 6rem)`. Body and `label` (13-16px) cover most interface text. Site-wide +tracking is slightly tight (`-0.011em`). + +## Layout + +Spacing follows a 4px rhythm: 4, 8, 12, 16, 24, 32, 40px. Keep a three-step cadence: 8px inside +a group, 16px between groups, 32 to 40px between sections. + +**Marketing** centers content in fixed columns and separates sections with `border-t border-rule` +plus `py-14`/`py-20`/`py-28`: + +| Region | Max width | +| ---------------------------- | --------- | +| Nav, footer | 1200px | +| Feature / pricing sections | 1100px | +| Hero | 920px | +| Connection-diagram card, FAQ | 760px | +| Founder note | 680px | + +The signature **frame guides** are full-height 1px hairlines at the content-column edges +(implemented in the app preview; on the marketing site the column edges are implied by the +section borders and the centered max-widths). The hero sits on a faint grayscale graph-paper +texture (see Signature patterns). + +**App** is a fixed sidebar plus a scrolling main pane. Every view works from the 768px +breakpoint up (the desktop window minimum). + +## Elevation and depth + +Depth comes from tonal surfaces and hairlines first. Separate a `card` from the page with a 1px +`border` and at most a soft shadow. Floating surfaces (menus, dialogs) may add one diffuse +shadow. In Dark, lift with a one-step-lighter surface (`card` to `popover`) rather than a heavier +shadow. Pair every elevation with the matching radius. + +## Motion + +Motion clarifies a change; it is never decoration. Most interactions should feel instant, and +`0ms` is often right. When motion helps, keep it short and tokenized: about 150ms for state +changes, 200ms for popovers and tooltips, 300ms for overlays. Press feedback is a small +`scale(0.99)` on primary actions. Marketing uses a restrained set of scroll reveals +(`cubic-bezier(0.22, 1, 0.36, 1)`, staggered) and the connection-diagram beam travels on a 4s +loop. Always honor `prefers-reduced-motion`. + +## Shapes + +Radii stay tight. App: 5px inline controls, 6px buttons and inputs, 8px cards and menus, 11px +large or overlay surfaces, `9999px` for pills, avatars, and dots. Marketing rounds a little +softer on large surfaces (cards and code windows at 14px, the feature grid at 16px) but holds +the same family per view. Do not mix rounded and sharp corners. + +## Components + +### App (`packages/react/src/components`) + +Built with `class-variance-authority` for variants, classes merged with `cn`, state surfaced on +`data-*` attributes (`data-slot`, `data-variant`, `data-size`, and Radix `data-state`). Every +interactive element shows a focus ring at `:focus-visible` +(`focus-visible:ring-[3px] focus-visible:ring-ring/50`); disabled drops to `opacity-50`. + +- **Button** (`button.tsx`): variants `default` (primary fill), `secondary`, `outline`, `ghost`, + `destructive`, `link`. Sizes `xs` / `sm` / `default` (h-9) / `lg`, plus `icon{,-xs,-sm,-lg}`. + Leading icons auto-size to `size-4`. +- **Badge** (`badge.tsx`): `rounded-full`, `px-2 py-0.5 text-xs`; variants mirror Button. Use + sparingly; prefer a mono label where a status word will do. +- **Input / Textarea** (`input.tsx`, `textarea.tsx`): `h-9`, `rounded-md`, 1px `input` border, + `bg-transparent` (dark `input/30`); textarea is `field-sizing-content`. +- **Select** (`select.tsx`): Radix; trigger sizes `default` / `sm`; content on `popover`. +- **Switch / Checkbox / RadioGroup**: Radix; checked state fills with `primary`; checkbox uses a + lucide `CheckIcon`, radio a filled `CircleIcon`. (These ticks are the sanctioned functional + icon use, see Marks and icons.) +- **Tabs** (`tabs.tsx`): `default` (pill list on `muted`) and `line` (2px `foreground` underline + on the active tab). Mono labels. +- **Card / CardStack** (`card.tsx`, `card-stack.tsx`): `card` fill, 1px `border`, `rounded-xl`. + CardStack is the app's dense bordered-row list (collapsible, searchable), the right pattern for + catalogs over rounded-rect card grids. + +The frontmatter `components` block gives ready-to-use recipes (button-primary / secondary / +ghost / danger, input, chip, card) at the system's default 32px control height. + +### Marketing (`apps/marketing/src/styles/global.css`) + +- **`.btn-primary`**: `ink` fill, `surface` text, 8px radius (the `--hero` modifier bumps + padding and size). +- **`.surface-card`**: white, 1px `rule`, 14px radius, one soft diffuse shadow. +- **`.eyebrow` / `.section-title` / `.section-sub`**: the section header trio (mono eyebrow, + Geist 600 title, `ink-2` sub). +- **`.cap-grid` / `.cap-card` / `.cap-num`**: the feature grid. Hairline separators come from a + 1px gap over a `rule` background; each cell leads with a mono index numeral, then a Geist 600 + title and `ink-2` body. `.cap-card--soon` dims a cell and adds a plain mono `.cap-soon-badge`. +- **`.code-window`**: a framed code sample with a hairline titlebar, gray "traffic light" dots + (all `rule-strong`, not colored), a mono filename, and the `.tok-*` syntax classes. +- **`.trace` waterfall**: mono rows with a pill rail (`surface-2`) and an `accent` bar; the root + span darkens to `ink`. Used inside the "Trace every call" card. + +## Signature patterns + +- **Frame guides** / centered hairline column: the registry-grade framing device. +- **Graph-paper hero texture**: an ink-masked grid at `opacity: 0.08`, faded into the surface + toward the bottom. Grayscale, subtle, never colored. +- **Connection diagram** (`animated-beam-demo.tsx`): agents to a central Executor hub to tools, + drawn with real brand SVGs and grayscale animated beams (the live page uses the `stripe` + variant: `#111` beam, `rule` paths). Brand marks, not icons. +- **Mono index numerals** (`01`-`06`) instead of icon chips on feature grids. +- **Install / agent pill**: a copyable mono command chip in the hero CTA. +- **Works-with row** and the catalog preview: real brand marks plus mono protocol sub-labels + (`OpenAPI`, `GraphQL`, `MCP`, `CLI`) and kind chips. + +## Marks and icons + +Do not use a uniform icon pack (Tabler, Lucide-as-decoration, Heroicons) or +monogram-in-rounded-square placeholders: both read as generic, generated UI. Identity comes from +authentic specifics or from nothing at all. + +- **App**: source and brand marks use the real favicon service, + `https://www.google.com/s2/favicons?domain={host}&sz={n}` (`integration-favicon.tsx`, rendered + 2x for retina), with Executor's own `/favicon-32.png` for the built-in source. The only + lucide import there is `BoxIcon` as a neutral fallback when a favicon fails to load. +- **Marketing**: brand marks are local SVGs in `apps/marketing/src/assets/logos` (`github.svg`, + `stripe.svg`, `linear.svg`, `claude.svg`, ...); the hub uses `/favicon-192.png`. +- **Generic UI affordances** (select chevron, checkbox tick): hand-drawn in CSS or a small inline + SVG, or a single functional lucide glyph (`CheckIcon`, `CircleIcon`, `ChevronRight`). These are + functional, not decorative, and are the sanctioned exception. +- Where a mark would only add noise, use none: type, a status dot, and mono metadata carry it. + +## Status and semantics + +State reads through grayscale tokens plus an icon or text label, never color alone. Destructive +red is the one retained hue (errors, irreversible actions). Success, warning, pending, and +connected states use grayscale: `foreground` for the affirmative or active, `muted-foreground` +for the quieter or pending, paired with their existing word ("Verified", "Pending", "Canceling") +or a dot. (The marketing demo dots and code syntax are the documented illustration exception +above.) + +## Voice and content + +Copy is part of the design; keep it precise, technical, and free of filler. + +- Title Case for labels, buttons, titles, and tabs; sentence case for body, helper text, toasts. +- Name actions with a verb and a noun (`Add Source`, `Connect Agent`, `Revoke Token`), never + `Confirm`, `OK`, or a bare verb. +- Errors are what happened plus what to do next: `Couldn't reach the source. Check the server is +running, then retry.` +- Toasts name the specific thing that changed, drop the trailing period, never say + `successfully`: `Source added`, not `Successfully added the source.` +- Empty states point to the first action: `No sources yet. Add one to start sharing tools across +your agents.` +- Present participle with an ellipsis for in-progress states: `Connecting...`, `Syncing...`. +- Use numerals (`3 tools`); skip `please` and marketing superlatives (powerful, seamless, robust, + leverage, unlock, game-changing). + +## For agents + +Executor serves AI agents, so its own interface follows agent-friendly rules: + +- The token values are the contract. Read a token; do not hardcode a hex literal or a raw size. +- No brand hue. If a design seems to need "another color," it is a role token at a different + step, not a new hue. +- State lives on `data-*` attributes and semantic tokens, so a component can be reasoned about + without parsing class soup. +- This file is the serialized system. Keep it in sync with the CSS sources listed above. + +## Do's and Don'ts + +- Rank information with the gray ramp and hairlines, not color. +- Keep the near-black `primary` for the single most important action; one per view. +- Hold WCAG AA contrast (4.5:1 for body text). +- Apply the typography tokens instead of setting size, line height, or weight by hand. +- Use Geist Mono for the wordmark, section labels, counts, slugs, shortcuts, and index numerals. +- Don't introduce a brand hue or a second accent; extend the neutral and semantic scales instead. +- Don't use an icon pack or monogram placeholders; use real favicons or brand SVGs, or nothing. +- Don't use `card` / `popover` as a page background, or `muted` / `accent` as a general fill. +- Don't mix rounded and sharp corners, or more than two font weights, in one view. diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index 1a29f4507..fadeedfce 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -8,6 +8,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; import { resolveSecretKey } from "./src/config"; +import { getSelfHostAppsSubsystem } from "./src/apps"; // --------------------------------------------------------------------------- // Single source of truth for the self-hosted app's plugin list. @@ -38,5 +39,10 @@ export default defineExecutorConfig({ toolkitsPlugin({ activeToolkitSlug }), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), + // The apps source plugin: published custom tools become real catalog + // citizens (tools.list + execute through the same policy/audit path). The + // runtime is a boot-time singleton shared across per-request plugin + // instances; the plugin itself is thin. + getSelfHostAppsSubsystem().plugin, ] as const, }); diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 1c73a8cc8..bb3119599 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -26,6 +26,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -48,6 +49,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/emulate": "^0.13.2", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/host-selfhost/scripts/mcp-apps-serve.ts b/apps/host-selfhost/scripts/mcp-apps-serve.ts new file mode 100644 index 000000000..7d69fdee7 --- /dev/null +++ b/apps/host-selfhost/scripts/mcp-apps-serve.ts @@ -0,0 +1,196 @@ +// Boot the REAL self-host app in-process, seed a published daily-brief app with +// scope-db rows, then serve its `/mcp` endpoint on a fixed loopback port for the +// sunpeak host simulation (e2e/mcp-apps) to connect to. +// +// The self-host MCP endpoint is Better-Auth-gated. Rather than teach sunpeak the +// auth dance, this wrapper owns auth: it signs up once, holds the bearer, and +// injects it on every forwarded request. sunpeak connects to `/mcp` with no +// credentials; the wrapper adds the Authorization header. +// +// Lives here (under apps/host-selfhost) so its imports resolve through this +// package's node_modules under Bun. Launched by e2e/mcp-apps/playwright.config.ts. +// +// Env in: PORT (wrapper port, default 8791). Health: GET /health -> 200 "ok". + +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createEmulator } from "@executor-js/emulate"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing"; + +import { mintInviteCode } from "../src/testing/mint-invite"; + +const PORT = Number(process.env.PORT ?? "8791"); +const origin = "http://mcp-apps.internal"; +const log = (...args: unknown[]) => console.log("[mcp-apps-server]", ...args); + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-mcpapps-")); +process.env.BETTER_AUTH_SECRET = "mcp-apps-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@mcp-apps.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; +process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; + +// --- real-shaped GitHub emulator + a seeded repo with issues ---------------- +const github = await createEmulator({ service: "github" }); +const cred = (await github.credentials.mint({ + type: "api-key", +})) as unknown as { token: string }; +const ghToken = cred.token; +const ghHeaders = { + authorization: `Bearer ${ghToken}`, + accept: "application/vnd.github+json", + "content-type": "application/json", +}; +const repoRes = await fetch(`${github.url}/user/repos`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ name: "app" }), +}); +const owner = ((await repoRes.json()) as { owner: { login: string } }).owner.login; +for (const title of ["Fresh bug", "Second bug"]) { + await fetch(`${github.url}/repos/${owner}/app/issues`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ title, labels: ["bug"] }), + }); +} +log("emulator ready", github.url, "owner", owner); + +// --- boot the real self-host handler --------------------------------------- +const { makeSelfHostApiHandler } = await import("../src/app"); +const app = await makeSelfHostApiHandler(); +const handler = app.handler; + +// --- sign up -> bearer token ------------------------------------------------ +const inviteCode = await mintInviteCode(handler); +const su = await handler( + new Request(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@mcp-apps.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), +); +const token = su.headers.get("set-auth-token"); +if (!token) throw new Error("sign-up produced no token"); + +const api = (path: string, init: RequestInit = {}) => + handler( + new Request(`${origin}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }), + ); + +// --- register the emulator as `github`, plus a connection to it ------------- +const spec = JSON.stringify({ + openapi: "3.0.0", + info: { title: "GitHub (emulated)", version: "1.0.0" }, + servers: [{ url: github.url }], + paths: { + "/user/repos": { + get: { + operationId: "listRepos", + responses: { 200: { description: "ok" } }, + }, + }, + }, +}); +await api("/api/openapi/specs", { + method: "POST", + body: JSON.stringify({ + spec: { kind: "blob", value: spec }, + slug: "github", + baseUrl: github.url, + }), +}); +await api("/api/connections", { + method: "POST", + body: JSON.stringify({ + owner: "user", + // Name the connection after its integration: the apps default binding maps a + // role to a connection of the same name as its integration, and the resolver + // requires an exact match (no silent conns[0] fallback), so the catalog-invoke + // path only resolves when the connection is named "github". + name: "github", + integration: "github", + template: "bearer", + value: ghToken, + }), +}); + +// --- connect a real MCP client to publish + populate rows ------------------- +const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + handler(input instanceof Request ? input : new Request(input, init)); +const client = new Client({ name: "mcp-apps-setup", version: "1.0.0" }); +await client.connect( + new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + fetch: wireFetch as unknown as typeof globalThis.fetch, + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }), +); +await client.callTool({ + name: "apps_publish", + arguments: { files: Object.fromEntries(dailyBriefFileSet()) }, +}); +await client.callTool({ + name: "execute", + arguments: { + code: "export default await tools.executor.apps.connect_catalog({});", + }, +}); +await client.callTool({ + name: "execute", + arguments: { + code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`, + }, +}); +await client.close(); +log("published daily-brief + populated issues"); + +// --- serve a wrapper that injects the bearer for sunpeak -------------------- +const server = Bun.serve({ + port: PORT, + hostname: "127.0.0.1", + idleTimeout: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${token}`); + return handler(new Request(request, { headers })); + }, +}); +log(`serving /mcp for sunpeak at http://127.0.0.1:${server.port}/mcp`); + +const shutdown = async () => { + try { + server.stop(true); + } catch { + /* ignore */ + } + try { + await app.dispose(); + } catch { + /* ignore */ + } + try { + await github.close(); + } catch { + /* ignore */ + } + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index c4a080f3b..59dbc3307 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -19,6 +19,7 @@ import { SelfHostPluginsProvider, } from "./execution"; import { makeSelfHostMcpSeams } from "./mcp"; +import { getSelfHostAppsSubsystem } from "./apps"; import { selfHostPlugins } from "./plugins"; import { ErrorCaptureLive } from "./observability"; import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; @@ -68,10 +69,52 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // API + MCP OAuth seam, all over the shared libSQL handle. const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + // ---- the apps subsystem (custom tools / workflows / ui / skills) ------- + // Built over the five self-hosted seam backings rooted at the data dir (a + // boot-time singleton, same instance the source plugin in executor.config.ts + // closes over). Its HTTP surface mounts under /api/apps/*; published tools are + // catalog citizens through its source plugin; its extra MCP surface (publish + // door, skills, ui:// resources) is registered on the real MCP server below. + // + // The HTTP surface (publish/invoke/workflows/ui/SSE) mutates per-scope state + // and reaches real integrations, so it is authenticated with the SAME Better + // Auth credential the rest of `/api` requires. We resolve a session from the + // same three credential shapes the identity seam accepts (session cookie, + // Bearer session token, Bearer value retried as an x-api-key), matching + // `betterAuthIdentityLayer` so a token that works on `/api` works here too. + const appsAuthenticate = async (request: Request): Promise => { + const auth = betterAuth.auth; + const session = await auth.api.getSession({ headers: request.headers }).catch(() => null); + if (session) return true; + const authorization = request.headers.get("authorization"); + const token = + authorization && authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() + : undefined; + if (!token) return false; + const apiKeySession = await auth.api + .getSession({ headers: new Headers({ "x-api-key": token }) }) + .catch(() => null); + return apiKeySession != null; + }; + const apps = getSelfHostAppsSubsystem({ + authenticate: appsAuthenticate, + webBaseUrl: config.webBaseUrl, + }); + // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind - // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). - const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); + // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). The + // `onServer` hook registers the apps subsystem's non-catalog MCP surface + // (publish/skills/ui) on each per-session MCP server, so publish-over-MCP, + // skills list/read, and ui:// resources are LIVE on the real endpoint. + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl, (server) => + // The real MCP SDK server registers with zod schema shapes; the apps + // registrar's structural `McpServerLike` uses a plain-object schema view. The + // shapes are runtime-compatible (the registrar passes zod raw shapes); the + // cast bridges the SDK's narrower generic signature. + apps.registerMcp(server as unknown as Parameters[0]), + ); // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints @@ -119,6 +162,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + // The apps subsystem HTTP surface: publish / invoke / ui bundle / SSE / + // workflow lifecycle under /api/apps/*. + HttpRouter.add("*", "/api/apps/*", HttpEffect.fromWebHandler(apps.http.handler)), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), ], @@ -139,6 +185,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { toWebHandler, betterAuth, closeDb: async () => { + await apps.close(); await mcp.close(); await dbHandle.close(); }, diff --git a/apps/host-selfhost/src/apps-resolver.test.ts b/apps/host-selfhost/src/apps-resolver.test.ts new file mode 100644 index 000000000..21fee8c7a --- /dev/null +++ b/apps/host-selfhost/src/apps-resolver.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient } from "effect/unstable/http"; + +import { makeCtxResolver } from "./apps-resolver"; + +// --------------------------------------------------------------------------- +// Finding 4 regression: a missing/misnamed credential binding must fail with a +// typed BindingError naming the role + surface, and must make NO upstream call. +// Before the fix the resolver fell back to `conns[0]` and dispatched the request +// with SOME OTHER connection's credential. +// +// We build a fake ctx whose connection list does NOT contain the requested name, +// with an HttpClient that flags if it is ever invoked. The assertion: a +// BindingError, and the http client was never touched (the "emulator ledger" is +// empty). +// --------------------------------------------------------------------------- + +describe("apps ClientResolver missing-binding (Fix 4)", () => { + it("fails typed and makes no upstream call when the bound connection is absent", async () => { + let httpCalled = false; + // A stub HttpClient layer that records any dispatch. If the resolver fell + // back to conns[0] it would build a request and call `execute`. + const httpLayer = Layer.succeed(HttpClient.HttpClient)({ + execute: () => { + httpCalled = true; + return Effect.die("http should never be called for a missing binding"); + }, + } as unknown as HttpClient.HttpClient); + + // The ctx exposes a DIFFERENT connection than the one the binding names. + const ctx = { + httpClientLayer: httpLayer, + connections: { + list: () => + Effect.succeed([ + { + owner: "user", + name: "some-other-connection", + integration: "github", + config: { baseUrl: "http://127.0.0.1:1/emulator" }, + }, + ]), + resolveValue: () => Effect.succeed("tok"), + }, + core: { integrations: { get: () => Effect.succeed(null) } }, + }; + + const resolver = makeCtxResolver(ctx); + + const exit = await Effect.runPromiseExit( + resolver.call({ + integration: "github", + // The binding names a connection that does NOT exist in the list. + connection: "the-bound-one", + path: ["repos", "listForAuthenticatedUser"], + args: [{}], + }), + ); + + expect(exit._tag).toBe("Failure"); + // The typed BindingError names the role + surface and explains the refusal. + const serialized = JSON.stringify(exit); + expect(serialized).toContain("BindingError"); + expect(serialized).toContain("the-bound-one"); + expect(serialized).toContain("refusing to"); + + // No upstream call was made (the ledger stayed empty). + expect(httpCalled).toBe(false); + }); +}); diff --git a/apps/host-selfhost/src/apps-resolver.ts b/apps/host-selfhost/src/apps-resolver.ts new file mode 100644 index 000000000..f0671768f --- /dev/null +++ b/apps/host-selfhost/src/apps-resolver.ts @@ -0,0 +1,226 @@ +import { Effect } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { BindingError, type ClientResolver } from "@executor-js/plugin-apps/api"; + +// --------------------------------------------------------------------------- +// The REAL per-request ClientResolver for the running self-host (Fix 2). +// +// A published apps tool declares connection("github") and its sandboxed handler +// makes method calls like github.repos.listForAuthenticatedUser(args). This +// resolver routes each such call through the executor's per-request context: +// 1. resolve the user's connection for the integration by name (the invoking +// owner's connection — policy/ownership applies at this lookup); +// 2. resolve its credential value through the provider (OAuth refresh happens +// here) — credentials are injected at the boundary, never seen by the +// sandboxed code; +// 3. dispatch the upstream call over the request's HttpClient, with the +// credential rendered as a Bearer token, against the integration's base URL +// using a REST-path convention (see `methodPathToUrl`). +// +// This is built PER REQUEST from `ctx` (the plugin's PluginCtx), so it has the +// real invoking context the boot-time singleton lacked. It is passed to the apps +// plugin's `invokeTool` via `makeResolver`, which threads it into the runtime's +// invoke path as a per-request override. +// +// HONEST GAP: mapping an arbitrary dotted method path to a concrete REST +// endpoint is integration-specific. This resolver implements the GitHub-style +// REST convention (dotted path -> `/segment/segment`, args -> query/body), which +// is enough for the daily-brief fixture and any REST-shaped integration whose +// method paths mirror their URL paths. A fully general mapping (per-integration +// operation tables, GraphQL, non-REST) is the remaining work; such a call fails +// with a typed BindingError naming the unmapped method rather than guessing. +// --------------------------------------------------------------------------- + +/** The subset of PluginCtx this resolver needs. Kept structural so the host does + * not import the plugin package's ctx type. */ +export interface AppsResolverCtx { + readonly httpClientLayer: import("effect").Layer.Layer; + readonly connections: { + readonly list: (filter?: { + readonly integration?: string; + }) => Effect.Effect; + readonly resolveValue: (ref: { + readonly owner: unknown; + readonly name: string; + readonly integration: string; + }) => Effect.Effect; + }; + /** Catalog integration lookup, used to resolve an integration's base URL from + * its registered record. An operator can register a `github` integration + * (OpenAPI-shaped) pointed at a loopback emulator; the base URL it stores in + * the integration's opaque config is what the resolver dispatches against. + * Kept structural so the host does not import the SDK's ctx type. */ + readonly core?: { + readonly integrations: { + readonly get: (slug: string) => Effect.Effect; + }; + }; +} + +interface AppsResolverIntegration { + /** The owning plugin's opaque config. OpenAPI-shaped integrations carry the + * spec server URL here as `baseUrl`. */ + readonly config?: { readonly baseUrl?: string } | null; +} + +interface AppsResolverConnection { + readonly owner: unknown; + readonly name: string; + readonly integration: string; + /** The integration's opaque config; a `baseUrl` here overrides the default. */ + readonly config?: { readonly baseUrl?: string } | null; +} + +/** Default base URLs for the integrations the self-host resolver knows how to + * reach over REST. An integration whose connection config carries a `baseUrl` + * (e.g. an emulator) overrides this. */ +const DEFAULT_BASE_URLS: Record = { + github: "https://api.github.com", +}; + +/** Map a dotted method path to a REST URL path. GitHub-style convention: the + * LAST segment is the operation verb (listForAuthenticatedUser, listForRepo) + * and the leading segments are the resource. For the daily-brief tool the two + * calls are `repos.listForAuthenticatedUser` -> GET /user/repos and + * `issues.listForRepo` -> GET /repos/{owner}/{repo}/issues. We special-case the + * handful the fixture uses and otherwise fall back to `/segment/segment`. */ +const methodPathToRequest = ( + path: readonly string[], + args: Record, +): { + method: "GET" | "POST"; + url: string; + query: Record; + body?: unknown; +} | null => { + const key = path.join("."); + const query: Record = {}; + for (const [k, v] of Object.entries(args)) { + if (v == null) continue; + if (typeof v === "object") continue; + query[k] = String(v); + } + switch (key) { + case "repos.listForAuthenticatedUser": + return { method: "GET", url: "/user/repos", query }; + case "issues.listForRepo": { + const owner = String(args.owner ?? ""); + const repo = String(args.repo ?? ""); + const { owner: _o, repo: _r, ...rest } = query as Record; + void _o; + void _r; + return { + method: "GET", + url: `/repos/${owner}/${repo}/issues`, + query: rest, + }; + } + default: + return null; + } +}; + +export const makeCtxResolver = ( + ctxUnknown: unknown, + integrationForRole: (role: string) => string = (role) => role, +): ClientResolver => { + const ctx = ctxUnknown as AppsResolverCtx; + return { + call: ({ integration, connection, path, args }) => + Effect.gen(function* () { + const integrationSlug = integrationForRole(integration); + const conns = yield* ctx.connections + .list({ integration: integrationSlug }) + .pipe(Effect.orElseSucceed(() => [] as readonly AppsResolverConnection[])); + // The binding MUST name a real connection: match by name exactly, never + // fall back to `conns[0]`. A silent fallback would dispatch the call with + // some OTHER connection's credential (whichever happens to sort first), + // leaking the wrong owner's token to the upstream. A missing/misnamed + // binding is a typed error naming the role + surface, and no upstream + // call is made. + const conn = conns.find((c) => c.name === connection); + if (!conn) { + return yield* Effect.fail( + new BindingError({ + message: + `no "${integrationSlug}" connection named "${connection}" is bound for role ` + + `"${integration}"; bind a connection for this role before invoking (refusing to ` + + `fall back to another connection's credential)`, + role: integration, + surface: integrationSlug, + }), + ); + } + const token = yield* ctx.connections + .resolveValue({ + owner: conn.owner, + name: conn.name, + integration: conn.integration, + }) + .pipe(Effect.orElseSucceed(() => null)); + + const req = methodPathToRequest(path, (args[0] ?? {}) as Record); + if (!req) { + return yield* Effect.fail( + new BindingError({ + message: `apps resolver has no REST mapping for "${integrationSlug}.${path.join(".")}" (the general per-integration operation mapping is the remaining gap)`, + role: integration, + surface: integrationSlug, + }), + ); + } + // Base URL precedence: the connection's own config override (rare), then + // the registered integration record's non-secret displayUrl (an operator + // registering `github` against a loopback emulator sets it there), then + // the built-in default. Reading it from the integration record is the + // right seam: the base URL is an integration property, not a credential. + const integrationBaseUrl = ctx.core + ? yield* ctx.core.integrations.get(integrationSlug).pipe( + Effect.map((rec) => { + const base = rec?.config?.baseUrl; + return typeof base === "string" && base.length > 0 ? base : undefined; + }), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const baseUrl = + conn.config?.baseUrl ?? integrationBaseUrl ?? DEFAULT_BASE_URLS[integrationSlug]; + if (!baseUrl) { + return yield* Effect.fail( + new BindingError({ + message: `apps resolver has no base URL for integration "${integrationSlug}"`, + role: integration, + surface: integrationSlug, + }), + ); + } + + const result = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const qs = new URLSearchParams(req.query).toString(); + const url = `${baseUrl.replace(/\/$/, "")}${req.url}${qs ? `?${qs}` : ""}`; + let request = HttpClientRequest.make(req.method)(url).pipe( + HttpClientRequest.setHeader("user-agent", "executor-apps"), + HttpClientRequest.setHeader("accept", "application/vnd.github+json"), + ); + if (token) { + request = request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); + } + const response = yield* client.execute(request); + return yield* response.json; + }).pipe( + Effect.provide(ctx.httpClientLayer), + Effect.mapError( + (cause) => + new BindingError({ + message: `upstream call ${integrationSlug}.${path.join(".")} failed: ${String(cause)}`, + role: integration, + surface: integrationSlug, + }), + ), + ); + return result; + }), + }; +}; diff --git a/apps/host-selfhost/src/apps-wire.node.test.ts b/apps/host-selfhost/src/apps-wire.node.test.ts new file mode 100644 index 000000000..3092b872b --- /dev/null +++ b/apps/host-selfhost/src/apps-wire.node.test.ts @@ -0,0 +1,381 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createEmulator } from "@executor-js/emulate"; + +import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing"; + +import { mintInviteCode } from "./testing/mint-invite"; + +// =========================================================================== +// THE BOOTED-HOST WIRE E2E (Fix 5): prove the apps subsystem over real HTTP + +// a real MCP client, against the ACTUAL self-host app (the same composition +// serve.ts uses), bound to an ephemeral socket. +// +// One runnable command: +// bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts +// +// The chain, all over the wire (nothing faked, no FakeMcpServer): +// 1. boot makeSelfHostApiHandler on an ephemeral Bun.serve port +// 2. stand up a real-shaped GitHub via @executor-js/emulate; seed a repo+issues +// 3. sign up (Better Auth) -> bearer token; register the emulator as the +// `github` integration and create a connection to it (its minted token) +// 4. connect a REAL MCP Client over StreamableHTTP to /mcp +// 5. publish daily-brief over the `apps_publish` MCP door +// 6. tools/list over MCP shows the published tool AS A CATALOG TOOL +// 7. invoke it through the catalog path; the emulator's ledger proves the +// upstream GitHub call landed +// 8. start the workflow (manual) over HTTP; see it complete with a journal +// 9. read the ui:// resource over MCP (resources/read) +// 10. make a scope-db write; observe the SSE invalidation frame over HTTP +// 11. list + read the published skill over MCP +// =========================================================================== + +// Better Auth needs a secret + a bootstrap admin before the app graph imports. +const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); +process.env.EXECUTOR_DATA_DIR = dataDir; +process.env.BETTER_AUTH_SECRET = "apps-wire-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-wire.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; +// The apps ClientResolver dials the emulator over loopback; allow it. +process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; + +const SCOPE = "default"; +const GITHUB_INTEGRATION = "github"; +// The connection name matches the integration slug, which is the apps binding +// contract (a role defaults to a connection of the same name as its integration). +// The catalog-invoke path uses that default binding, and the resolver now +// requires an EXACT connection match (no silent conns[0] fallback, Fix 4), so the +// connection MUST be named after the integration for the default binding to +// resolve. +const GITHUB_CONNECTION = "github"; + +type LocalEmulator = Awaited>; + +describe("Executor apps wire e2e (booted self-host, real MCP client + GitHub emulator)", () => { + let handler!: (request: Request) => Promise; + let dispose: () => Promise = async () => {}; + let github: LocalEmulator; + // The booted app is an in-process web handler; the "wire" is a real MCP client + // + real JSON-RPC/SSE framing over a fetch bound to it (vitest runs under Node, + // so there is no ambient Bun.serve to bind an ephemeral socket to). `origin` is + // the URL the transport dials; the injected fetch routes it to the handler. + const origin = "http://apps-wire.internal"; + let token = ""; + let owner = ""; + + // A JSON fetch that carries the bearer, aimed at the booted handler. + const api = (path: string, init?: RequestInit) => + handler( + new Request(`${origin}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }), + ); + + // Fetch shim the MCP StreamableHTTP transport uses: forward WHATWG fetch calls + // straight into the booted handler. + const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = input instanceof Request ? input : new Request(input, init); + return handler(request); + }; + + let client: Client; + + beforeAll(async () => { + // --- real-shaped GitHub (emulate) + seed a repo with two issues --------- + github = await createEmulator({ service: "github" }); + const cred = (await github.credentials.mint({ + type: "api-key", + })) as unknown as { + token: string; + login: string; + }; + const ghToken = cred.token; + const ghHeaders = { + authorization: `Bearer ${ghToken}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + }; + const repoRes = await fetch(`${github.url}/user/repos`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ name: "app" }), + }); + const repo = (await repoRes.json()) as { owner: { login: string } }; + owner = repo.owner.login; + for (const title of ["Fresh bug", "Second bug"]) { + await fetch(`${github.url}/repos/${owner}/app/issues`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ title, labels: ["bug"] }), + }); + } + + // --- boot the REAL self-host app (the composition serve.ts uses) -------- + const { makeSelfHostApiHandler } = await import("./app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; + + // --- sign up -> bearer token -------------------------------------------- + const inviteCode = await mintInviteCode(handler); + const su = await handler( + new Request(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@apps-wire.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), + ); + token = su.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + + // --- register the emulator as the `github` integration + a connection ---- + // An OpenAPI-shaped integration whose baseUrl points at the loopback + // emulator: the apps ClientResolver reads that base URL from the integration + // record and dispatches the published tool's github.* calls there. A minimal + // spec is enough; the resolver builds REST paths itself (it doesn't drive the + // spec's operations), so the integration exists only to carry the base URL + + // hold the connection's credential. + const spec = JSON.stringify({ + openapi: "3.0.0", + info: { title: "GitHub (emulated)", version: "1.0.0" }, + servers: [{ url: github.url }], + paths: { + "/user/repos": { + get: { + operationId: "listRepos", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + const addSpec = await api("/api/openapi/specs", { + method: "POST", + body: JSON.stringify({ + spec: { kind: "blob", value: spec }, + slug: GITHUB_INTEGRATION, + baseUrl: github.url, + }), + }); + expect(addSpec.status).toBe(200); + + const conn = await api("/api/connections", { + method: "POST", + body: JSON.stringify({ + owner: "user", + name: GITHUB_CONNECTION, + integration: GITHUB_INTEGRATION, + template: "bearer", + value: ghToken, + }), + }); + expect(conn.status).toBe(200); + + // --- connect a REAL MCP client over StreamableHTTP to /mcp -------------- + client = new Client({ name: "apps-wire-client", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + fetch: wireFetch as unknown as typeof globalThis.fetch, + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }); + await client.connect(transport); + }, 90_000); + + afterAll(async () => { + await client?.close().catch(() => {}); + await dispose(); + await github?.close(); + }); + + it("publishes the daily-brief set over the MCP publish door", async () => { + const result = (await client.callTool({ + name: "apps_publish", + arguments: { files: Object.fromEntries(dailyBriefFileSet()) }, + })) as { + structuredContent?: { + tools?: string[]; + workflows?: string[]; + ui?: string[]; + skills?: string[]; + }; + }; + const sc = result.structuredContent!; + expect((sc.tools ?? []).sort()).toEqual(["issues-sync", "search-all-mail"]); + expect(sc.workflows).toEqual(["morning-sync"]); + expect(sc.ui).toEqual(["dashboard"]); + expect(sc.skills).toEqual(["issues-brief"]); + }); + + it("wires the published app into the catalog; the tool becomes a catalog citizen", async () => { + // Wire the scope into the catalog through the REAL request path: the built-in + // `executor.apps.connect_catalog` tool registers the `apps` integration and + // creates the apps/ connection for the caller. Invoked over MCP via + // the `execute` sandbox, exactly as an agent would. + const connect = (await client.callTool({ + name: "execute", + arguments: { + code: "export default await tools.executor.apps.connect_catalog({});", + }, + })) as { isError?: boolean; structuredContent?: { status?: string } }; + expect(connect.isError ?? false).toBe(false); + expect(connect.structuredContent?.status).toBe("completed"); + + // The published tool is now discoverable as a catalog tool through the same + // `tools.search` an agent uses (executor exposes catalog tools inside the + // `execute` sandbox, not as flat MCP tools — `execute` IS the catalog door). + const search = (await client.callTool({ + name: "execute", + arguments: { + code: 'export default (await tools.search({ query: "sync github issues into the scope table", limit: 30 })).items.map((m) => m.path)', + }, + })) as { structuredContent?: { result?: string[] } }; + const paths = search.structuredContent?.result ?? []; + // Addressed as tools.apps... — a real catalog citizen. + expect(paths.some((p) => p.includes("apps.") && p.includes("issues-sync"))).toBe(true); + }); + + it("invokes the published tool through the catalog path; the ledger proves the upstream GitHub call landed", async () => { + const before = (await github.ledger.list()).length; + + // Invoke via the MCP `execute` sandbox, addressing the published tool through + // the catalog: tools.apps...issues-sync(...) routes through + // resolveTools/invokeTool -> the sandbox -> the per-request ClientResolver -> + // the GitHub emulator, then writes the scope db. + const call = (await client.callTool({ + name: "execute", + arguments: { + code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`, + }, + })) as { + isError?: boolean; + structuredContent?: { + status?: string; + result?: { ok?: boolean; data?: { synced?: number } }; + }; + }; + + expect(call.isError ?? false).toBe(false); + expect(call.structuredContent?.status).toBe("completed"); + // The execute sandbox wraps a tool result as { ok, data }. + expect(call.structuredContent?.result?.data?.synced).toBe(2); + + // The emulator's request ledger proves the tool really called GitHub. + const ledger = await github.ledger.list(); + expect(ledger.length).toBeGreaterThan(before); + expect( + ledger.some( + (e) => + String(e.operationId ?? "") + .toLowerCase() + .includes("issue") || + String((e as { path?: string }).path ?? "") + .toLowerCase() + .includes("issues"), + ), + ).toBe(true); + }); + + it("starts the workflow (manual) and sees it complete with a journal", async () => { + // Start the workflow through the REAL request path (the `start_workflow` + // built-in over the MCP execute sandbox), so its `step.tool` external call + // reaches the emulator through the caller's connection + per-request resolver. + const started = (await client.callTool({ + name: "execute", + arguments: { + code: + "export default await tools.executor.apps.start_workflow(" + + '{ workflow: "morning-sync", runId: "wire-morning" });', + }, + })) as { + isError?: boolean; + structuredContent?: { + status?: string; + result?: { data?: { status?: string } }; + }; + }; + expect(started.isError ?? false).toBe(false); + expect(started.structuredContent?.result?.data?.status).toBe("completed"); + + const hist = await api(`/api/apps/${SCOPE}/workflows/runs/wire-morning`, { + method: "GET", + }); + const steps = ((await hist.json()) as { steps: { name: string; status: string }[] }).steps; + expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe(true); + }); + + it("reads the ui:// resource over MCP (resources/read)", async () => { + const read = await client.readResource({ uri: `ui://${SCOPE}/dashboard` }); + const first = read.contents[0] as { + mimeType?: string; + text?: string; + _meta?: { ui?: { title?: string } }; + }; + expect(first.mimeType).toContain("mcp"); + expect(first.text).toContain("issues"); + expect(first._meta?.ui?.title).toBe("GitHub Issues"); + }); + + it("delivers an SSE invalidation frame over HTTP after a scope-db write", async () => { + const res = await api(`/api/apps/${SCOPE}/live`, { method: "GET" }); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + const first = await reader.read(); + expect(decoder.decode(first.value)).toContain("event: ready"); + + // Re-invoke issues-sync via HTTP to write the scope db (a fresh write bumps + // the version and fires the invalidation). + await api(`/api/apps/${SCOPE}/tools/issues-sync`, { + method: "POST", + body: JSON.stringify({ + args: { repos: [`${owner}/app`] }, + bindings: { github: { kind: "single", connection: GITHUB_CONNECTION } }, + }), + }); + + const invalidation = await Promise.race([ + (async () => { + // Drain frames until we see an invalidate (there may be keepalives). + for (let i = 0; i < 10; i++) { + const chunk = await reader.read(); + if (chunk.done) break; + const text = decoder.decode(chunk.value); + if (text.includes("event: invalidate")) return text; + } + return ""; + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error("SSE timeout")), 8000)), + ]); + expect(invalidation).toContain("event: invalidate"); + expect(invalidation).toContain('"table":"issues"'); + await reader.cancel(); + }); + + it("lists and reads the published skill over MCP", async () => { + const listed = (await client.callTool({ + name: "apps_list_skills", + arguments: {}, + })) as { structuredContent?: { skills?: { name: string }[] } }; + expect((listed.structuredContent?.skills ?? []).map((s) => s.name)).toEqual(["issues-brief"]); + + const readSkill = (await client.callTool({ + name: "apps_read_skill", + arguments: { name: "issues-brief" }, + })) as { content?: { text: string }[] }; + expect(readSkill.content?.[0]?.text).toContain("GitHub issues brief"); + }); +}); diff --git a/apps/host-selfhost/src/apps.node.test.ts b/apps/host-selfhost/src/apps.node.test.ts new file mode 100644 index 000000000..813f5dc9d --- /dev/null +++ b/apps/host-selfhost/src/apps.node.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "./testing/mint-invite"; + +// Point config at a throwaway data dir before importing the app graph. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); +process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; +let token = ""; + +// A JSON fetch that carries the bearer (the apps HTTP surface is authenticated). +const authed = (path: string, init?: RequestInit) => + handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }), + ); + +beforeAll(async () => { + // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps + // extension route under /api/apps/*. + const { makeSelfHostApiHandler } = await import("./app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; + + // Sign up to get a bearer token (the apps surface is behind Better Auth). + const inviteCode = await mintInviteCode(handler); + const su = await handler( + new Request("http://localhost/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@apps-node.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), + ); + token = su.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); +}); +afterAll(() => dispose()); + +// Fix 1: the apps HTTP surface rejects unauthenticated requests with 401. +test("apps HTTP surface requires auth (401 without a credential)", async () => { + // publish / invoke / workflow-start / SSE all 401 unauthenticated. + const publish = await handler( + new Request("http://localhost/api/apps/default/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ files: FILES }), + }), + ); + expect(publish.status).toBe(401); + + const invoke = await handler( + new Request("http://localhost/api/apps/default/tools/note", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ args: { text: "x" }, bindings: {} }), + }), + ); + expect(invoke.status).toBe(401); + + const sse = await handler( + new Request("http://localhost/api/apps/default/live", { method: "GET" }), + ); + expect(sse.status).toBe(401); +}, 30_000); + +// A minimal published app: one tool that writes the scope db, and a ui view. +const FILES = { + "tools/note.ts": + `import { z } from "zod";\nimport { defineTool } from "executor:app";\n` + + `export default defineTool({ description: "Save a note", input: z.object({ text: z.string() }), ` + + `async handler({ text }, { db }) { await db.sql\`CREATE TABLE IF NOT EXISTS notes (t TEXT)\`; ` + + `await db.sql\`INSERT INTO notes (t) VALUES (\${text})\`; const rows = await db.sql\`SELECT COUNT(*) AS n FROM notes\`; return { count: Number(rows[0].n) }; } });`, + "ui/board.tsx": + `import { config } from "executor:ui";\nconfig({ title: "Board", maxHeight: 400 });\n` + + `export default function App() { return null; }`, +}; + +test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => { + // Publish over the booted server's /api/apps/:scope/publish route (authed). + const publishRes = await authed("/api/apps/default/publish", { + method: "POST", + body: JSON.stringify({ files: FILES }), + }); + expect(publishRes.status).toBe(200); + const published = (await publishRes.json()) as { + descriptor: { tools: { name: string }[]; ui: { name: string }[] }; + }; + expect(published.descriptor.tools.map((t) => t.name)).toEqual(["note"]); + expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); + + // The ui bundle is served (compiled JS) with its title header. + const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" }); + expect(uiRes.status).toBe(200); + expect(uiRes.headers.get("content-type")).toContain("javascript"); + expect(uiRes.headers.get("x-ui-title")).toBe("Board"); + + // The HTML document variant (Fix 10 fallback target) is served behind auth. + const docRes = await authed("/api/apps/default/ui/board?document=html", { method: "GET" }); + expect(docRes.status).toBe(200); + expect(docRes.headers.get("content-type")).toContain("text/html"); + + // Invoke the tool (scope-db path is live in the running server). + const invokeRes = await authed("/api/apps/default/tools/note", { + method: "POST", + body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), + }); + expect(invokeRes.status).toBe(200); + const invoked = (await invokeRes.json()) as { result: { count: number } }; + expect(invoked.result.count).toBe(1); +}, 30_000); diff --git a/apps/host-selfhost/src/apps.ts b/apps/host-selfhost/src/apps.ts new file mode 100644 index 000000000..16bf195b3 --- /dev/null +++ b/apps/host-selfhost/src/apps.ts @@ -0,0 +1,119 @@ +import { Effect } from "effect"; + +import { + makeSelfHostApps, + BindingError, + connectionNameForScope, + type ClientResolver, + type SelfHostApps, +} from "@executor-js/plugin-apps/api"; + +import { resolveDataDir } from "./config"; +import { makeCtxResolver } from "./apps-resolver"; + +// --------------------------------------------------------------------------- +// Self-host wiring for the apps subsystem. +// +// The apps subsystem (packages/plugins/apps) owns custom tools, durable +// workflows, ui views and skills behind five substrate-neutral seams. This +// module builds it ONCE (a boot-time singleton) so the SAME runtime is shared +// by (a) the source plugin added to the executor plugin list in +// executor.config.ts, so published tools are real catalog citizens, (b) the +// MCP registration on the real MCP server, and (c) the HTTP surface. `plugins()` +// runs per request and re-instantiates the plugin, but every instance closes +// over this one runtime, so there is one published-descriptor store, one +// journal, one scheduler across the process. +// +// The `ClientResolver` is the one seam that reaches real integrations (the +// platform invoke path: credentials, policy, audit). Threading it through the +// executor catalog requires per-request executor context that the boot-time +// singleton does not hold; the running server ships a resolver that fails with +// a typed NotImplemented for external calls, while the scope-database path +// (`db.sql`) is fully live. See the note in DESIGN / the remaining-gap section. +// The full real external-call path is exercised end-to-end in the package e2e +// and the booted-host wire e2e against the emulate GitHub. +// --------------------------------------------------------------------------- + +/** The single scope this self-host instance serves (single-tenant). */ +export const SELF_HOST_SCOPE = "default"; + +/** The apps connection name for the self-host scope (formalized mapping). */ +export const SELF_HOST_APPS_CONNECTION = connectionNameForScope(SELF_HOST_SCOPE); + +const notImplementedResolver: ClientResolver = { + call: ({ integration }) => + Effect.fail( + new BindingError({ + message: + `external integration "${integration}" routing is not wired into the running self-host ` + + `server yet (the ClientResolver -> catalog bridge needs per-request executor context). ` + + `Scope-database tools work; see the apps package e2e for the full external-call path.`, + role: integration, + surface: integration, + }), + ), +}; + +/** Options for the apps subsystem singleton. `authenticate` gates the HTTP + * surface; supplied by `app.ts` from the resolved Better Auth instance. + * `webBaseUrl` is the public origin used to build the `apps_open_ui` HTTP + * fallback URL. */ +export interface SelfHostAppsSubsystemOptions { + readonly authenticate?: (request: Request) => Promise; + readonly webBaseUrl?: string; +} + +// The boot-time singleton. Built lazily on first access so importing this module +// (which executor.config.ts does) does not do filesystem work at import time. +let subsystem: SelfHostApps | undefined; + +// The live authenticate + fallback-URL refs. The subsystem is a singleton and is +// FIRST built by executor.config.ts (which has no auth), then app.ts calls again +// WITH auth. So we can't bake auth into the http handler at build time — instead +// the handler reads these mutable refs per request, and app.ts sets them once the +// Better Auth instance is resolved. Until set, the surface DENIES all requests +// (fail-closed), so a misconfigured boot never exposes the surface unauthed. +let authenticateRef: ((request: Request) => Promise) | undefined; +let webBaseUrlRef: string | undefined; + +export const getSelfHostAppsSubsystem = ( + options: SelfHostAppsSubsystemOptions = {}, +): SelfHostApps => { + // Apply any newly-supplied config to the live refs (app.ts's call carries the + // real auth even though config.ts built the singleton first). + if (options.authenticate) authenticateRef = options.authenticate; + if (options.webBaseUrl) webBaseUrlRef = options.webBaseUrl; + + if (!subsystem) { + subsystem = makeSelfHostApps({ + dataDir: resolveDataDir(), + // Boot-time fallback for calls made outside a request (e.g. the scheduler + // firing a workflow with no request ctx). The per-request `makeResolver` + // below is the real path for catalog-invoked tools. + resolver: notImplementedResolver, + scope: SELF_HOST_SCOPE, + // The REAL per-request resolver: built from the invoking executor context + // so external integration calls resolve the user's connection + credential + // at the boundary and dispatch over the request's HttpClient. + makeResolver: ({ ctx }) => makeCtxResolver(ctx), + // Read the live auth ref per request. Fail-closed: if app.ts hasn't wired + // the Better Auth check yet, deny (never expose the surface unauthed). + authenticate: (request) => + authenticateRef ? authenticateRef(request) : Promise.resolve(false), + // Read the live web base URL ref (used for the apps_open_ui fallback URL). + webBaseUrl: undefined, + webBaseUrlFn: () => webBaseUrlRef, + }); + } + return subsystem; +}; + +/** Reset the singleton (tests build fresh instances per data dir). */ +export const resetSelfHostAppsSubsystem = (): void => { + subsystem = undefined; + authenticateRef = undefined; + webBaseUrlRef = undefined; +}; + +/** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ +export const makeSelfHostAppsSubsystem = getSelfHostAppsSubsystem; diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518c..59427953c 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -133,8 +133,11 @@ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, webBaseUrl?: string, + /** Host extension: register additional (non-catalog) MCP tools/resources on + * each per-session server (the apps publish door, skills, ui:// resources). */ + onServer?: (server: import("@executor-js/api/server").McpServer) => void, ): SelfHostMcpSeams => { - const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); + const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl, onServer); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf62..a86a1762e 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,6 +1,10 @@ import { Layer } from "effect"; -import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; +import { + makeConsoleMcpErrorReporter, + makeMcpBuildServer, + type McpServer, +} from "@executor-js/api/server"; import type { McpErrorReporter } from "@executor-js/host-mcp"; import { inMemoryMcpSessionsLayer, @@ -32,10 +36,14 @@ export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-sto export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, + /** Host extension: register additional (non-catalog) tools/resources on each + * per-session MCP server (the apps publish door, skills tools, ui:// ). */ + onServer?: (server: McpServer) => void, ): InMemoryMcpSessionStore => makeInMemoryMcpSessionStore( makeMcpBuildServer( SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + onServer, ), { webBaseUrl }, ); diff --git a/bun.lock b/bun.lock index e1f7250b2..24e555359 100644 --- a/bun.lock +++ b/bun.lock @@ -224,6 +224,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -246,6 +247,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/emulate": "^0.13.2", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", @@ -781,6 +783,38 @@ "effect": "catalog:", }, }, + "packages/plugins/apps": { + "name": "@executor-js/plugin-apps", + "version": "0.1.0", + "dependencies": { + "@executor-js/codemode-core": "workspace:*", + "@executor-js/config": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@standard-schema/spec": "^1.0.0", + "esbuild": "^0.27.3", + "zod": "4.3.6", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.13.2", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/api": "workspace:*", + "effect": "catalog:", + }, + "optionalPeers": [ + "@executor-js/api", + ], + }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", "version": "1.5.28", @@ -1215,9 +1249,9 @@ }, }, "patchedDependencies": { - "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", + "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, @@ -1776,6 +1810,8 @@ "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], + "@executor-js/plugin-apps": ["@executor-js/plugin-apps@workspace:packages/plugins/apps"], + "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore new file mode 100644 index 000000000..014c4cc36 --- /dev/null +++ b/e2e/mcp-apps/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +.server.json diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md new file mode 100644 index 000000000..8ea657e95 --- /dev/null +++ b/e2e/mcp-apps/README.md @@ -0,0 +1,66 @@ +# MCP Apps tests (sunpeak) — executor apps' published UI + +Mount and test **executor apps' published UI** (the daily-brief dashboard, served +as a `ui://` MCP-Apps resource) in a **real MCP-Apps host**, headlessly, in CI, +with **no VM and no Claude/ChatGPT account**. + +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts +the returned `ui://` resource in a sandboxed iframe with the host bridge, and +hands the test a frame-scoped handle to the rendered component. Every test runs +against both host simulations. + +## Run + +```bash +cd e2e/mcp-apps +npm install +npm test # boots our self-host MCP server + sunpeak, runs the specs +``` + +`playwright.config.ts` starts two web servers: our self-host wrapper +(`scripts/start-server.mjs`, under Bun) and sunpeak's inspect backend. The +wrapper boots the REAL self-host app in-process, publishes the daily-brief app, +populates its scope-db `issues` table from a GitHub emulator, and serves `/mcp` +on a fixed loopback port with the Better-Auth bearer injected (so sunpeak needs +no credentials). + +A spec is tiny: + +```ts +import { test, expect } from "sunpeak/test"; +test("dashboard mounts", async ({ inspector }) => { + const result = await inspector.renderTool("apps_open_ui", { name: "dashboard" }); + await expect(result.app().getByText("Open issues")).toBeVisible(); + await expect(result.app().getByText(/\/app#\d+/)).toBeVisible(); +}); +``` + +## How our UI reaches a host + +Our published `ui:///` resource is a COMPLETE, self-booting HTML +document (`text/html;profile=mcp-app`): React, the `executor:ui` runtime +(`useQuery`/`useTool`/`config`), the compiled component, and the current +scope-db rows are all inlined, so it renders under a strict sandbox CSP with no +network. The `apps_open_ui` MCP tool declares `_meta.ui.resourceUri` pointing at +that resource, which is how a real MCP-Apps host (and sunpeak) knows to render +it when the tool runs. See `packages/plugins/apps/src/mcp/ui-shell.ts` (document +builder) and `.../mcp/register.ts` (tool + resource template). + +## Notes (vs the earlier render-ui harness) + +1. **Use the LATEST sunpeak and DO NOT patch it.** sunpeak now advertises the + MCP-Apps UI _client_ capability upstream, so widgets mount inline without the + old `scripts/patch-sunpeak.mjs` (dropped here on purpose). +2. **Extra iframe descent.** Our shell mounts the component directly in the host + sandbox iframe, so `result.app()` reaches it. If the shell is ever changed to + nest a further `srcdoc` iframe, add one `.frameLocator("iframe")` descent in + the spec. + +## Scope + +sunpeak is a host _simulation_: it covers the protocol contract, the rendered UI, +and tool/theme/display-mode behavior. It does not catch real-host quirks (OAuth, +production CSP). Live SSE-driven refetch INTO the mounted widget is a documented +follow-up (see `APPS_DESIGN.md`); this harness proves mount + first-paint row +rendering. diff --git a/e2e/mcp-apps/package-lock.json b/e2e/mcp-apps/package-lock.json new file mode 100644 index 000000000..5162a9eab --- /dev/null +++ b/e2e/mcp-apps/package-lock.json @@ -0,0 +1,3010 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@executor-js/e2e-mcp-apps", + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.60" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.8.tgz", + "integrity": "sha512-ZqT9kLxS2Cbo/4juwtEGbYyP0jNQTnUUKy2nQ8Vn28xqNu50+TukO/oCEWCpm18YbQq7N+hr6ClJs/ATTFpBTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.12.tgz", + "integrity": "sha512-Y7Fy8xJwPz7ZC0DhSQG3HIVk+drup42hrIj6yqKlib3CxwiR0F7nYyUI8+kPrEtbZEoyKoRstvT4/o0HEyFBHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.7.tgz", + "integrity": "sha512-55K0j8RRjcKosUvIl8u/+SMaS1wedq77xN2iuhlOvB/USbIgSmjkWKV9lqGhhp+Qxhnm3qg5NzrqOI1jmra9fQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz", + "integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz", + "integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", + "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ai": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.15.tgz", + "integrity": "sha512-7406MUy9O5sIhwOgxEWuoj+td3XUGgG96SBt6pmBU4t4sQ2fpnDx5UnHaXT2HUTXl5GorbWz/MfCrSPRz0QJqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.12", + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/sunpeak": { + "version": "0.20.60", + "resolved": "https://registry.npmjs.org/sunpeak/-/sunpeak-0.20.60.tgz", + "integrity": "sha512-mHpnB/GnQEzmET6vHDKFc5bTar5Iff4p7VEg3A/W218FbhUNSmyXFeNjdtZ10QTChFPgDguVAr7Y41KnKWikvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/openai": "^4.0.0", + "@clack/prompts": "^1.6.0", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.29.0", + "@vitejs/plugin-react": "^6.0.3", + "ai": "^7.0.2", + "clsx": "^2.1.1", + "esbuild": "^0.28.1", + "tailwind-merge": "^3.6.0", + "vite": "8.1.0", + "zod": "^4.4.3" + }, + "bin": { + "sunpeak": "bin/sunpeak.js" + }, + "peerDependencies": { + "@ai-sdk/google": "^4.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/google": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json new file mode 100644 index 000000000..1512f9632 --- /dev/null +++ b/e2e/mcp-apps/package.json @@ -0,0 +1,15 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "private": true, + "description": "Mount + test executor apps' published UI in a real MCP-Apps host (sunpeak), headless, no VM.", + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.60" + } +} diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts new file mode 100644 index 000000000..3caa10114 --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,58 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { defineConfig } from "sunpeak/test/config"; + +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host simulation that mounts +// ui:// resources in a sandboxed iframe) as its Playwright web server, connects +// it to the MCP server below, and gives each spec a frame-scoped handle to the +// rendered app. Every spec runs against both the Claude and ChatGPT host +// simulations. +// +// We point sunpeak at OUR self-host over HTTP. `scripts/start-server.mjs` boots +// the real self-host app in-process, publishes the daily-brief app + populates +// its scope-db `issues` table, and serves `/mcp` on a fixed loopback port with +// the Better-Auth bearer injected (so sunpeak needs no credentials). We append +// that wrapper as a SECOND Playwright webServer (health-gated), alongside +// sunpeak's inspect backend. +// +// IMPORTANT (vs the older render-ui harness): use the LATEST sunpeak and DO NOT +// patch it. sunpeak now advertises the MCP-Apps UI *client* capability upstream, +// so widgets mount inline without the old scripts/patch-sunpeak.mjs. + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); +const hostDir = resolve(repo, "apps/host-selfhost"); + +const PORT = process.env.MCP_APPS_PORT ?? "8791"; + +const base = defineConfig({ + testDir: "tests", + hosts: ["claude", "chatgpt"], + server: { url: `http://127.0.0.1:${PORT}/mcp` }, + timeout: 120_000, +}); + +// Our self-host wrapper server, started before the specs and torn down after. +const ourServer = { + // The seeding server lives under apps/host-selfhost so its imports (emulate, + // MCP SDK, workspace source) resolve through that package's node_modules. + command: `bun ${resolve(hostDir, "scripts/mcp-apps-serve.ts")}`, + cwd: hostDir, + url: `http://127.0.0.1:${PORT}/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { PORT }, +}; + +const baseWebServers = Array.isArray(base.webServer) + ? base.webServer + : base.webServer + ? [base.webServer] + : []; + +export default { + ...base, + // Start our MCP server first, then sunpeak's inspect backend. + webServer: [ourServer, ...baseWebServers], +}; diff --git a/e2e/mcp-apps/tests/dashboard.spec.ts b/e2e/mcp-apps/tests/dashboard.spec.ts new file mode 100644 index 000000000..dc9098487 --- /dev/null +++ b/e2e/mcp-apps/tests/dashboard.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "sunpeak/test"; + +// executor apps serve a published UI view (`ui:///dashboard`) as a +// complete, self-booting MCP-Apps HTML document (React + the executor:ui runtime +// + the compiled component + the current scope-db rows, all inline). The +// `apps_open_ui` tool declares `_meta.ui.resourceUri` linking to that resource, +// so a real MCP-Apps host renders the widget when the tool runs. +// +// This spec proves the widget actually MOUNTS and RENDERS rows from the scope db +// inside a simulated host. sunpeak runs it against BOTH the Claude and ChatGPT +// host simulations (Playwright projects). + +// Our published document mounts the component directly inside the host's sandbox +// iframe. sunpeak's `result.app()` already descends one nested iframe; if our +// shell ever nests a further srcdoc iframe, add one more `.frameLocator("iframe")` +// descent here (see README). +const appBody = (result: { app: () => ReturnType["app"]> } | any) => + result.app(); + +test("the published dashboard mounts and renders scope-db issue rows", async ({ inspector }) => { + // No input: `apps_open_ui` takes none. sunpeak renders the tool's declared + // ui resource (`_meta.ui.resourceUri` -> ui:///dashboard) in the host + // sandbox. + const result = await inspector.renderTool("apps_open_ui"); + const app = appBody(result); + + // The widget's chrome renders (proves React mounted inside the host sandbox). + await expect(app.getByText("Open issues", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // The scope-db rows the server inlined render: the daily-brief `issues-sync` + // populated the `issues` table (2 issues) from the GitHub emulator, and the + // dashboard shows the live count + lists each `repo#number`. + await expect(app.getByText("2 open issues")).toBeVisible({ timeout: 30_000 }); + await expect(app.getByText(/\/app#\d+/).first()).toBeVisible({ + timeout: 30_000, + }); +}); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index a909a8130..bc2005d62 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -34,6 +34,7 @@ export { makeMcpBuildServer, makeConsoleMcpErrorReporter, type McpExecutionStackLayer, + type McpServer, } from "./server/mcp-build"; // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author // contract) into this host surface. The pure FumaDB assembly (`createExecutorFumaDb` diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index c274cd623..94bd3d844 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -6,7 +6,9 @@ import { type McpBuildServer, type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { createExecutorMcpServer, type McpServer } from "@executor-js/host-mcp/tool-server"; + +export type { McpServer }; import { ErrorCapture } from "../observability"; import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; @@ -37,7 +39,12 @@ export type McpExecutionStackLayer = Layer.Layer< * in the injected stack layer (libSQL vs D1, etc.). */ export const makeMcpBuildServer = - (executionStack: McpExecutionStackLayer): McpBuildServer => + ( + executionStack: McpExecutionStackLayer, + /** Host extension: register additional (non-catalog) tools/resources on each + * per-session MCP server (e.g. the apps publish door + skills + ui:// ). */ + onServer?: (server: McpServer) => void, + ): McpBuildServer => (principal: Principal, options?: McpBuildServerOptions) => makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { mcpResource: options?.resource, @@ -51,7 +58,7 @@ export const makeMcpBuildServer = Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), Effect.flatMap((engine) => - createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe( + createExecutorMcpServer({ engine, ...(options ?? {}), onServer }).pipe( Effect.map((mcpServer) => ({ mcpServer, engine })), ), ), diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 07daf0e23..6b27f737c 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,6 +1,8 @@ import { Duration, Effect, Match, Option, Schema } from "effect"; import * as Cause from "effect/Cause"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export type { McpServer }; import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js"; import type { jsonSchemaValidator, @@ -118,6 +120,15 @@ type SharedMcpServerConfig = { executionId: string, response: ResumeResponse, ) => Effect.Effect; + /** + * Host extension hook: called with the fully-built `McpServer` just before it + * is returned, so a host can register ADDITIONAL tools/resources on the same + * server the catalog `execute` surface lives on (e.g. the apps subsystem's + * publish door, skills tools, and ui:// resources). The catalog tools + * themselves still come through the plugin/`execute` path; this is only for + * host-specific surfaces that are not catalog tools. + */ + readonly onServer?: (server: McpServer) => void; }; export type ExecutorMcpServerConfig = @@ -1035,5 +1046,14 @@ export const createExecutorMcpServer = ( }); }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); + // Host extension hook: register any additional (non-catalog) tools/resources + // on the built server before handing it back (e.g. the apps publish door, + // skills, ui:// resources). + if (config.onServer) { + yield* Effect.sync(() => config.onServer!(server)).pipe( + Effect.withSpan("mcp.host.on_server_extension"), + ); + } + return server; }).pipe(Effect.withSpan("mcp.host.create_executor_server")); diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json new file mode 100644 index 000000000..3127b84fb --- /dev/null +++ b/packages/plugins/apps/package.json @@ -0,0 +1,61 @@ +{ + "name": "@executor-js/plugin-apps", + "version": "0.1.0", + "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/apps", + "bugs": { + "url": "https://github.com/UsefulSoftwareCo/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", + "directory": "packages/plugins/apps" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./api": "./src/api.ts", + "./seams": "./src/seams/index.ts", + "./testing": "./src/testing/index.ts" + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/codemode-core": "workspace:*", + "@executor-js/config": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@standard-schema/spec": "^1.0.0", + "esbuild": "^0.27.3", + "zod": "4.3.6" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.13.2", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@executor-js/api": "workspace:*", + "effect": "catalog:" + }, + "peerDependenciesMeta": { + "@executor-js/api": { + "optional": true + } + } +} diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts new file mode 100644 index 000000000..414b60706 --- /dev/null +++ b/packages/plugins/apps/src/api.ts @@ -0,0 +1,15 @@ +// HTTP + plugin surface for @executor-js/plugin-apps. +export { + appsPlugin, + APPS_INTEGRATION_SLUG, + APPS_PLUGIN_ID, + APPS_CONNECTION_PREFIX, + connectionNameForScope, + scopeFromConnection, + type AppsPluginOptions, +} from "./plugin/apps-plugin"; +export { makeAppsHttpRoutes, type AppsHttpDeps } from "./http/routes"; +export { registerAppsMcp, type AppsMcpDeps, type McpServerLike } from "./mcp/register"; +export { makeSelfHostApps, type SelfHostApps, type SelfHostAppsOptions } from "./plugin/self-host"; +export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; +export { BindingError, type ClientResolver, type Bindings } from "./plugin/bindings"; diff --git a/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts b/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts new file mode 100644 index 000000000..a1438a8d2 --- /dev/null +++ b/packages/plugins/apps/src/backing/git-artifact-store.cas.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeGitArtifactStore } from "./git-artifact-store"; +import type { ArtifactStoreError } from "../seams/artifact-store"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +// --------------------------------------------------------------------------- +// Finding 6 regression (git compare-and-swap): two commits built from the SAME +// parent cannot both advance the branch. The ref update is a CAS against the +// expected old value, so the second raced writer gets a typed conflict instead +// of silently clobbering the first. This is deterministic (no timing race): we +// force both to share a parent by committing them against a store whose head we +// pin. +// --------------------------------------------------------------------------- + +describe("git ArtifactStore ref CAS (Fix 6)", () => { + it("two concurrent commits from one parent: one wins, the other typed-conflicts", async () => { + const root = mkdtempSync(join(tmpdir(), "apps-cas-")); + const storeA = makeGitArtifactStore({ root }); + const scopeA = await run(storeA.forScope("s")); + + // Seed a parent so racers commit ON TOP of the same head. + await run(scopeA.commit(new Map([["tools/base.ts", "// base"]]), "base")); + + // Many SEPARATE store instances over the SAME repo dir. The repo already + // exists (seeded above), so opening each scope is a re-`init --bare` (a no-op + // on an existing repo). Open them SEQUENTIALLY so the idempotent init doesn't + // itself race, then race only the COMMITS (the ref-CAS is what's under test). + const N = 12; + const openedScopes: { commit: typeof scopeA.commit; log: typeof scopeA.log }[] = []; + for (let i = 0; i < N; i++) { + const store = makeGitArtifactStore({ root }); + // eslint-disable-next-line no-await-in-loop + openedScopes.push(await run(store.forScope("s"))); + } + const scopeB = openedScopes[0]; + + // Fire many concurrent commits, ALL built against the seeded base parent. + const results = await Promise.allSettled( + openedScopes.map((sc, i) => + run(sc.commit(new Map([[`tools/c${i}.ts`, `// ${i}`]]), `c${i}`)), + ), + ); + + const fulfilled = results.filter((r) => r.status === "fulfilled").length; + const rejected = results.filter((r) => r.status === "rejected"); + + // Any failure MUST be a typed conflict (never a silent clobber). + for (const r of rejected) { + const err = (r as PromiseRejectedResult).reason as ArtifactStoreError; + expect(String(JSON.stringify(err))).toMatch(/conflict/i); + } + + // No lost commits: the git log length equals base(1) + every commit that + // reported success. Without the CAS, a raced writer clobbers the ref and an + // earlier successful commit vanishes from history, making the log SHORTER + // than base + fulfilled. The CAS guarantees every reported success is a real, + // reachable, non-clobbering advance. + const logEntries = await run(scopeB.log(1000)); + expect(logEntries.length).toBe(1 + fulfilled); + }, 60_000); +}); diff --git a/packages/plugins/apps/src/backing/git-artifact-store.test.ts b/packages/plugins/apps/src/backing/git-artifact-store.test.ts new file mode 100644 index 000000000..e1a434fa4 --- /dev/null +++ b/packages/plugins/apps/src/backing/git-artifact-store.test.ts @@ -0,0 +1,10 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { artifactStoreConformance } from "../seams/artifact-store.conformance"; +import { makeGitArtifactStore } from "./git-artifact-store"; + +artifactStoreConformance("git", () => + makeGitArtifactStore({ root: mkdtempSync(join(tmpdir(), "apps-artifacts-")) }), +); diff --git a/packages/plugins/apps/src/backing/git-artifact-store.ts b/packages/plugins/apps/src/backing/git-artifact-store.ts new file mode 100644 index 000000000..6d231f029 --- /dev/null +++ b/packages/plugins/apps/src/backing/git-artifact-store.ts @@ -0,0 +1,305 @@ +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { Effect } from "effect"; + +import { + ArtifactStoreError, + asSnapshotId, + type ArtifactStore, + type FileSet, + type ScopeArtifactStore, + type SnapshotId, + type SnapshotMeta, +} from "../seams/artifact-store"; + +// --------------------------------------------------------------------------- +// Git-backed ArtifactStore (self-hosted). One bare git repo per scope under +// `/.git`. Snapshots are commits, written via git plumbing +// (hash-object -> update-index -> write-tree -> commit-tree) so no working tree +// is ever checked out — the runtime only reads committed snapshots. The commit +// hash IS the SnapshotId; git guarantees immutability (content-addressed). +// --------------------------------------------------------------------------- + +const BRANCH = "refs/heads/main"; + +// The "must not exist" sentinel for `git update-ref `: an empty +// old-value asserts the ref currently has NO value (the first publish to a fresh +// scope repo). git treats the empty string as "the ref must not already exist". +const EMPTY_OID = ""; + +const run = ( + cwd: string, + args: readonly string[], + input?: string | Buffer, +): Effect.Effect => + Effect.callback((resume) => { + const child = execFile( + "git", + args, + { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "buffer" }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + if (input !== undefined) { + child.stdin?.write(input); + child.stdin?.end(); + } + }); + +const sanitizeScope = (scope: string): string => { + if (!/^[a-zA-Z0-9._-]+$/.test(scope)) { + // Scopes are internal identifiers; refuse anything that could escape a path. + throw new ArtifactStoreError({ message: `invalid scope key: ${scope}` }); + } + return scope; +}; + +const makeScopeStore = (repoDir: string): ScopeArtifactStore => { + // Environment forcing a deterministic author/committer so a snapshot's hash + // depends only on its content + parent, not on wall-clock/identity drift. + const commitEnv = (message: string) => + Effect.gen(function* () { + const parent = yield* headCommit(); + // Build a tree from the provided file set held in a bare index file. + return { parent, message }; + }); + + const headCommit = (): Effect.Effect => + run(repoDir, ["rev-parse", "--verify", "--quiet", BRANCH]).pipe( + Effect.map((out) => out.trim() || null), + Effect.catch(() => Effect.succeed(null)), + ); + + const readMeta = (id: string): Effect.Effect => + run(repoDir, ["show", "-s", "--format=%H%n%ct%n%s", id]).pipe( + Effect.map((out) => { + const [hash, committed, ...subjectParts] = out.split("\n"); + return { + id: asSnapshotId(hash.trim()), + committedAt: Number(committed.trim()) * 1000, + message: subjectParts.join("\n").trim(), + } satisfies SnapshotMeta; + }), + ); + + return { + commit: (files: FileSet, message: string) => + Effect.gen(function* () { + const { parent } = yield* commitEnv(message); + // Use a throwaway index so we never touch a working tree. + const indexFile = join( + repoDir, + `commit-index-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const withIndex = (args: readonly string[], input?: string | Buffer) => + Effect.callback((resume) => { + const child = execFile( + "git", + args, + { + cwd: repoDir, + maxBuffer: 256 * 1024 * 1024, + encoding: "buffer", + env: { ...process.env, GIT_INDEX_FILE: indexFile }, + }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + if (input !== undefined) { + child.stdin?.write(input); + child.stdin?.end(); + } + }); + + // Start from an empty index each publish (full file set, not a diff). + yield* withIndex(["read-tree", "--empty"]); + for (const [path, contents] of files) { + const blobHash = (yield* withIndex( + ["hash-object", "-w", "--stdin"], + Buffer.from(contents, "utf8"), + )).trim(); + yield* withIndex(["update-index", "--add", "--cacheinfo", `100644,${blobHash},${path}`]); + } + const treeHash = (yield* withIndex(["write-tree"])).trim(); + const commitArgs = ["commit-tree", treeHash, "-m", message]; + if (parent) commitArgs.push("-p", parent); + const commitEnvVars = { + ...process.env, + GIT_AUTHOR_NAME: "executor-apps", + GIT_AUTHOR_EMAIL: "apps@executor.local", + GIT_COMMITTER_NAME: "executor-apps", + GIT_COMMITTER_EMAIL: "apps@executor.local", + }; + const commitHash = (yield* Effect.callback((resume) => { + execFile( + "git", + commitArgs, + { cwd: repoDir, encoding: "buffer", env: commitEnvVars }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git commit-tree failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + })).trim(); + // Compare-and-swap the branch ref: `update-ref ` fails + // if HEAD moved since we read `parent`, so a concurrent publish that + // committed first cannot be silently clobbered. On the first commit there + // is no parent, so we assert the ref is absent (the empty-oid form). A CAS + // failure surfaces as a typed conflict for the caller to retry from a + // fresh parent. + const expectedOld = parent ?? EMPTY_OID; + yield* run(repoDir, ["update-ref", BRANCH, commitHash, expectedOld]).pipe( + Effect.mapError( + (cause) => + new ArtifactStoreError({ + message: `publish conflict: ${BRANCH} moved concurrently (expected ${expectedOld}); retry from the new head`, + conflict: true, + cause, + }), + ), + ); + return yield* readMeta(commitHash); + }), + + read: (id: SnapshotId) => + run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe( + Effect.flatMap((out) => { + const paths = out + .split("\n") + .map((p) => p.trim()) + .filter(Boolean); + return Effect.forEach( + paths, + (path) => + run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe( + Effect.map((contents) => [path, contents] as const), + ), + { concurrency: 8 }, + ); + }), + Effect.map((entries) => new Map(entries) as FileSet), + ), + + readFile: (id: SnapshotId, path: string) => + run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe( + Effect.map((contents) => contents as string | null), + Effect.catch(() => Effect.succeed(null)), + ), + + list: (id: SnapshotId) => + run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe( + Effect.map((out) => + out + .split("\n") + .map((p) => p.trim()) + .filter(Boolean), + ), + ), + + latest: () => + headCommit().pipe(Effect.flatMap((head) => (head ? readMeta(head) : Effect.succeed(null)))), + + log: (limit = 50) => + headCommit().pipe( + Effect.flatMap((head) => { + if (!head) return Effect.succeed([] as readonly SnapshotMeta[]); + return run(repoDir, [ + "log", + `--max-count=${limit}`, + "--format=%H%x1f%ct%x1f%s", + BRANCH, + ]).pipe( + Effect.map((out) => + out + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [hash, committed, subject] = line.split("\x1f"); + return { + id: asSnapshotId(hash), + committedAt: Number(committed) * 1000, + message: subject ?? "", + } satisfies SnapshotMeta; + }), + ), + ); + }), + ), + }; +}; + +export interface GitArtifactStoreOptions { + /** Directory holding one bare repo per scope. Created on demand. */ + readonly root: string; +} + +/** Build the git-backed ArtifactStore. Each scope gets a lazily-initialized + * bare repo `/.git`. */ +export const makeGitArtifactStore = (options: GitArtifactStoreOptions): ArtifactStore => { + const initialized = new Map>(); + + const init = async (scope: string): Promise => { + const safe = sanitizeScope(scope); + const repoDir = join(options.root, `${safe}.git`); + await mkdir(repoDir, { recursive: true }); + await new Promise((resolve, reject) => { + execFile("git", ["init", "--bare", "--quiet"], { cwd: repoDir }, (error) => + error ? reject(error) : resolve(), + ); + }); + return makeScopeStore(repoDir); + }; + + return { + forScope: (scope) => + Effect.tryPromise({ + try: () => { + let existing = initialized.get(scope); + if (!existing) { + existing = init(scope); + initialized.set(scope, existing); + } + return existing; + }, + catch: (cause) => + new ArtifactStoreError({ message: `failed to open scope repo: ${scope}`, cause }), + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/in-process-live-channel.ts b/packages/plugins/apps/src/backing/in-process-live-channel.ts new file mode 100644 index 000000000..b73111070 --- /dev/null +++ b/packages/plugins/apps/src/backing/in-process-live-channel.ts @@ -0,0 +1,34 @@ +import { Effect } from "effect"; + +import type { Invalidation, LiveChannel } from "../seams/live-channel"; + +// --------------------------------------------------------------------------- +// In-process LiveChannel (self-hosted). A per-scope set of listeners; publish +// fans out synchronously. The self-host server exposes each subscription as an +// SSE stream. The cloud backing (future) makes the storage owner the notifier. +// --------------------------------------------------------------------------- + +export const makeInProcessLiveChannel = (): LiveChannel => { + const byScope = new Map void>>(); + + return { + publish: (event: Invalidation) => + Effect.sync(() => { + const listeners = byScope.get(event.scope); + if (!listeners) return; + for (const listener of listeners) listener(event); + }), + subscribe: (scope: string, listener: (event: Invalidation) => void) => { + let set = byScope.get(scope); + if (!set) { + set = new Set(); + byScope.set(scope, set); + } + set.add(listener); + return () => { + set?.delete(listener); + if (set && set.size === 0) byScope.delete(scope); + }; + }, + }; +}; diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.test.ts b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts new file mode 100644 index 000000000..6e79cf695 --- /dev/null +++ b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts @@ -0,0 +1,9 @@ +import { scopeDbConformance } from "../seams/scope-db.conformance"; +import { makeLibsqlScopeDb } from "./libsql-scope-db"; +import { makeInProcessLiveChannel } from "./in-process-live-channel"; + +scopeDbConformance( + "libsql (in-memory)", + () => makeLibsqlScopeDb({ root: ":memory:" }), + () => makeInProcessLiveChannel(), +); diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.ts b/packages/plugins/apps/src/backing/libsql-scope-db.ts new file mode 100644 index 000000000..291812469 --- /dev/null +++ b/packages/plugins/apps/src/backing/libsql-scope-db.ts @@ -0,0 +1,207 @@ +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import { + ScopeDbError, + type ScopeDb, + type ScopeDbHandle, + type ScopeWriteEvent, +} from "../seams/scope-db"; + +// --------------------------------------------------------------------------- +// libSQL-backed ScopeDb (self-hosted). One SQLite file per scope under +// `/.db`. A control table `__versions` holds a monotonic counter +// per user table; every write statement bumps the counters for the tables it +// touched and emits a `ScopeWriteEvent` (the runtime forwards that to +// LiveChannel). Scope isolation is a separate file per scope — there is no +// cross-scope query path. +// --------------------------------------------------------------------------- + +const VERSION_TABLE = "__scope_versions"; + +// Statements that mutate data. Determines whether a `sql` call bumps versions. +const WRITE_RE = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i; + +// Extract the table names a write statement targets (best-effort: covers the +// shapes authored tools produce — INSERT INTO x, UPDATE x, DELETE FROM x, +// CREATE TABLE x, REPLACE INTO x). +const targetsOf = (sql: string): string[] => { + const out = new Set(); + const patterns = [ + /\binsert\s+(?:or\s+\w+\s+)?into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\breplace\s+into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bupdate\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bdelete\s+from\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bdrop\s+table\s+(?:if\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\balter\s+table\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + ]; + for (const re of patterns) { + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const name = m[1]; + if (name && name !== VERSION_TABLE) out.add(name); + } + } + return [...out]; +}; + +const toArgs = (values: readonly unknown[]): unknown[] => + values.map((v) => (v === undefined ? null : v)); + +const makeHandle = ( + scope: string, + client: Client, + emit: (event: ScopeWriteEvent) => void, +): ScopeDbHandle => { + const ensureVersionTable = async () => { + await client.execute( + `CREATE TABLE IF NOT EXISTS ${VERSION_TABLE} (name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)`, + ); + }; + + const bump = async (tables: readonly string[]): Promise<{ table: string; version: number }[]> => { + const bumped: { table: string; version: number }[] = []; + for (const table of tables) { + await client.execute({ + sql: `INSERT INTO ${VERSION_TABLE} (name, version) VALUES (?, 1) + ON CONFLICT(name) DO UPDATE SET version = version + 1`, + args: [table], + }); + const row = await client.execute({ + sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, + args: [table], + }); + bumped.push({ table, version: Number(row.rows[0]?.version ?? 0) }); + } + return bumped; + }; + + const runStatement = ( + sql: string, + args: unknown[], + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const result = await client.execute({ sql, args: args as never }); + if (WRITE_RE.test(sql)) { + const targets = targetsOf(sql); + if (targets.length > 0) { + const bumped = await bump(targets); + if (bumped.length > 0) emit({ scope, tables: bumped }); + } + } + return result.rows as unknown as readonly Row[]; + }, + catch: (cause) => + new ScopeDbError({ message: `scope-db statement failed for scope ${scope}`, cause }), + }); + + return { + sql: >(strings: TemplateStringsArray, ...values: unknown[]) => { + const sql = strings.reduce((acc, part, i) => acc + part + (i < values.length ? "?" : ""), ""); + return runStatement(sql, toArgs(values)); + }, + exec: >(sql: string, params: readonly unknown[] = []) => + runStatement(sql, toArgs(params)), + tableVersion: (table: string) => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const row = await client.execute({ + sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, + args: [table], + }); + return Number(row.rows[0]?.version ?? 0); + }, + catch: (cause) => new ScopeDbError({ message: "tableVersion failed", cause }), + }), + versions: () => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const rows = await client.execute(`SELECT name, version FROM ${VERSION_TABLE}`); + const out = new Map(); + for (const r of rows.rows) out.set(String(r.name), Number(r.version)); + return out as ReadonlyMap; + }, + catch: (cause) => new ScopeDbError({ message: "versions failed", cause }), + }), + }; +}; + +export interface LibsqlScopeDbOptions { + /** Directory holding one SQLite file per scope, or ":memory:" for tests. */ + readonly root: string; +} + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +// A reversible, collision-free filename key for a scope (Fix 9). A scope already +// composed only of filename-safe characters is used verbatim (so distinct +// filename-safe scopes stay distinct — "my-scope" and "my_scope" no longer +// collide). Any scope containing a character outside that set is hex-encoded in +// full under an `x-` prefix, which can never collide with a verbatim scope +// (verbatim scopes never start with `x-` followed by only hex... unless they +// literally are `x-`, so we also encode those). Two different scopes always +// produce two different keys. +const SAFE_SCOPE = /^[A-Za-z0-9._-]+$/; +const HEX_PREFIXED = /^x-[0-9a-f]*$/; +const scopeFileKey = (scope: string): string => { + if (SAFE_SCOPE.test(scope) && !HEX_PREFIXED.test(scope)) return scope; + const hex = Buffer.from(scope, "utf8").toString("hex"); + return `x-${hex}`; +}; + +export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { + const clients = new Map(); + const listeners = new Set<(event: ScopeWriteEvent) => void>(); + + const emit = (event: ScopeWriteEvent) => { + for (const listener of listeners) listener(event); + }; + + const clientFor = (scope: string): Client => { + let client = clients.get(scope); + if (!client) { + if (options.root === ":memory:") { + client = createClient({ url: ":memory:" }); + } else { + mkdirSync(options.root, { recursive: true }); + // Collision-free filename (Fix 9): the old `replace(/[^..]/g, "_")` + // COLLAPSED distinct scopes to the same file ("my-scope" and "my_scope" + // -> "my_scope.db"), so two scopes shared one database. Encode the raw + // scope instead: keep the common safe-identifier case readable, and for + // anything with a character outside `[A-Za-z0-9._-]` fall back to a + // reversible hex encoding of the full raw scope. Distinct scopes always + // map to distinct filenames. + const safe = scopeFileKey(scope); + client = createClient({ url: toUrl(join(options.root, `${safe}.db`)) }); + } + clients.set(scope, client); + } + return client; + }; + + return { + forScope: (scope) => + Effect.try({ + try: () => makeHandle(scope, clientFor(scope), emit), + catch: (cause) => new ScopeDbError({ message: `failed to open scope db ${scope}`, cause }), + }), + onWrite: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close: () => + Effect.sync(() => { + for (const client of clients.values()) client.close(); + clients.clear(); + listeners.clear(); + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts new file mode 100644 index 000000000..53e0a9a66 --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts @@ -0,0 +1,7 @@ +import { toolSandboxConformance } from "../seams/tool-sandbox.conformance"; +import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; + +// Short timeout so the "kills a runaway handler" case finishes quickly. +toolSandboxConformance("quickjs", () => + makeQuickjsToolSandbox({ collectTimeoutMs: 5_000, invokeTimeoutMs: 2_000 }), +); diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts new file mode 100644 index 000000000..7e212214f --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { bundleEntry } from "../pipeline/bundle"; +import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; +import { ISSUES_SYNC_TS, SEARCH_ALL_MAIL_TS } from "../testing/daily-brief"; +import type { HandleBridge } from "../seams/tool-sandbox"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +describe("QuickJS ToolSandbox", () => { + const sandbox = makeQuickjsToolSandbox(); + + it("bundles + collects a tool descriptor (deterministic)", async () => { + const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); + const result = await run(sandbox.collect(code)); + const descriptor = result.artifacts.default.descriptor as { + kind: string; + connections: Record; + inputJsonSchema: { type: string; properties: Record }; + hasHandler: boolean; + }; + expect(descriptor.kind).toBe("tool"); + expect(descriptor.connections.github).toEqual( + expect.objectContaining({ decl: "single", integration: "github" }), + ); + expect(descriptor.hasHandler).toBe(true); + expect(descriptor.inputJsonSchema.type).toBe("object"); + expect(descriptor.inputJsonSchema.properties).toHaveProperty("repos"); + expect(descriptor.inputJsonSchema.properties).toHaveProperty("since"); + }); + + it("collects a fan-out (connections) declaration", async () => { + const files = new Map([["tools/search-all-mail.ts", SEARCH_ALL_MAIL_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/search-all-mail.ts" })); + const result = await run(sandbox.collect(code)); + const descriptor = result.artifacts.default.descriptor as { + connections: Record; + }; + expect(descriptor.connections.inboxes).toEqual( + expect.objectContaining({ decl: "array", integration: "gmail" }), + ); + }); + + it("invokes a tool, routing injected-client + db calls through the bridge", async () => { + const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); + + const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = []; + const bridge: HandleBridge = { + call: ({ root, path, args }) => + Effect.sync(() => { + calls.push({ root, path, args }); + const key = `${root}.${path.join(".")}`; + if (key === "github.repos.listForAuthenticatedUser") { + return [{ full_name: "acme/app" }]; + } + if (key === "github.issues.listForRepo") { + return [ + { + number: 1, + title: "Bug", + labels: [{ name: "bug" }], + assignee: { login: "rhys" }, + updated_at: "2026-01-01T00:00:00Z", + html_url: "https://github.com/acme/app/issues/1", + }, + ]; + } + // db.sql calls return empty rows + return []; + }), + }; + + const result = await run( + sandbox.invoke( + code, + { + artifact: "issues-sync", + kind: "tool", + input: {}, + roots: { github: { kind: "single" }, db: { kind: "single" } }, + }, + bridge, + ), + ); + + expect(result.output).toEqual({ synced: 1, repos: 1 }); + // The db write went through the bridge (root "db"). + expect(calls.some((c) => c.root === "db")).toBe(true); + expect(calls.some((c) => c.root === "github")).toBe(true); + }); + + it("rejects a non-deterministic descriptor (Math.random at describe time)", async () => { + const nondeterministic = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "x-" + Math.random(), + input: z.object({}), + async handler() { return {}; }, +});`; + const files = new Map([["tools/rng.ts", nondeterministic]]); + const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" })); + const exit = await Effect.runPromiseExit(sandbox.collect(code)); + expect(exit._tag).toBe("Failure"); + }); + + it("denies network (fetch throws in the sandbox)", async () => { + const usesFetch = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "fetches", + input: z.object({}), + async handler() { await fetch("https://example.com"); return {}; }, +});`; + const files = new Map([["tools/net.ts", usesFetch]]); + const { code } = await run(bundleEntry({ files, entry: "tools/net.ts" })); + const bridge: HandleBridge = { call: () => Effect.succeed(null) }; + const exit = await Effect.runPromiseExit( + sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), + ); + expect(exit._tag).toBe("Failure"); + }); +}); diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts new file mode 100644 index 000000000..24faa19c9 --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts @@ -0,0 +1,264 @@ +import { Effect } from "effect"; +import type { SandboxToolInvoker } from "@executor-js/codemode-core"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import { + ToolSandboxError, + type CollectResult, + type HandleBridge, + type InvokeRequest, + type InvokeResult, + type ToolSandbox, +} from "../seams/tool-sandbox"; +import { stableStringify } from "../pipeline/descriptor"; + +// --------------------------------------------------------------------------- +// QuickJS-backed ToolSandbox (self-hosted). +// +// The published bundle is a CJS string. The sandbox body prepends a `require` +// shim providing `executor:app` (defineTool/defineWorkflow/connection/ +// connections/catalog) and `executor:ui` stubs, then executes the bundle so +// `module.exports.default` is the artifact. A driver appended after the bundle +// either collects descriptors (nothing bound) or runs one handler with injected +// clients. +// +// Injected clients are Proxies that turn `github.issues.listForRepo(args)` into +// `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the +// QuickJS runtime already provides (`tools`/`__invokeTool`). Our +// `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`. +// Everything crossing is JSON (the cloud version is RPC), so the interface +// stays honest. +// +// Determinism: `collect` runs the collection body twice and byte-compares the +// descriptor JSON. Effectful top-levels (Math.random, Date.now) diverge and are +// rejected. QuickJS denies `fetch` and enforces a deadline + memory cap. +// --------------------------------------------------------------------------- + +const COLLECT_TIMEOUT_MS = 10_000; +const INVOKE_TIMEOUT_MS = 30_000; + +// The shim + module system injected before the bundle. Kept as a plain string +// (QuickJS evals a string). `defineTool`/`defineWorkflow` record their def so +// the driver can read it back. Clients are built by `__mkHandle`. +const runtimePrelude = ` +var __modules = {}; +var __defs = { tool: null, workflow: null, ui: null }; +function __recordDefault(mod) { return mod; } +var __handleBridge = function(root, path, args) { + // Route every injected-client method call through the single tool bridge. + return tools.__handle__({ root: root, path: path, args: args }); +}; +function __mkHandle(root, prefix) { + var target = function(){}; + return new Proxy(target, { + get: function(_t, prop) { + if (prop === 'then' || typeof prop === 'symbol') return undefined; + return __mkHandle(root, prefix.concat([String(prop)])); + }, + apply: function(_t, _this, callArgs) { + return __handleBridge(root, prefix, callArgs); + } + }); +} +var __executorApp = { + connection: function(integration, opts) { return { __decl: 'single', integration: integration, description: opts && opts.description }; }, + connections: function(integration, opts) { return { __decl: 'array', integration: integration, description: opts && opts.description }; }, + catalog: function() { return { __decl: 'catalog' }; }, + defineTool: function(def) { __defs.tool = def; return def; }, + defineWorkflow: function(def) { __defs.workflow = def; return def; }, +}; +var __executorUi = { + config: function(){ return undefined; }, + useQuery: function(){ return { data: [], isLoading: false, refetch: function(){} }; }, + useTool: function(){ return { run: function(){ return Promise.resolve(); }, isRunning: false }; }, +}; +function __require(id) { + if (id === 'executor:app') return __executorApp; + if (id === 'executor:ui') return __executorUi; + if (id === 'executor:ui/components') return {}; + throw new Error('module not available in sandbox: ' + id); +} +`; + +// Run the CJS bundle. The bundle's virtual entry sets `globalThis.__artifact` +// (the def object returned by define*), `globalThis.__zodToJson` and +// `__zodToJsonOutput` (bound to the author's inlined zod). `require` is our +// shim; `defineTool`/`defineWorkflow` also record into `__defs` as a fallback. +const wrapBundle = (bundle: string): string => ` +var module = { exports: {} }; +var exports = module.exports; +var require = __require; +(function(module, exports, require){ +${bundle} +})(module, exports, require); +`; + +// Collect driver: describe the artifact's connections + input/output schema. +// Deterministic JSON only — no handler execution. +const collectDriver = ` +return await (async () => { + var def = __defs.tool || __defs.workflow || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); + var kind = __defs.workflow ? 'workflow' : 'tool'; + var conns = {}; + if (def && def.connections) { + for (var k in def.connections) { + var c = def.connections[k]; + conns[k] = { decl: c && c.__decl ? c.__decl : 'single', integration: c && c.integration, description: c && c.description }; + } + } + var toJson = (typeof globalThis.__zodToJson === 'function') ? globalThis.__zodToJson : function(){ return undefined; }; + var toJsonOut = (typeof globalThis.__zodToJsonOutput === 'function') ? globalThis.__zodToJsonOutput : function(){ return undefined; }; + return { + kind: kind, + description: def && def.description, + connections: conns, + annotations: def && def.annotations, + schedule: def && def.schedule, + hasHandler: !!(def && (def.handler || def.run)), + inputJsonSchema: def ? toJson(def.input) : undefined, + outputJsonSchema: def ? toJsonOut(def.output) : undefined, + }; +})() +`; + +const buildCollectCode = (bundle: string): string => + runtimePrelude + wrapBundle(bundle) + collectDriver; + +// Invoke driver: build injected clients from the request roots, then call the +// artifact's handler with (input, injected). Fan-out arrays become arrays of +// element handles. +const buildInvokeDriver = (request: InvokeRequest): string => { + const rootsLiteral = JSON.stringify(request.roots); + const inputLiteral = JSON.stringify(request.input ?? {}); + return ` +return await (async () => { + var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); + if (!def || typeof def.handler !== 'function') throw new Error('artifact has no handler: ${request.artifact}'); + var roots = ${rootsLiteral}; + var injected = {}; + for (var name in roots) { + var spec = roots[name]; + if (spec.kind === 'array') { + var arr = []; + for (var i = 0; i < spec.count; i++) arr.push(__mkHandle(name + '#' + i, [])); + injected[name] = arr; + } else { + injected[name] = __mkHandle(name, []); + } + } + var out = await def.handler(${inputLiteral}, injected); + return out; +})() +`; +}; + +const buildInvokeCode = (bundle: string, request: InvokeRequest): string => + runtimePrelude + wrapBundle(bundle) + buildInvokeDriver(request); + +// A no-op invoker for collect: no handle calls should happen; if they do +// (misbehaving describe path), fail loudly. +const collectInvoker: SandboxToolInvoker = { + invoke: () => Effect.fail(new Error("collect must not make handle calls")) as never, +}; + +export interface QuickjsToolSandboxOptions { + readonly collectTimeoutMs?: number; + readonly invokeTimeoutMs?: number; +} + +export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): ToolSandbox => { + const collectExecutor = makeQuickJsExecutor({ + timeoutMs: options.collectTimeoutMs ?? COLLECT_TIMEOUT_MS, + }); + const invokeExecutor = makeQuickJsExecutor({ + timeoutMs: options.invokeTimeoutMs ?? INVOKE_TIMEOUT_MS, + }); + + const runCollect = (bundle: string): Effect.Effect => + collectExecutor.execute(buildCollectCode(bundle), collectInvoker).pipe( + Effect.mapError( + (cause) => new ToolSandboxError({ kind: "collect", message: "collect run failed", cause }), + ), + Effect.flatMap((result) => { + if (result.error) { + return Effect.fail(new ToolSandboxError({ kind: "collect", message: result.error })); + } + return Effect.succeed(result.result); + }), + ); + + return { + collect: (bundle: string) => + Effect.gen(function* () { + // Run twice, byte-compare (determinism gate). Key-sorted stringify so a + // false mismatch never comes from property-order luck — a real + // divergence (Math.random / Date.now) still fails. + const first = yield* runCollect(bundle); + const second = yield* runCollect(bundle); + const a = stableStringify(first); + const b = stableStringify(second); + if (a !== b) { + return yield* Effect.fail( + new ToolSandboxError({ + kind: "nondeterministic", + message: + "descriptor collection is non-deterministic (an artifact read Math.random/Date.now or otherwise diverged between runs)", + }), + ); + } + const descriptor = first as { kind?: string }; + const kind = descriptor?.kind === "workflow" ? "workflow" : "tool"; + const result: CollectResult = { + artifacts: { + [String((descriptor as { artifact?: string }).artifact ?? "default")]: { + kind, + descriptor: first, + }, + }, + }; + return result; + }), + + invoke: (bundle: string, request: InvokeRequest, bridge: HandleBridge) => + Effect.gen(function* () { + // The invoker decodes the routed handle call and forwards to the host + // bridge. Path 0 is `__handle__`; the single arg is {root, path, args}. + const invoker: SandboxToolInvoker = { + invoke: (input: { path: string; args: unknown }) => { + // Strictness (grafted from A): the ONLY reserved bridge path the + // invoke phase accepts is `__handle__`. Anything else is a hard + // error, never silently ignored — a handler must not reach the host + // through an unexpected channel. + if (input.path !== "__handle__") { + return Effect.fail( + new Error(`unexpected sandbox bridge path: ${input.path}`), + ) as never; + } + const call = input.args as { + root: string; + path: readonly string[]; + args: readonly unknown[]; + }; + if (!call || typeof call.root !== "string" || !Array.isArray(call.path)) { + return Effect.fail(new Error("malformed sandbox bridge call")) as never; + } + return bridge.call({ root: call.root, path: call.path, args: call.args }) as never; + }, + }; + const result = yield* invokeExecutor + .execute(buildInvokeCode(bundle, request), invoker) + .pipe( + Effect.mapError( + (cause) => + new ToolSandboxError({ kind: "invoke", message: "invoke run failed", cause }), + ), + ); + if (result.error) { + return yield* Effect.fail( + new ToolSandboxError({ kind: "invoke", message: result.error }), + ); + } + return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult; + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts b/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts new file mode 100644 index 000000000..994b24410 --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts @@ -0,0 +1,210 @@ +import { Effect } from "effect"; +import type { SandboxToolInvoker } from "@executor-js/codemode-core"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import { + WorkflowError, + type DriveOutcome, + type SuspendMarker, + type WorkflowBridge, + type WorkflowBridgeResult, + type WorkflowDriver, +} from "../seams/workflow-runner"; +import type { ArtifactStore, SnapshotId } from "../seams/artifact-store"; +import { bundleEntry } from "../pipeline/bundle"; + +// --------------------------------------------------------------------------- +// QuickJS-backed WorkflowDriver (self-hosted). +// +// The author's `defineWorkflow({ run(step, { db }) })` body runs INSIDE QuickJS +// on every replay (the same isolation the tool handlers use), NOT in-process. +// Each `step.*` / `db.sql` call crosses the single `__wf` bridge to the host, +// which services it against the journal (`WorkflowBridge`). This is what makes +// the orchestrator substrate-neutral: everything crossing the boundary is JSON, +// so the cloud backing can be an RPC. +// +// The shim's `__runWorkflow()` returns a STRUCTURED envelope, never throws a +// string-matched sentinel: +// { status: "completed", output } +// { status: "suspended", marker: { suspend: "sleep" | "event", event? } } +// { status: "failed", retryable, message, retryAfter? } +// so the runner reads typed fields (retryable-vs-fatal is a discriminator, the +// suspend marker is structured), never `error.includes("...")`. +// --------------------------------------------------------------------------- + +const WORKFLOW_TIMEOUT_MS = 60_000; + +/** True if a host bridge result is a structured suspend (vs a plain value). */ +const isSuspend = (r: WorkflowBridgeResult): r is SuspendMarker => "suspend" in r; + +// The workflow shim: a `step` facade + a `db` client, both routing through the +// `__wf` bridge, plus `__runWorkflow` that drives the authored body and returns +// the structured envelope. `globalThis.__artifact` is set by the compiled +// bundle (the workflow def). Kept as a plain string (QuickJS evals a string). +const workflowShim = (input: unknown): string => ` +var __wfInput = ${JSON.stringify(input ?? {})}; +var __wf = function(op) { return tools.__wf(op); }; +// A tagged suspension we throw to unwind the body; caught by __runWorkflow and +// turned into a structured { status: "suspended" } (no string-matching). +function __Suspend(marker) { this.__wfSuspend = marker; } +var __await = async function(op) { + var res = await __wf(op); + if (res && res.suspend) throw new __Suspend({ suspend: res.suspend, event: res.event }); + if (res && res.error) { + // A structured step error (e.g. step.tool's bound tool threw). Re-throw with + // the typed retryable discriminator so __runWorkflow classifies the run. + var err = new Error(res.error.message); + err.retryable = res.error.retryable === true; + if (typeof res.error.retryAfter === "number") err.retryAfter = res.error.retryAfter; + throw err; + } + return res.value; +}; +var step = { + do: async function(name, fn) { + var j = await __wf({ kind: "step.check", step: name }); + if (j && j.value && j.value.journaled) return j.value.output; + var value = await fn(); + await __wf({ kind: "step.record", step: name, value: value === undefined ? null : value }); + return value; + }, + tool: async function(address, args) { + return __await({ kind: "step.tool", step: "tool:" + address, address: address, args: args || {} }); + }, + sleep: async function(name, ms) { await __await({ kind: "step.sleep", step: "sleep:" + name, ms: ms }); }, + waitForEvent: async function(name, opts) { + return __await({ kind: "step.waitForEvent", step: "wait:" + name }); + }, + notify: async function(msg) { await __wf({ kind: "step.notify", msg: msg }); }, +}; +var __db = { + sql: function(strings) { + var values = Array.prototype.slice.call(arguments, 1); + var sql = ""; + for (var i = 0; i < strings.length; i++) { sql += strings[i]; if (i < values.length) sql += "?"; } + return __wf({ kind: "db.sql", sql: sql, params: values }).then(function(res){ return res.value; }); + }, +}; +globalThis.__runWorkflow = async function() { + var def = globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact); + if (!def || typeof def.run !== "function") { + return { status: "failed", retryable: false, message: "workflow bundle did not produce a run() function" }; + } + try { + var output = await def.run(step, { db: __db, input: __wfInput }); + return { status: "completed", output: output === undefined ? null : output }; + } catch (e) { + if (e && e.__wfSuspend) return { status: "suspended", marker: e.__wfSuspend }; + // A typed control error from the author: RetryableError / FatalError carry a + // discriminator the runtime reads. Anything else is fatal. + var retryable = !!(e && (e.retryable === true || e.name === "RetryableError" || (e.constructor && e.constructor.name === "RetryableError"))); + var retryAfter = e && typeof e.retryAfter === "number" ? e.retryAfter : undefined; + var message = e && e.message ? String(e.message) : String(e); + return { status: "failed", retryable: retryable, message: message, retryAfter: retryAfter }; + } +}; +`; + +// Wrap the CJS bundle so `require` resolves the platform module shims and the +// def lands on `globalThis.__artifact`. Mirrors the tool sandbox's wrapper. +const workflowPrelude = ` +var __executorApp = { + connection: function(integration, opts) { return { __decl: 'single', integration: integration, description: opts && opts.description }; }, + connections: function(integration, opts) { return { __decl: 'array', integration: integration, description: opts && opts.description }; }, + catalog: function() { return { __decl: 'catalog' }; }, + defineTool: function(def) { return def; }, + defineWorkflow: function(def) { globalThis.__artifact = def; return def; }, +}; +var __executorUi = { config: function(){}, useQuery: function(){ return {}; }, useTool: function(){ return {}; } }; +function __require(id) { + if (id === 'executor:app') return __executorApp; + if (id === 'executor:ui') return __executorUi; + if (id === 'executor:ui/components') return {}; + throw new Error('module not available in sandbox: ' + id); +} +`; + +const wrapBundle = (bundle: string): string => ` +var module = { exports: {} }; +var exports = module.exports; +var require = __require; +(function(module, exports, require){ +${bundle} +})(module, exports, require); +`; + +const buildWorkflowCode = (bundle: string, input: unknown): string => + workflowPrelude + wrapBundle(bundle) + workflowShim(input) + "\nreturn await __runWorkflow();"; + +export interface QuickjsWorkflowDriverDeps { + readonly artifactStore: ArtifactStore; + readonly timeoutMs?: number; +} + +export const makeQuickjsWorkflowDriver = (deps: QuickjsWorkflowDriverDeps): WorkflowDriver => { + const executor = makeQuickJsExecutor({ timeoutMs: deps.timeoutMs ?? WORKFLOW_TIMEOUT_MS }); + + // Re-bundle the workflow entry from the pinned snapshot on each drive; the + // runner never caches source that could drift. A published snapshot is + // immutable, so a per-(snapshot,entry) cache would be safe, but the source of + // truth is always the committed snapshot. + const loadBundle = ( + scope: string, + snapshotId: string, + entryPath: string, + ): Effect.Effect => + Effect.gen(function* () { + const scopeStore = yield* deps.artifactStore + .forScope(scope) + .pipe(Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c }))); + const files = yield* scopeStore + .read(snapshotId as SnapshotId) + .pipe(Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c }))); + const bundle = yield* bundleEntry({ files, entry: entryPath }).pipe( + Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c })), + ); + return bundle.code; + }); + + return { + drive: (input, bridge: WorkflowBridge) => + Effect.gen(function* () { + const code = yield* loadBundle(input.scope, input.snapshotId, input.entryPath); + + // The invoker decodes the routed `__wf` op and forwards to the host + // bridge. Everything crossing is JSON (the cloud version is RPC). + const invoker: SandboxToolInvoker = { + invoke: (call: { path: string; args: unknown }) => { + if (call.path !== "__wf") { + return Effect.fail( + new Error(`unexpected workflow sandbox call: ${call.path}`), + ) as never; + } + return bridge + .call(call.args as never) + .pipe(Effect.mapError((e) => new Error(e.message))) as never; + }, + }; + + const result = yield* executor + .execute(buildWorkflowCode(code, input.input), invoker) + .pipe( + Effect.mapError( + (cause) => new WorkflowError({ message: "workflow drive failed", cause }), + ), + ); + + if (result.error) { + // A sandbox-level error (timeout, syntax) is fatal for the run. + return { + status: "failed", + retryable: false, + message: result.error, + } satisfies DriveOutcome; + } + return result.result as DriveOutcome; + }), + }; +}; + +export { isSuspend }; diff --git a/packages/plugins/apps/src/backing/scope-collision.test.ts b/packages/plugins/apps/src/backing/scope-collision.test.ts new file mode 100644 index 000000000..23db1c6c9 --- /dev/null +++ b/packages/plugins/apps/src/backing/scope-collision.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeLibsqlScopeDb } from "./libsql-scope-db"; +import { makeSqliteAppsStore } from "./sqlite-apps-store"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +// --------------------------------------------------------------------------- +// Finding 9 regression: scopes that differ only by a character the old naming +// collapsed ("my-scope" vs "my_scope") must NOT share a database, and the +// connection->scope mapping must route each to its own scope. Before the fix the +// scope-db filename collapsed `[^A-Za-z0-9._-]` to `_` (colliding two scopes onto +// one file) and the connection name was reverse-parsed (colliding two scopes onto +// one normalized identifier). +// --------------------------------------------------------------------------- + +describe("scope collision (Fix 9)", () => { + it("gives my-scope and my_scope DISTINCT scope-db files with isolated data", async () => { + const root = mkdtempSync(join(tmpdir(), "apps-scopecol-")); + const db = makeLibsqlScopeDb({ root }); + + const a = await run(db.forScope("my-scope")); + await run(a.exec("CREATE TABLE t (v TEXT)")); + await run(a.exec("INSERT INTO t (v) VALUES ('a')")); + + const b = await run(db.forScope("my_scope")); + await run(b.exec("CREATE TABLE t (v TEXT)")); + await run(b.exec("INSERT INTO t (v) VALUES ('b')")); + + // Each scope sees ONLY its own row (no shared file). + const aRows = await run(a.exec<{ v: string }>("SELECT v FROM t")); + const bRows = await run(b.exec<{ v: string }>("SELECT v FROM t")); + expect(aRows.map((r) => r.v)).toEqual(["a"]); + expect(bRows.map((r) => r.v)).toEqual(["b"]); + + // Two distinct .db files exist on disk. + const dbFiles = readdirSync(root).filter((f) => f.endsWith(".db")); + expect(dbFiles.length).toBe(2); + + await run(db.close()); + }); + + it("routes distinct scopes through the explicit connection->scope mapping", async () => { + const store = makeSqliteAppsStore({ path: ":memory:" }); + // Both scopes' connection names normalize to the SAME identifier + // ("appsMyScope"), but the explicit mapping keys by the ACTUAL stored name, + // so each scope's connection maps to the right scope. Simulate two DISTINCT + // connection names being recorded for two distinct scopes. + await run(store.putScopeForConnection("appsMyScopeDash", "my-scope")); + await run(store.putScopeForConnection("appsMyScopeUnderscore", "my_scope")); + + expect(await run(store.getScopeForConnection("appsMyScopeDash"))).toBe("my-scope"); + expect(await run(store.getScopeForConnection("appsMyScopeUnderscore"))).toBe("my_scope"); + // An unmapped name returns null (caller falls back to legacy parse). + expect(await run(store.getScopeForConnection("unknown"))).toBeNull(); + }); +}); diff --git a/packages/plugins/apps/src/backing/sqlite-apps-store.ts b/packages/plugins/apps/src/backing/sqlite-apps-store.ts new file mode 100644 index 000000000..942ef90f0 --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-apps-store.ts @@ -0,0 +1,127 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import type { StorageFailure } from "@executor-js/sdk"; + +import type { AppDescriptor } from "../pipeline/descriptor"; +import type { AppsStore } from "../plugin/store"; + +// --------------------------------------------------------------------------- +// SQLite-backed AppsStore (self-hosted). The apps subsystem owns its own +// metadata store on disk: one small SQLite file holding the published +// descriptor per scope and the content-addressed blobs (compiled ui bundles + +// skill bodies). Kept separate from the executor DB so the plugin's persistence +// is self-contained (matching the per-scope-file philosophy) and doesn't couple +// to the executor's owner-policy layer. +// --------------------------------------------------------------------------- + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS descriptors (scope TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, descriptor TEXT NOT NULL, published_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS blobs (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS scope_connections (connection_name TEXT PRIMARY KEY, scope TEXT NOT NULL); +`; + +// Not-really-failing storage errors from libSQL wrapped opaquely. +const storageFail = (message: string, cause: unknown): StorageFailure => + ({ _tag: "StorageError", message, cause }) as unknown as StorageFailure; + +export interface SqliteAppsStoreOptions { + readonly path: string; +} + +export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore => { + if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true }); + const client: Client = createClient({ url: toUrl(options.path) }); + let ready: Promise | undefined; + const init = async () => { + if (!ready) { + ready = (async () => { + for (const stmt of SCHEMA.split(";")) { + const s = stmt.trim(); + if (s) await client.execute(s); + } + })(); + } + return ready; + }; + + return { + putDescriptor: (_owner, descriptor) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: `INSERT INTO descriptors (scope, snapshot_id, descriptor, published_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(scope) DO UPDATE SET snapshot_id=excluded.snapshot_id, descriptor=excluded.descriptor, published_at=excluded.published_at`, + args: [descriptor.scope, descriptor.snapshotId, JSON.stringify(descriptor), Date.now()], + }); + }, + catch: (cause) => storageFail("putDescriptor failed", cause), + }), + getDescriptor: (scope) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT descriptor FROM descriptors WHERE scope = ?", + args: [scope], + }); + const row = res.rows[0]; + return row ? (JSON.parse(String(row.descriptor)) as AppDescriptor) : null; + }, + catch: (cause) => storageFail("getDescriptor failed", cause), + }), + putScopeForConnection: (connectionName, scope) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: "INSERT INTO scope_connections (connection_name, scope) VALUES (?, ?) ON CONFLICT(connection_name) DO UPDATE SET scope=excluded.scope", + args: [connectionName, scope], + }); + }, + catch: (cause) => storageFail("putScopeForConnection failed", cause), + }), + getScopeForConnection: (connectionName) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT scope FROM scope_connections WHERE connection_name = ?", + args: [connectionName], + }); + return res.rows[0] ? String(res.rows[0].scope) : null; + }, + catch: (cause) => storageFail("getScopeForConnection failed", cause), + }), + putBlob: (key, value) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: "INSERT INTO blobs (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + args: [key, value], + }); + }, + catch: (cause) => storageFail("putBlob failed", cause), + }), + getBlob: (key) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT value FROM blobs WHERE key = ?", + args: [key], + }); + return res.rows[0] ? String(res.rows[0].value) : null; + }, + catch: (cause) => storageFail("getBlob failed", cause), + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.concurrency.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.concurrency.test.ts new file mode 100644 index 000000000..687e60e3f --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.concurrency.test.ts @@ -0,0 +1,114 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeSqliteWorkflowRunner } from "./sqlite-workflow-runner"; +import type { + DriveOutcome, + WorkflowBindings, + WorkflowBridge, + WorkflowDriver, +} from "../seams/workflow-runner"; + +// --------------------------------------------------------------------------- +// Finding 3 regression: a run driven concurrently (start + signal racing) must +// execute an unjournaled side-effecting `step.tool` EXACTLY once. Before the +// single-driver lease, both drivers loaded the same "step not journaled" view +// and both ran the tool, doubling its side effect. +// +// The stub driver drives one `step.tool("count")` per replay. The runner +// services it: the FIRST driver to hold the lease executes the tool (bumping the +// counter + journaling it); the second waits for the lease, re-drives, and finds +// the step journaled -> replays without re-executing. So no matter how the race +// interleaves, the counter ends at exactly 1. +// --------------------------------------------------------------------------- + +const drivenTool: WorkflowDriver = { + drive: (_input, bridge: WorkflowBridge): Effect.Effect => + Effect.gen(function* () { + // One side-effecting step per replay. The runner runs the bound tool only + // when the step is NOT yet journaled. + const res = yield* bridge.call({ + kind: "step.tool", + step: "count", + address: "count", + args: {}, + }); + // A suspend/step-error result never happens for step.tool here; treat any + // value as completion. + const value = "value" in res ? res.value : undefined; + return { status: "completed", output: value } satisfies DriveOutcome; + }) as Effect.Effect, +}; + +describe("WorkflowRunner single-driver lease (concurrent drive)", () => { + it("executes an unjournaled step.tool exactly once under concurrent start+signal", async () => { + const dir = mkdtempSync(join(tmpdir(), "apps-wf-conc-")); + const dbPath = join(dir, "journal.db"); + let t = 1_000_000; + const runner = makeSqliteWorkflowRunner({ + path: dbPath, + driver: drivenTool, + clock: () => (t += 1), + }); + + // A side-effecting bound tool that increments a shared counter each time it + // actually runs. If the run double-drives the unjournaled step, this bumps + // twice. + let counter = 0; + const bindings: WorkflowBindings = { + runTool: async () => { + counter += 1; + // A small yield so a racing driver has a real window to interleave. + await new Promise((r) => setTimeout(r, 5)); + return counter; + }, + notify: async () => {}, + }; + + const runId = "race-run"; + // Fire start + signal concurrently, many times, all targeting the same run. + // `start` is idempotent on runId (returns the existing run) and both paths + // end up driving the run; the lease must still yield exactly one execution. + const races: Promise[] = []; + for (let i = 0; i < 20; i++) { + races.push( + Effect.runPromise( + runner + .start( + { + scope: "s", + workflow: "wf", + snapshotId: "snap", + entryPath: "workflows/wf.ts", + input: {}, + runId, + }, + bindings, + ) + .pipe(Effect.orElseSucceed(() => undefined)), + ), + ); + races.push( + Effect.runPromise( + runner.signal(runId, "go", {}, bindings).pipe(Effect.orElseSucceed(() => undefined)), + ), + ); + } + await Promise.all(races); + + // The side effect ran exactly once. + expect(counter).toBe(1); + + // And the step is journaled exactly once as completed. + const steps = await Effect.runPromise(runner.listSteps(runId)); + const countSteps = steps.filter((s) => s.name === "count"); + expect(countSteps.length).toBe(1); + expect(countSteps[0]?.status).toBe("completed"); + + await Effect.runPromise(runner.close()); + }, 30_000); +}); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts new file mode 100644 index 000000000..01f6986a8 --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts @@ -0,0 +1,69 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +// --------------------------------------------------------------------------- +// The REAL kill test: SIGKILL a child process mid-step, restart over the same +// data dir, and prove the completed step did NOT re-execute. A side-effect file +// written exactly once is the assertion. +// +// Phase 1 (child): start the run; step "write-once" appends one line to the +// marker file, then step "hang" blocks forever. When the child prints "HUNG" +// the side effect has landed and it is stuck in a NOT-journaled step. We +// SIGKILL it there. +// +// Phase 2 (child): resume the run over the same journal DB. "write-once" is +// journaled so it replays without re-running; the run completes. +// +// Assertion: the marker file has exactly ONE line. +// --------------------------------------------------------------------------- + +const CHILD = join(import.meta.dirname, "..", "testing", "kill-child.ts"); + +const runChild = ( + phase: string, + dbPath: string, + markerPath: string, + waitForHung: boolean, +): Promise<{ code: number | null; killed: boolean; stdout: string }> => + new Promise((resolve) => { + const child = spawn("bun", [CHILD, phase, dbPath, markerPath], { + stdio: ["ignore", "pipe", "inherit"], + }); + let stdout = ""; + let killed = false; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (waitForHung && stdout.includes("HUNG") && !killed) { + killed = true; + child.kill("SIGKILL"); + } + }); + child.on("exit", (code) => resolve({ code, killed, stdout })); + }); + +describe("WorkflowRunner kill test (SIGKILL mid-step, restart, no double-execute)", () => { + it("runs a completed step exactly once across a kill+restart", async () => { + const dir = mkdtempSync(join(tmpdir(), "apps-kill-")); + const dbPath = join(dir, "journal.db"); + const markerPath = join(dir, "marker.txt"); + + // Phase 1: start, land the side effect, get killed mid-"hang". + const phase1 = await runChild("1", dbPath, markerPath, true); + expect(phase1.killed).toBe(true); + expect(existsSync(markerPath)).toBe(true); + const afterKill = readFileSync(markerPath, "utf8").trim().split("\n").filter(Boolean); + expect(afterKill.length).toBe(1); // side effect happened once before the kill + + // Phase 2: resume over the SAME db; completed step must NOT re-run. + const phase2 = await runChild("2", dbPath, markerPath, false); + expect(phase2.code).toBe(0); + expect(phase2.stdout).toContain("STATUS:completed"); + + const finalLines = readFileSync(markerPath, "utf8").trim().split("\n").filter(Boolean); + expect(finalLines.length).toBe(1); // STILL exactly one line: no double-execute + }, 60_000); +}); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts new file mode 100644 index 000000000..1b9a9b6e8 --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts @@ -0,0 +1,39 @@ +import { Effect } from "effect"; + +import { + workflowRunnerConformance, + type WorkflowConformanceHarness, +} from "../seams/workflow-runner.conformance"; +import { makeSqliteWorkflowRunner } from "./sqlite-workflow-runner"; +import { makeQuickjsWorkflowDriver } from "./quickjs-workflow-driver"; +import { makeInMemoryArtifactStore } from "../testing/index"; +import type { WorkflowBindings } from "../seams/workflow-runner"; + +// A synthetic monotonic clock so sleep-based suspension is deterministic. The +// runner drives the author body inside the QuickJS sandbox via the real driver, +// over an in-memory artifact store the harness publishes workflow sources into. +workflowRunnerConformance( + "sqlite (in-memory) + quickjs driver", + (bindings: WorkflowBindings): WorkflowConformanceHarness => { + let t = 1_000_000; + const artifactStore = makeInMemoryArtifactStore(); + const driver = makeQuickjsWorkflowDriver({ artifactStore }); + const runner = makeSqliteWorkflowRunner({ + path: ":memory:", + driver, + clock: () => (t += 1), + }); + void bindings; + return { + runner, + publish: async (name, source) => { + const entryPath = `workflows/${name}.ts`; + const store = await Effect.runPromise(artifactStore.forScope("s")); + const meta = await Effect.runPromise( + store.commit(new Map([[entryPath, source]]), `publish ${name}`), + ); + return { snapshotId: meta.id, entryPath }; + }, + }; + }, +); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts new file mode 100644 index 000000000..13b3eb71c --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts @@ -0,0 +1,614 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import { + WorkflowError, + type RunView, + type StartRunInput, + type StepView, + type SuspendMarker, + type WorkflowBindings, + type WorkflowBridge, + type WorkflowBridgeOp, + type WorkflowBridgeResult, + type WorkflowDriver, + type WorkflowRunner, +} from "../seams/workflow-runner"; + +// --------------------------------------------------------------------------- +// SQLite journal replay WorkflowRunner (self-hosted). +// +// An append-only event journal in SQLite backs materialized run/step views +// (modeled on vercel/workflow's World Storage contract). The author's workflow +// body runs INSIDE the ToolSandbox (via the injected `WorkflowDriver`), NOT +// in-process; each `step.*` / `db.sql` call crosses the serializable +// `WorkflowBridge` this runner implements and is serviced against the journal: +// - a completed step replays its recorded result WITHOUT re-executing +// - the first not-yet-recorded step actually executes, appends its result +// - `sleep`/`waitForEvent` return a STRUCTURED suspend marker; the driver +// unwinds the body and the runner marks the run sleeping/waiting; `resume`/ +// `signal` re-drive it later +// +// This is what makes the kill test pass: SIGKILL mid-step -> restart over the +// same DB file -> `resume` replays completed steps from the journal and only +// the interrupted (never-recorded) step runs again. A side-effect from a +// COMPLETED step happens exactly once. +// +// `start`/`resume`/`signal` take DATA (scope/workflow/snapshot/entryPath/input), +// never a closure — the run row persists the identity so `resume` re-drives it +// with no host state. `step.tool` / `step.notify` reach the outside world +// through the caller-supplied `WorkflowBindings`. +// --------------------------------------------------------------------------- + +const nowMs = () => Date.now(); + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS wf_run ( + run_id TEXT PRIMARY KEY, scope TEXT NOT NULL, workflow TEXT NOT NULL, + snapshot_id TEXT NOT NULL, entry_path TEXT NOT NULL, status TEXT NOT NULL, input TEXT NOT NULL, + output TEXT, error TEXT, wake_at INTEGER, wait_event TEXT, bindings TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS wf_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, step_name TEXT, data TEXT, created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS wf_event_run ON wf_event (run_id, seq); +CREATE TABLE IF NOT EXISTS wf_step ( + run_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, + output TEXT, error TEXT, attempt INTEGER NOT NULL DEFAULT 1, completed_at INTEGER NOT NULL, + PRIMARY KEY (run_id, name) +); +CREATE TABLE IF NOT EXISTS wf_signal ( + run_id TEXT NOT NULL, event_name TEXT NOT NULL, payload TEXT, + PRIMARY KEY (run_id, event_name) +); +CREATE TABLE IF NOT EXISTS wf_lease ( + run_id TEXT PRIMARY KEY, holder TEXT NOT NULL, expires_at INTEGER NOT NULL +); +`; + +/** How long a drive lease is held before it is considered abandoned (a driver + * that crashed mid-step). A live driver renews implicitly by finishing and + * releasing; a genuinely long step just extends past this and a concurrent + * driver treats the lease as stale, which is acceptable because the journal + * still guarantees a COMPLETED step never re-runs — the lease only prevents the + * narrow unjournaled-step double-execution window. */ +const LEASE_MS = 30_000; + +interface StepRecord { + status: "completed" | "failed"; + output?: unknown; + error?: string; + attempt: number; +} + +interface RunRow extends RunView { + readonly entryPath: string; + readonly persistedBindings?: unknown; +} + +export interface SqliteWorkflowRunnerOptions { + /** Journal DB path, or ":memory:". A file path is what the kill test needs. */ + readonly path: string; + /** The DATA-driven driver that runs one replay of a workflow body inside the + * sandbox behind the bridge. */ + readonly driver: WorkflowDriver; + /** Injected clock for deterministic sleep in tests. */ + readonly clock?: () => number; +} + +export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): WorkflowRunner => { + if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true }); + const client: Client = createClient({ url: toUrl(options.path) }); + const clock = options.clock ?? nowMs; + const driver = options.driver; + let ready: Promise | undefined; + + const init = async () => { + if (!ready) { + ready = (async () => { + for (const stmt of SCHEMA.split(";")) { + const s = stmt.trim(); + if (s) await client.execute(s); + } + // Idempotent add for DBs created before the `bindings` column existed + // (a persisted journal file from an earlier version). SQLite has no + // `ADD COLUMN IF NOT EXISTS`, so tolerate the duplicate-column error. + await client.execute("ALTER TABLE wf_run ADD COLUMN bindings TEXT").catch(() => undefined); + })(); + } + return ready; + }; + + const loadRun = async (runId: string): Promise => { + await init(); + const res = await client.execute({ + sql: "SELECT * FROM wf_run WHERE run_id = ?", + args: [runId], + }); + const row = res.rows[0]; + if (!row) return null; + return { + runId: String(row.run_id), + scope: String(row.scope), + workflow: String(row.workflow), + snapshotId: String(row.snapshot_id), + entryPath: String(row.entry_path), + status: String(row.status) as RunView["status"], + input: JSON.parse(String(row.input)), + output: row.output != null ? JSON.parse(String(row.output)) : undefined, + error: row.error != null ? String(row.error) : undefined, + persistedBindings: row.bindings != null ? JSON.parse(String(row.bindings)) : undefined, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; + }; + + const loadSteps = async (runId: string): Promise> => { + const res = await client.execute({ + sql: "SELECT name, status, output, error, attempt FROM wf_step WHERE run_id = ?", + args: [runId], + }); + const map = new Map(); + for (const r of res.rows) { + map.set(String(r.name), { + status: String(r.status) as StepRecord["status"], + output: r.output != null ? JSON.parse(String(r.output)) : undefined, + error: r.error != null ? String(r.error) : undefined, + attempt: Number(r.attempt), + }); + } + return map; + }; + + const nextSeq = async (runId: string): Promise => { + const res = await client.execute({ + sql: "SELECT COALESCE(MAX(seq), 0) AS m FROM wf_event WHERE run_id = ?", + args: [runId], + }); + return Number(res.rows[0]?.m ?? 0) + 1; + }; + + const appendEvent = async ( + runId: string, + type: string, + stepName: string | null, + data: unknown, + ): Promise => { + const seq = await nextSeq(runId); + await client.execute({ + sql: "INSERT INTO wf_event (run_id, seq, type, step_name, data, created_at) VALUES (?, ?, ?, ?, ?, ?)", + args: [runId, seq, type, stepName, data === undefined ? null : JSON.stringify(data), clock()], + }); + }; + + const recordStep = async (runId: string, name: string, record: StepRecord): Promise => { + await client.execute({ + sql: `INSERT INTO wf_step (run_id, name, status, output, error, attempt, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id, name) DO UPDATE SET status=excluded.status, output=excluded.output, + error=excluded.error, attempt=excluded.attempt, completed_at=excluded.completed_at`, + args: [ + runId, + name, + record.status, + record.output === undefined ? null : JSON.stringify(record.output), + record.error ?? null, + record.attempt, + clock(), + ], + }); + await appendEvent(runId, `step_${record.status}`, name, record.output ?? record.error); + }; + + const setRunStatus = async ( + runId: string, + status: RunView["status"], + extra: { output?: unknown; error?: string; wakeAt?: number; waitEvent?: string } = {}, + ): Promise => { + await client.execute({ + sql: "UPDATE wf_run SET status = ?, output = ?, error = ?, wake_at = ?, wait_event = ?, updated_at = ? WHERE run_id = ?", + args: [ + status, + extra.output === undefined ? null : JSON.stringify(extra.output), + extra.error ?? null, + extra.wakeAt ?? null, + extra.waitEvent ?? null, + clock(), + runId, + ], + }); + await appendEvent(runId, `run_${status}`, null, extra.output ?? extra.error); + }; + + // --------------------------------------------------------------------------- + // The host-side WorkflowBridge: services each op the sandboxed body sends + // against the journal for ONE run. The journal is loaded once per drive and + // held in `journaled` so replays are consistent and O(1). Only ops for a step + // NOT yet journaled cause a side effect (a tool call, a record); everything + // else replays from the journal. + // --------------------------------------------------------------------------- + const makeBridge = ( + runId: string, + journaled: Map, + bindings: WorkflowBindings, + scope: string, + ): WorkflowBridge => { + const value = (v: unknown): WorkflowBridgeResult => ({ value: v }); + const suspend = (m: SuspendMarker): WorkflowBridgeResult => m; + + const service = async (op: WorkflowBridgeOp): Promise => { + switch (op.kind) { + case "step.check": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") { + return value({ journaled: true, output: existing.output }); + } + return value({ journaled: false }); + } + case "step.record": { + const record: StepRecord = { status: "completed", output: op.value, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value({ ok: true }); + } + case "step.tool": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(existing.output); + try { + const out = await bindings.runTool(op.address, op.args); + const record: StepRecord = { status: "completed", output: out, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value(out); + } catch (cause) { + // A bound-tool failure is NOT journaled (so a retry can re-run it). + // Report it as a structured step error with a typed retryable flag. + const retryable = !!( + cause && + typeof cause === "object" && + ((cause as { retryable?: boolean }).retryable === true || + (cause as { name?: string }).name === "RetryableError") + ); + const message = cause instanceof Error ? cause.message : String(cause); + const retryAfter = + cause && typeof (cause as { retryAfter?: number }).retryAfter === "number" + ? (cause as { retryAfter: number }).retryAfter + : undefined; + return { error: { message, retryable, retryAfter } }; + } + } + case "step.sleep": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(null); + const wakeAt = clock() + op.ms; + const record: StepRecord = { status: "completed", output: { wakeAt }, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return suspend({ suspend: "sleep" }); + } + case "step.waitForEvent": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(existing.output); + const eventName = op.step.replace(/^wait:/, ""); + const sig = await client.execute({ + sql: "SELECT payload FROM wf_signal WHERE run_id = ? AND event_name = ?", + args: [runId, eventName], + }); + if (sig.rows[0]) { + const payload = + sig.rows[0].payload != null ? JSON.parse(String(sig.rows[0].payload)) : undefined; + const record: StepRecord = { status: "completed", output: payload, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value(payload); + } + return suspend({ suspend: "event", event: eventName }); + } + case "step.notify": { + const step = `notify:${op.msg.title}`; + const existing = journaled.get(step); + if (existing && existing.status === "completed") return value({ notified: true }); + await bindings.notify(op.msg); + const record: StepRecord = { + status: "completed", + output: { notified: true }, + attempt: 1, + }; + journaled.set(step, record); + await recordStep(runId, step, record); + return value({ notified: true }); + } + case "db.sql": { + // The workflow's scope-db access between steps. Not memoized (reads); + // authors put durable side effects in step.do / step.tool. + const out = await bindings.runDb?.(scope, op.sql, op.params); + return value(out ?? []); + } + } + }; + + return { + call: (op) => + Effect.tryPromise({ + try: () => service(op), + catch: (cause) => new WorkflowError({ message: "workflow bridge op failed", cause }), + }), + }; + }; + + // --------------------------------------------------------------------------- + // Single-driver lease. A run may be driven concurrently (start + signal + + // resume racing); without coordination each driver loads the same "step not + // journaled yet" view and both execute the unjournaled `step.tool`, running + // its side effect twice. The lease makes drive single-driver per run: a driver + // atomically claims the run row before executing and releases after. A second + // driver that fails to claim WAITS for the holder to finish, then re-drives — + // by which point the step is journaled, so it replays instead of re-executing. + // + // The claim is a single atomic statement (INSERT .. ON CONFLICT DO UPDATE with + // a WHERE that only overwrites an EXPIRED lease), so exactly one racer wins + // even under SQLite's serialized writer. + // --------------------------------------------------------------------------- + const tryClaim = async (runId: string, holder: string): Promise => { + const now = clock(); + const res = await client.execute({ + sql: `INSERT INTO wf_lease (run_id, holder, expires_at) VALUES (?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET holder = excluded.holder, expires_at = excluded.expires_at + WHERE wf_lease.expires_at <= ?`, + args: [runId, holder, now + LEASE_MS, now], + }); + // `changes` is 1 when we inserted or overwrote an expired lease, 0 when the + // ON CONFLICT WHERE filtered out (a live lease is held by someone else). + return Number(res.rowsAffected ?? 0) > 0; + }; + + const releaseClaim = async (runId: string, holder: string): Promise => { + await client.execute({ + sql: "DELETE FROM wf_lease WHERE run_id = ? AND holder = ?", + args: [runId, holder], + }); + }; + + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + // Claim, or wait for the current holder to release (or its lease to expire), + // then claim. Returns the holder token once held. + const acquire = async (runId: string): Promise => { + const holder = `d-${clock()}-${Math.random().toString(36).slice(2)}`; + // Bounded spin: LEASE_MS is the worst case before a stale lease is reclaimed. + // Poll frequently so the follow-up re-drive is prompt once the holder frees. + for (;;) { + if (await tryClaim(runId, holder)) return holder; + await sleep(5); + } + }; + + const driveExclusive = ( + run: RunRow, + bindings: WorkflowBindings, + ): Effect.Effect => + Effect.gen(function* () { + const holder = yield* Effect.tryPromise({ + try: () => acquire(run.runId), + catch: (cause) => new WorkflowError({ message: "lease acquire failed", cause }), + }); + // Re-load the run + drive under the lease; a second driver that waited for + // the lease re-reads the (now-updated) journal and replays completed steps. + const fresh = yield* Effect.tryPromise({ + try: () => loadRun(run.runId), + catch: (cause) => new WorkflowError({ message: "lease reload failed", cause }), + }); + return yield* driveUnleased(fresh ?? run, bindings).pipe( + Effect.ensuring(Effect.promise(() => releaseClaim(run.runId, holder))), + ); + }); + + const driveUnleased = ( + run: RunRow, + bindings: WorkflowBindings, + ): Effect.Effect => + Effect.gen(function* () { + const journaled = yield* Effect.tryPromise({ + try: () => loadSteps(run.runId), + catch: (cause) => new WorkflowError({ message: "load steps failed", cause }), + }); + const bridge = makeBridge(run.runId, journaled, bindings, run.scope); + const outcome = yield* driver.drive( + { + scope: run.scope, + workflow: run.workflow, + snapshotId: run.snapshotId, + entryPath: run.entryPath, + input: run.input, + }, + bridge, + ); + yield* Effect.tryPromise({ + try: async () => { + if (outcome.status === "completed") { + await setRunStatus(run.runId, "completed", { output: outcome.output }); + } else if (outcome.status === "suspended") { + if (outcome.marker.suspend === "sleep") { + await setRunStatus(run.runId, "sleeping"); + } else { + await setRunStatus(run.runId, "waiting", { waitEvent: outcome.marker.event }); + } + } else if (outcome.retryable) { + // Re-drivable: leave the run running so a later resume retries. + await setRunStatus(run.runId, "running", { error: outcome.message }); + } else { + await setRunStatus(run.runId, "failed", { error: outcome.message }); + } + }, + catch: (cause) => new WorkflowError({ message: "persist outcome failed", cause }), + }); + const view = yield* Effect.tryPromise({ + try: () => loadRun(run.runId), + catch: (cause) => new WorkflowError({ message: "reload failed", cause }), + }); + return view!; + }); + + const resumeImpl = ( + runId: string, + bindings: WorkflowBindings, + ): Effect.Effect => + Effect.tryPromise({ + try: () => loadRun(runId), + catch: (cause) => new WorkflowError({ message: "resume load failed", cause }), + }).pipe( + Effect.flatMap((run) => { + if (!run) return Effect.fail(new WorkflowError({ message: `no run ${runId}` })); + if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") { + return Effect.succeed(run as RunView); + } + return Effect.tryPromise({ + try: async () => { + await setRunStatus(runId, "running"); + return (await loadRun(runId))!; + }, + catch: (cause) => new WorkflowError({ message: "resume set-running failed", cause }), + }).pipe(Effect.flatMap((fresh) => driveExclusive(fresh, bindings))); + }), + ); + + return { + start: (input: StartRunInput, bindings) => + Effect.tryPromise({ + try: async () => { + await init(); + const runId = input.runId ?? `run-${clock()}-${Math.random().toString(36).slice(2)}`; + const existing = await loadRun(runId); + if (existing) return existing; + const ts = clock(); + await client.execute({ + sql: `INSERT INTO wf_run (run_id, scope, workflow, snapshot_id, entry_path, status, input, bindings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, ?)`, + args: [ + runId, + input.scope, + input.workflow, + input.snapshotId, + input.entryPath, + JSON.stringify(input.input ?? {}), + input.persistedBindings === undefined + ? null + : JSON.stringify(input.persistedBindings), + ts, + ts, + ], + }); + await appendEvent(runId, "run_created", null, input.input ?? {}); + return (await loadRun(runId))!; + }, + catch: (cause) => new WorkflowError({ message: "start failed", cause }), + }).pipe(Effect.flatMap((run) => driveExclusive(run, bindings))), + + resume: (runId, bindings) => resumeImpl(runId, bindings), + + signal: (runId, eventName, payload, bindings) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: `INSERT INTO wf_signal (run_id, event_name, payload) VALUES (?, ?, ?) + ON CONFLICT(run_id, event_name) DO UPDATE SET payload = excluded.payload`, + args: [runId, eventName, payload === undefined ? null : JSON.stringify(payload)], + }); + await appendEvent(runId, "signal", eventName, payload); + }, + catch: (cause) => new WorkflowError({ message: "signal failed", cause }), + }).pipe(Effect.flatMap(() => resumeImpl(runId, bindings))), + + cancel: (runId) => + Effect.tryPromise({ + try: async () => { + await setRunStatus(runId, "cancelled"); + return (await loadRun(runId))! as RunView; + }, + catch: (cause) => new WorkflowError({ message: "cancel failed", cause }), + }), + + get: (runId) => + Effect.tryPromise({ + try: () => loadRun(runId) as Promise, + catch: (cause) => new WorkflowError({ message: "get failed", cause }), + }), + + getPersisted: (runId) => + Effect.tryPromise({ + try: async () => { + const run = await loadRun(runId); + if (!run) return null; + return { + runId: run.runId, + scope: run.scope, + workflow: run.workflow, + snapshotId: run.snapshotId, + entryPath: run.entryPath, + persistedBindings: run.persistedBindings, + }; + }, + catch: (cause) => new WorkflowError({ message: "getPersisted failed", cause }), + }), + + list: (filter) => + Effect.tryPromise({ + try: async () => { + await init(); + const clauses: string[] = []; + const args: unknown[] = []; + if (filter?.scope) { + clauses.push("scope = ?"); + args.push(filter.scope); + } + if (filter?.workflow) { + clauses.push("workflow = ?"); + args.push(filter.workflow); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const res = await client.execute({ + sql: `SELECT run_id FROM wf_run ${where} ORDER BY created_at DESC`, + args: args as never, + }); + const views: RunView[] = []; + for (const r of res.rows) { + const v = await loadRun(String(r.run_id)); + if (v) views.push(v); + } + return views as readonly RunView[]; + }, + catch: (cause) => new WorkflowError({ message: "list failed", cause }), + }), + + listSteps: (runId) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT name, status, output, error, attempt, completed_at FROM wf_step WHERE run_id = ? ORDER BY completed_at ASC, name ASC", + args: [runId], + }); + return res.rows.map((r) => ({ + runId, + name: String(r.name), + status: String(r.status) as StepView["status"], + output: r.output != null ? JSON.parse(String(r.output)) : undefined, + error: r.error != null ? String(r.error) : undefined, + attempt: Number(r.attempt), + completedAt: Number(r.completed_at), + })) as readonly StepView[]; + }, + catch: (cause) => new WorkflowError({ message: "listSteps failed", cause }), + }), + + close: () => Effect.sync(() => client.close()), + }; +}; diff --git a/packages/plugins/apps/src/http/routes.ts b/packages/plugins/apps/src/http/routes.ts new file mode 100644 index 000000000..abad61a03 --- /dev/null +++ b/packages/plugins/apps/src/http/routes.ts @@ -0,0 +1,249 @@ +import { Effect } from "effect"; + +import type { AppsRuntime } from "../plugin/runtime"; +import type { Bindings } from "../plugin/bindings"; + +// --------------------------------------------------------------------------- +// Apps HTTP surface — a plain web handler (Request -> Response) the self-host +// app mounts as an extension route under `/api/apps/*`. Covers: +// POST /api/apps/:scope/publish { files } -> descriptor +// GET /api/apps/:scope/descriptor -> descriptor +// POST /api/apps/:scope/tools/:tool { args, bindings } -> result +// POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run +// POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run +// GET /api/apps/:scope/workflows/runs/:runId -> run + steps +// GET /api/apps/:scope/workflows/runs -> runs +// GET /api/apps/:scope/ui/:name -> compiled bundle (JS) +// GET /api/apps/:scope/live (SSE) -> invalidations +// +// It's deliberately transport-thin: all logic is in AppsRuntime. +// --------------------------------------------------------------------------- + +export interface AppsHttpDeps { + readonly runtime: AppsRuntime; + /** Mount prefix (default "/api/apps"). */ + readonly prefix?: string; + /** + * Authenticate an inbound request. Returns `true` when the caller is + * authorized and `false` otherwise; the handler answers `false` with a 401. + * + * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates + * per-scope state and reaches real integrations, so it MUST be behind the same + * credential the rest of `/api` requires. This seam lets the host thread its + * own identity check (self-host's Better Auth session/bearer/api-key) in + * without this package importing the host's auth stack. When omitted (tests + * that drive the runtime directly) every request is allowed. + */ + readonly authenticate?: (request: Request) => Promise; +} + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); + +export const makeAppsHttpRoutes = ( + deps: AppsHttpDeps, +): { readonly path: string; readonly handler: (request: Request) => Promise } => { + const prefix = deps.prefix ?? "/api/apps"; + const runtime = deps.runtime; + + const handler = async (request: Request): Promise => { + const url = new URL(request.url); + if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); + + // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is + // behind the host's identity check. An unauthenticated caller gets a 401 + // BEFORE any route logic runs — including the SSE stream. + if (deps.authenticate) { + const ok = await deps.authenticate(request).catch(() => false); + if (!ok) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + } + + const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); + const parts = rest.split("/").filter(Boolean); + // parts[0] = scope + const scope = parts[0]; + if (!scope) return json({ error: "scope required" }, 400); + + try { + // POST :scope/publish + if (parts[1] === "publish" && request.method === "POST") { + const body = (await request.json()) as { files: Record; message?: string }; + const files = new Map(Object.entries(body.files ?? {})); + const out = await run(runtime.publish({ scope, files, message: body.message })); + return json({ snapshotId: out.snapshotId, descriptor: out.descriptor }); + } + + // GET :scope/descriptor + if (parts[1] === "descriptor" && request.method === "GET") { + const descriptor = await run(runtime.getDescriptor(scope)); + return json({ descriptor }); + } + + // POST :scope/tools/:tool + if (parts[1] === "tools" && parts[2] && request.method === "POST") { + const body = (await request.json()) as { args?: unknown; bindings?: Bindings }; + const result = await run( + runtime.invokeTool({ + scope, + tool: parts[2], + args: body.args ?? {}, + bindings: body.bindings ?? {}, + }), + ); + return json({ result }); + } + + // POST :scope/workflows/:wf/start + if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") { + const body = (await request.json()) as { + input?: unknown; + bindings?: Bindings; + runId?: string; + }; + const runView = await run( + runtime.startWorkflow({ + scope, + workflow: parts[2], + input: body.input, + bindings: body.bindings, + runId: body.runId, + }), + ); + return json({ run: runView }); + } + + // POST :scope/workflows/runs/:runId/signal + if ( + parts[1] === "workflows" && + parts[2] === "runs" && + parts[3] && + parts[4] === "signal" && + request.method === "POST" + ) { + const body = (await request.json()) as { event: string; payload?: unknown }; + const runView = await run( + runtime.signalWorkflow({ + scope, + runId: parts[3], + event: body.event, + payload: body.payload, + }), + ); + return json({ run: runView }); + } + + // GET :scope/workflows/runs/:runId + if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") { + const runView = await run(runtime.getRun(parts[3])); + const steps = await run(runtime.listSteps(parts[3])); + return json({ run: runView, steps }); + } + + // GET :scope/workflows/runs + if ( + parts[1] === "workflows" && + parts[2] === "runs" && + !parts[3] && + request.method === "GET" + ) { + const runs = await run(runtime.listRuns(scope)); + return json({ runs }); + } + + // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML + // document when `?document=html` (Fix 10: the fallback a non-UI MCP client + // opens in a browser). Both are behind the same auth gate as the rest of + // the surface. + if (parts[1] === "ui" && parts[2] && request.method === "GET") { + if (url.searchParams.get("document") === "html") { + const doc = await run(runtime.getUiDocument(scope, parts[2])); + if (!doc) return new Response("ui not found", { status: 404 }); + return new Response(doc.html, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "x-ui-title": doc.title ?? "", + "x-ui-max-height": String(doc.maxHeight ?? ""), + }, + }); + } + const bundle = await run(runtime.getUiBundle(scope, parts[2])); + if (!bundle) return new Response("ui not found", { status: 404 }); + return new Response(bundle.code, { + status: 200, + headers: { + "content-type": "application/javascript", + "x-ui-title": bundle.title ?? "", + "x-ui-max-height": String(bundle.maxHeight ?? ""), + }, + }); + } + + // GET :scope/live -> SSE invalidations + if (parts[1] === "live" && request.method === "GET") { + return sseResponse(scope, runtime); + } + + return new Response("not found", { status: 404 }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return json({ error: message }, 400); + } + }; + + return { path: `${prefix}/*`, handler }; +}; + +// An SSE stream of `{table, version}` invalidations for a scope. Each scope-db +// write bumps a counter and publishes here; a `: keepalive` comment holds the +// connection open. +const sseResponse = (scope: string, runtime: AppsRuntime): Response => { + let unsubscribe: (() => void) | undefined; + let keepalive: ReturnType | undefined; + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`)); + unsubscribe = runtime.subscribeLive(scope, (event) => { + try { + controller.enqueue( + enc.encode( + `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`, + ), + ); + } catch { + /* controller closed */ + } + }); + keepalive = setInterval(() => { + try { + controller.enqueue(enc.encode(`: keepalive\n\n`)); + } catch { + /* closed */ + } + }, 15_000); + }, + cancel() { + unsubscribe?.(); + if (keepalive) clearInterval(keepalive); + }, + }); + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +}; diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts new file mode 100644 index 000000000..d9b5efea1 --- /dev/null +++ b/packages/plugins/apps/src/index.ts @@ -0,0 +1,55 @@ +// @executor-js/plugin-apps — the executor apps subsystem (self-hosted build). +// +// Custom tools, durable workflows, UI views, and skills: published into a +// per-scope store and served/executed by the platform. Publish is the compiler +// (FDI); every substrate-specific capability sits behind a seam. + +export * from "./seams"; +export * from "./pipeline/descriptor"; +export { discover, PublishError } from "./pipeline/discover"; +export { bundleEntry, PLATFORM_MODULES, INLINABLE_MODULES } from "./pipeline/bundle"; +export { + publish, + type PublishInput, + type PublishOutput, + type PublishDeps, + type PutBlob, +} from "./pipeline/publish"; + +export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; +export { + makeAppsStore, + type AppsStore, + type AppsStoreDeps, + descriptorCollection, +} from "./plugin/store"; +export { + buildBridge, + rootsFor, + BindingError, + type Bindings, + type RoleBinding, + type ClientResolver, + type BindingContext, +} from "./plugin/bindings"; +export { + makeSelfHostAppsRuntime, + type SelfHostAppsRuntime, + type SelfHostAppsRuntimeOptions, +} from "./plugin/self-host-runtime"; + +// Self-host seam backings. +export { makeGitArtifactStore } from "./backing/git-artifact-store"; +export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; +export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; +export { makeSqliteWorkflowRunner } from "./backing/sqlite-workflow-runner"; +export { makeInProcessLiveChannel } from "./backing/in-process-live-channel"; +export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; + +// Workflow scheduler. +export { + makeScheduler, + cronMatches, + type Scheduler, + type SchedulerOptions, +} from "./workflow/scheduler"; diff --git a/packages/plugins/apps/src/mcp/open-ui-capability.test.ts b/packages/plugins/apps/src/mcp/open-ui-capability.test.ts new file mode 100644 index 000000000..479ca5748 --- /dev/null +++ b/packages/plugins/apps/src/mcp/open-ui-capability.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime"; +import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; +import { registerAppsMcp, MCP_APPS_UI_CAPABILITY_KEY, type McpServerLike } from "./register"; +import { UI_APP_MIME } from "./ui-shell"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +// --------------------------------------------------------------------------- +// Finding 10 regression: `apps_open_ui` must check the client's MCP-Apps UI +// capability. A capable client gets a resource link (`_meta.ui.resourceUri`); a +// non-capable client (terminal) gets a fallback URL + a structured status, and +// NO `_meta.ui` (never overpromising a widget it can't render). +// --------------------------------------------------------------------------- + +interface Registered { + handler: (args: Record) => Promise | unknown; +} + +// A fake McpServer capturing tool registrations and advertising a chosen client +// capability. +const makeFakeServer = ( + clientCaps: { extensions?: Record } | undefined, +): { server: McpServerLike; tools: Map } => { + const tools = new Map(); + const server: McpServerLike = { + registerTool: (name, _config, handler) => { + tools.set(name, { handler }); + return undefined; + }, + registerResource: () => undefined, + server: { getClientCapabilities: () => clientCaps }, + }; + return { server, tools }; +}; + +const makeRuntime = async () => { + const store = makeInMemoryAppsStore(); + const resolver = makeTestResolver({ + github: { + "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }], + "issues.listForRepo": () => [], + }, + }); + const host = makeSelfHostAppsRuntime({ + dataDir: mkdtempSync(join(tmpdir(), "apps-openui-")), + store, + resolver, + inMemory: true, + }); + await run(host.runtime.publish({ scope: "default", files: dailyBriefFileSet() })); + return host; +}; + +describe("apps_open_ui capability check (Fix 10)", () => { + it("returns a resource link when the client supports MCP-Apps UI", async () => { + const host = await makeRuntime(); + const { server, tools } = makeFakeServer({ + extensions: { [MCP_APPS_UI_CAPABILITY_KEY]: { mimeTypes: [UI_APP_MIME] } }, + }); + registerAppsMcp(server, { + runtime: host.runtime, + scope: "default", + uiDocumentUrl: (s, n) => `http://host/api/apps/${s}/ui/${n}?document=html`, + }); + + const res = (await tools.get("apps_open_ui")!.handler({})) as { + _meta?: { ui?: { resourceUri?: string } }; + structuredContent?: { status?: string }; + }; + // Capable client: resource link + _meta.ui present. + expect(res._meta?.ui?.resourceUri).toBe("ui://default/dashboard"); + expect(res.structuredContent?.status).toBe("ui"); + await host.close(); + }, 60_000); + + it("returns a fallback URL and NO _meta.ui when the client cannot render UI", async () => { + const host = await makeRuntime(); + // No UI capability advertised. + const { server, tools } = makeFakeServer({ extensions: {} }); + registerAppsMcp(server, { + runtime: host.runtime, + scope: "default", + uiDocumentUrl: (s, n) => `http://host/api/apps/${s}/ui/${n}?document=html`, + }); + + const res = (await tools.get("apps_open_ui")!.handler({})) as { + _meta?: { ui?: unknown }; + structuredContent?: { status?: string; url?: string }; + }; + // Non-capable client: fallback URL, structured status, and NO _meta.ui. + expect(res._meta).toBeUndefined(); + expect(res.structuredContent?.status).toBe("fallback_url"); + expect(res.structuredContent?.url).toBe( + "http://host/api/apps/default/ui/dashboard?document=html", + ); + await host.close(); + }, 60_000); + + it("returns fallback_unavailable when non-capable and no URL is configured", async () => { + const host = await makeRuntime(); + const { server, tools } = makeFakeServer(undefined); + registerAppsMcp(server, { runtime: host.runtime, scope: "default" }); + + const res = (await tools.get("apps_open_ui")!.handler({})) as { + isError?: boolean; + structuredContent?: { status?: string }; + }; + expect(res.structuredContent?.status).toBe("fallback_unavailable"); + expect(res.isError).toBe(true); + await host.close(); + }, 60_000); +}); diff --git a/packages/plugins/apps/src/mcp/register.ts b/packages/plugins/apps/src/mcp/register.ts new file mode 100644 index 000000000..9ebbf0f51 --- /dev/null +++ b/packages/plugins/apps/src/mcp/register.ts @@ -0,0 +1,297 @@ +import { z } from "zod"; +import { Effect } from "effect"; +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { AppsRuntime } from "../plugin/runtime"; +import { UI_APP_MIME } from "./ui-shell"; + +// --------------------------------------------------------------------------- +// MCP surface for the apps subsystem. Registers, on a McpServer: +// - `apps_publish` tool: the chat-authoring door (agent publishes a file set) +// - `apps_list_skills` / `apps_read_skill` tools: published skills over MCP +// - one `ui:///` resource per published ui view (MCP Apps), +// carrying `_meta.ui` so a client renders it, + the raw bundle in the body +// +// Published TOOLS are already catalog citizens through the source plugin, so +// they surface over the host's normal `execute`/tools surface with no extra +// wiring here. This module adds the publish door, skills, and ui resources. +// +// `server` is a minimal structural view of the MCP SDK's McpServer (registerTool +// / registerResource), so this module has no hard dependency on a specific SDK +// version's class. +// --------------------------------------------------------------------------- + +interface McpToolResult { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; + _meta?: Record; +} + +export interface McpServerLike { + registerTool: ( + name: string, + config: { + description?: string; + inputSchema?: Record; + /** MCP `_meta`. Carries the MCP-Apps UI extension (`ui.resourceUri`) that + * links a tool to a `ui://` resource a host renders when it runs. */ + _meta?: Record; + }, + handler: (args: Record) => Promise | McpToolResult, + ) => unknown; + registerResource: ( + name: string, + uriOrTemplate: string | ResourceTemplate, + metadata: Record, + reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] }, + ) => unknown; + /** The underlying protocol server, exposing the negotiated client capabilities. + * Present on the real SDK `McpServer` (`.server`); optional in the structural + * view so tests can omit it (treated as "no UI capability"). */ + readonly server?: { + getClientCapabilities?: () => ClientCapabilitiesLike | undefined; + }; +} + +/** The subset of the client's negotiated capabilities we read: the MCP-Apps UI + * extension under `extensions["io.modelcontextprotocol/ui"]`. */ +export interface ClientCapabilitiesLike { + readonly experimental?: Record; + readonly extensions?: Record; +} + +/** The MCP-Apps UI client-capability extension key (per the MCP Apps spec). */ +export const MCP_APPS_UI_CAPABILITY_KEY = "io.modelcontextprotocol/ui"; + +/** True when the connected client advertises it can render MCP-Apps UI of our + * document mime type. A client that does NOT advertise this cannot mount the + * `ui://` resource, so `apps_open_ui` returns an HTTP fallback URL instead. */ +export const clientSupportsMcpAppsUi = (server: McpServerLike): boolean => { + const caps = server.server?.getClientCapabilities?.(); + const ui = caps?.extensions?.[MCP_APPS_UI_CAPABILITY_KEY] as + | { readonly mimeTypes?: readonly string[] } + | undefined; + if (!ui) return false; + // A UI extension with no explicit mime list is treated as generally capable; + // an explicit list must include our document mime. + return ui.mimeTypes === undefined || ui.mimeTypes.includes(UI_APP_MIME); +}; + +export interface AppsMcpDeps { + readonly runtime: AppsRuntime; + /** The scope this MCP session serves (self-host single-tenant). */ + readonly scope: string; + /** + * Build an absolute HTTP URL to the served HTML document for a ui view (Fix + * 10). Returned as the fallback when the client cannot render MCP-Apps UI + * inline, so a terminal/non-UI client still gets a working link instead of an + * unrenderable resource. Omit when no HTTP base is known (fallback becomes + * `fallback_unavailable`). + */ + readonly uiDocumentUrl?: (scope: string, name: string) => string | undefined; +} + +const text = (value: unknown): McpToolResult => ({ + content: [ + { + type: "text", + text: typeof value === "string" ? value : JSON.stringify(value, null, 2), + }, + ], + structuredContent: + typeof value === "object" && value !== null ? (value as Record) : undefined, +}); + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); + +export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void => { + const { runtime, scope } = deps; + + // --- the publish door ----------------------------------------------------- + server.registerTool( + "apps_publish", + { + description: + "Publish a set of app files (tools/, workflows/, ui/, skills/) into this scope. " + + "Returns the compiled descriptor: which tools, workflows, ui views and skills were published.", + inputSchema: { + files: z + .record(z.string(), z.string()) + .describe("Map of POSIX path -> file contents, e.g. { 'tools/x.ts': '...' }"), + message: z.string().optional().describe("Publish message"), + }, + }, + async ({ files, message }) => { + try { + const out = await run( + runtime.publish({ + scope, + files: new Map(Object.entries((files as Record) ?? {})), + message: message as string | undefined, + }), + ); + return text({ + snapshotId: out.snapshotId, + tools: out.descriptor.tools.map((t) => t.name), + workflows: out.descriptor.workflows.map((w) => w.name), + ui: out.descriptor.ui.map((u) => u.name), + skills: out.descriptor.skills.map((s) => s.name), + }); + } catch (cause) { + return { + ...text(cause instanceof Error ? cause.message : String(cause)), + isError: true, + }; + } + }, + ); + + // --- skills over MCP ------------------------------------------------------ + server.registerTool( + "apps_list_skills", + { + description: "List the skills published in this scope (name + description).", + inputSchema: {}, + }, + async () => { + const descriptor = await run(runtime.getDescriptor(scope)); + const skills = (descriptor?.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + })); + return text({ skills }); + }, + ); + + server.registerTool( + "apps_read_skill", + { + description: "Read a published skill's full SKILL.md body by name.", + inputSchema: { + name: z.string().describe("The skill name (== its directory)"), + }, + }, + async ({ name }) => { + const descriptor = await run(runtime.getDescriptor(scope)); + const skill = descriptor?.skills.find((s) => s.name === name); + if (!skill) return { ...text(`no skill named "${name}"`), isError: true }; + const body = await run(runtime.deps.store.getBlob(`skill/${skill.bodyHash}`)); + return text(body ?? ""); + }, + ); + + // --- open-ui tool: the MCP-Apps entry point for a ui view ----------------- + // A UI tool a host runs to MOUNT a published view. It declares the MCP-Apps UI + // extension (`_meta.ui.resourceUri`) linking it to the view's `ui://` resource, + // so an MCP-Apps host (Claude / ChatGPT, or the sunpeak host simulation) reads + // that resource and renders the widget when the tool runs. + // + // The extension keys off a CONCRETE resourceUri: a host reads it verbatim, so a + // `{name}` template placeholder is NOT expanded on read. The daily-brief app's + // primary view is `dashboard`; that is this tool's fixed target. + const defaultUiView = "dashboard"; + server.registerTool( + "apps_open_ui", + { + description: + `Open the published \`${defaultUiView}\` UI view. In a client that supports ` + + `MCP-Apps UI, this renders the widget inline. In a client that does NOT (e.g. a ` + + `terminal), it returns a URL to an HTTP-served HTML version of the view instead ` + + `of rendering, so it never overpromises a widget the client cannot show.`, + inputSchema: {}, + _meta: { ui: { resourceUri: `ui://${scope}/${defaultUiView}` } }, + }, + async () => { + const uri = `ui://${scope}/${defaultUiView}`; + // Fix 10: only claim to render inline when the client actually advertises + // the MCP-Apps UI capability. Otherwise fall back to an HTTP document URL + // (honest text + a structured status), never a `_meta.ui` resource link the + // client can't mount. + if (clientSupportsMcpAppsUi(server)) { + return { + content: [{ type: "text", text: `Opening ${defaultUiView}` }], + structuredContent: { status: "ui", uri, view: defaultUiView }, + _meta: { ui: { resourceUri: uri } }, + } as McpToolResult; + } + const url = deps.uiDocumentUrl?.(scope, defaultUiView); + if (url) { + return { + content: [ + { + type: "text", + text: + `This client can't render MCP-Apps UI inline. Open the \`${defaultUiView}\` ` + + `view in a browser instead:\n${url}`, + }, + ], + structuredContent: { status: "fallback_url", url, view: defaultUiView }, + } as McpToolResult; + } + return { + content: [ + { + type: "text", + text: + `This client can't render MCP-Apps UI inline and no HTTP document URL is ` + + `configured for the \`${defaultUiView}\` view.`, + }, + ], + structuredContent: { + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + view: defaultUiView, + }, + isError: true, + } as McpToolResult; + }, + ); + + // --- ui views as MCP Apps resources -------------------------------------- + // A dynamic resource TEMPLATE whose URI carries the ui view name; the reader + // resolves the view's self-booting HTML document (React + the executor:ui + // runtime + the compiled component + the current scope-db rows inlined). It is + // served as `text/html;profile=mcp-app` so a real MCP-Apps host (Claude / + // ChatGPT, or the sunpeak host simulation) mounts and renders it. `_meta.ui` + // marks it renderable. + // + // It MUST be a ResourceTemplate (not a fixed URI string): a fixed URI only + // matches itself, so `resources/read` of `ui:///` would 404 + // against a `ui:///` literal. The template `ui:///{name}` + // matches every published view under the scope. + server.registerResource( + "apps-ui", + new ResourceTemplate(`ui://${scope}/{name}`, { list: undefined }), + { description: "Published app UI views", mimeType: UI_APP_MIME }, + async (uri: URL) => { + // uri like ui:/// + const name = uri.pathname.replace(/^\//, "") || uri.hostname; + const viewName = name.includes("/") ? name.split("/").pop()! : name; + const doc = await run(runtime.getUiDocument(scope, viewName)); + if (!doc) { + return { + contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }], + }; + } + return { + contents: [ + { + uri: uri.toString(), + mimeType: UI_APP_MIME, + text: doc.html, + _meta: { + ui: { + title: doc.title, + maxHeight: doc.maxHeight, + // No external network needed: React, the runtime, and the initial + // rows are all inline in the document. + csp: { connectDomains: [], resourceDomains: [] }, + }, + }, + }, + ], + }; + }, + ); +}; diff --git a/packages/plugins/apps/src/mcp/ui-shell.ts b/packages/plugins/apps/src/mcp/ui-shell.ts new file mode 100644 index 000000000..12850c08a --- /dev/null +++ b/packages/plugins/apps/src/mcp/ui-shell.ts @@ -0,0 +1,263 @@ +import { build } from "esbuild"; + +import { Effect } from "effect"; + +import { ToolSandboxError } from "../seams/tool-sandbox"; + +// --------------------------------------------------------------------------- +// UI shell: wrap a published ui view's compiled bundle into a COMPLETE HTML +// document that a real MCP-Apps host (Claude / ChatGPT, or the sunpeak host +// simulation) can mount in a sandboxed iframe and render. +// +// The published ui bundle is CJS with `react` / `react-dom` / `executor:ui` / +// `executor:ui/components` left EXTERNAL (as `require(...)`). A browser host +// cannot run that directly: it needs React + the `executor:ui` runtime + the +// component primitives, then it must execute the module and mount its default +// export. This module produces that self-booting document. +// +// The runtime is hermetic: React and the executor:ui runtime are bundled INTO +// the document by esbuild (no CDN, so it renders under a strict sandbox CSP with +// no network). Live data is delivered as a data island the server writes at read +// time (`window.__EXECUTOR_UI__.rows`), so `useQuery` returns real scope-db rows +// on mount. Refetch is wired to the MCP-Apps host bridge + a lightweight polling +// fallback; the SSE-driven live refetch into the mounted widget is a documented +// follow-up (see APPS_DESIGN.md). +// --------------------------------------------------------------------------- + +/** The MCP-Apps resource MIME type real hosts key inline rendering on. */ +export const UI_APP_MIME = "text/html;profile=mcp-app"; + +// The browser runtime, bundled once (React + ReactDOM + the executor:ui shim + +// the mount driver). The `__PUBLISHED_BUNDLE__;` marker statement is replaced +// with the published component's CJS at document-assembly time. +const runtimeSource = ` +import React from "react"; +import { createRoot } from "react-dom/client"; + +// --- executor:ui runtime --------------------------------------------------- +// A real, minimal implementation of the author-facing hooks. \`useQuery\` returns +// the rows the host delivered in the data island and re-runs when the host +// signals an invalidation (postMessage from the MCP-Apps bridge). \`useTool\` +// posts a tool-call request to the host and resolves on its reply. +const __data = (globalThis.__EXECUTOR_UI__ || { rows: [], title: "", ready: false }); + +function useQuery(fn) { + const [state, setState] = React.useState(() => ({ + data: __data.rows || [], + isLoading: false, + error: null, + })); + const refetch = React.useCallback(() => { + // Ask the host to re-read; if it answers we update, else keep current rows. + try { + window.parent.postMessage({ type: "executor:ui/refetch" }, "*"); + } catch (_e) { /* sandboxed: ignore */ } + return Promise.resolve(state.data); + }, [state.data]); + React.useEffect(() => { + const onMessage = (event) => { + const msg = event && event.data; + if (msg && msg.type === "executor:ui/rows" && Array.isArray(msg.rows)) { + setState({ data: msg.rows, isLoading: false, error: null }); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + return { data: state.data, isLoading: state.isLoading, error: state.error, refetch }; +} + +function useTool(name) { + const [isRunning, setRunning] = React.useState(false); + const run = React.useCallback(async (args) => { + setRunning(true); + try { + window.parent.postMessage({ type: "executor:ui/tool", tool: name, args: args }, "*"); + } catch (_e) { /* sandboxed */ } + setRunning(false); + return undefined; + }, [name]); + return { run, isRunning }; +} + +function config() { /* metadata read at publish; a no-op in the browser */ } + +// --- executor:ui/components (minimal, styled primitives) ------------------- +const h = React.createElement; +const box = (tag, base) => (props) => { + const { className, children, ...rest } = props || {}; + return h(tag, { className: (base + " " + (className || "")).trim(), ...rest }, children); +}; +const components = { + Card: box("div", "ex-card"), + CardHeader: box("div", "ex-card-header"), + CardTitle: box("div", "ex-card-title"), + CardContent: box("div", "ex-card-content"), + Badge: box("span", "ex-badge"), + Button: (props) => { + const { className, children, ...rest } = props || {}; + return h("button", { className: ("ex-button " + (className || "")).trim(), ...rest }, children); + }, + Input: (props) => h("input", { className: "ex-input", ...(props || {}) }), +}; + +// --- CJS require shim: satisfy the bundle's externals ---------------------- +function __require(id) { + if (id === "react") return React; + if (id === "react/jsx-runtime") return { jsx: h, jsxs: h, Fragment: React.Fragment }; + if (id === "react-dom" || id === "react-dom/client") return { createRoot }; + if (id === "executor:ui") return { config, useQuery, useTool }; + if (id === "executor:ui/components") return components; + throw new Error("module not available in ui runtime: " + id); +} + +// --- run the published component bundle + mount ---------------------------- +function __runBundle() { + const module = { exports: {} }; + const exports = module.exports; + const require = __require; + // The published component's CJS is spliced in below. It is built from the same + // virtual entry the tool/workflow collect path uses, which assigns the author + // module onto \`globalThis.__artifact\` (NOT module.exports). We read the default + // export from there, falling back to module.exports for robustness. + (function (module, exports, require) { + __PUBLISHED_BUNDLE__; + })(module, exports, require); + const artifact = globalThis.__artifact; + return ( + (artifact && (artifact.default || artifact)) || + (module.exports && (module.exports.default || module.exports)) + ); +} + +function __mount() { + const App = __runBundle(); + const root = createRoot(document.getElementById("root")); + root.render(h(App)); + // Signal paint so a host waiting on first render can proceed. + try { window.parent.postMessage({ type: "executor:ui/mounted" }, "*"); } catch (_e) {} +} + +try { + __mount(); +} catch (err) { + // Surface a mount failure in the document itself so a host (and tests) see it + // instead of a blank frame. + const root = document.getElementById("root"); + if (root) root.textContent = "ui mount error: " + ((err && err.message) || String(err)); + try { window.parent.postMessage({ type: "executor:ui/error", message: String(err && err.message || err) }, "*"); } catch (_e) {} +} +`; + +const HTML_STYLES = ` + :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; } + body { margin: 0; padding: 12px; } + .ex-card { border: 1px solid rgba(0,0,0,0.12); border-radius: 8px; margin-top: 8px; } + .ex-card-header { padding: 8px 12px; border-bottom: 1px solid rgba(0,0,0,0.08); } + .ex-card-title { font-weight: 600; } + .ex-card-content { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; } + .ex-badge { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 6px; background: rgba(0,0,0,0.08); margin-left: 4px; } + .ex-button { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); background: #f5f5f5; cursor: pointer; } + .ex-input { padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); } + a { color: inherit; text-decoration: none; } +`; + +/** Bundle the browser runtime (React + shim + mount) with the published + * component's CJS spliced in, then wrap it in a complete HTML document. */ +export const buildUiDocument = (input: { + readonly compiledBundle: string; + readonly title: string; + readonly maxHeight?: number; + readonly rows: readonly unknown[]; +}): Effect.Effect => + Effect.tryPromise({ + try: async () => { + // Splice the published CJS in place of the marker statement. Use a + // function replacer so `$`-sequences in the bundle are not interpreted. + const runtimeWithBundle = runtimeSource.replace( + "__PUBLISHED_BUNDLE__;", + () => input.compiledBundle, + ); + const result = await build({ + stdin: { + contents: runtimeWithBundle, + loader: "tsx", + resolveDir: process.cwd(), + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: "es2022", + minify: true, + jsx: "automatic", + logLevel: "silent", + // React is resolved from this package's node_modules and inlined. + define: { "process.env.NODE_ENV": '"production"' }, + }); + const runtimeJs = result.outputFiles?.[0]?.text; + if (runtimeJs === undefined) throw new Error("ui runtime produced no output"); + + const dataIsland = safeJsonForScript({ + rows: input.rows, + title: input.title, + ready: true, + }); + // A complete, self-contained MCP-Apps document. No external network: React, + // the runtime, and the initial rows are all inline, so it renders under a + // strict sandbox CSP. + return [ + "", + '', + '', + `${escapeHtml(input.title)}`, + ``, + ``, + "", + '
', + ``, + "", + ].join(""); + }, + catch: (cause) => + new ToolSandboxError({ + kind: "bundle", + message: `ui document build failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + +// Serialize a value as JSON safe to embed in an inline `` (or `