Skip to content

Latest commit

 

History

History
133 lines (116 loc) · 28.2 KB

File metadata and controls

133 lines (116 loc) · 28.2 KB

This project is building a platform for "vibe coded" personal applications and AI agents that run inside a strong sandbox.

The following files are commonly important to reference:

  • packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.
  • packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.

The project structure is:

  • packages/workshop-frontend: The Gadgets Workshop UI.
    • This is a pure single-page app, running entirely client-side.
    • It speaks to the backend using an RPC API over a persistent WebSocket connection.
    • Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.
  • packages/workshop-backend: The Gadgets Workshop server.
    • Runs on Cloudflare Workers.
    • This is the kernel: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read every line of workshop-backend and of API changes in workshop-shared, so keep diffs here small and elegant. Concretely: doc-comment every exported member of the workshop-shared public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an as unknown as cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes "ambient" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so workshop-backend/workshop-shared can be reviewed apart from UI), since fewer kernel lines = easier review.
    • format-blueprints/ holds the output format blueprints the deployment ships with, committed as data: a <name>.gadget archive plus a <name>.json sidecar giving its blueprintId, prose, and output presentation. scripts/build-format-blueprints.mjs globs that directory (override with FORMAT_BLUEPRINTS_DIR, which lets a fork ship its own set without touching this submodule) into the gitignored src/generated/format-blueprints.ts, so build and test both run the generator first (it rewrites the module only when the content changes, so the repeat invocations don't invalidate the downstream task cache). Replace one with pnpm import:format-blueprint <export.gadget> <blueprintId>, or add one with pnpm import:format-blueprint <export.gadget> --new <name>; never edit a blueprintId after deploy, since the install and promotion are keyed on it and a rename orphans the old entry. See format-blueprints/README.md.
  • packages/workshop-shared: Shared API definitions between client and server.
    • This defines the application's RPC interface.
    • The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.
  • packages/configurator-ui: Type-only component helpers used by optional gatekeeper resource configurator UI modules.
    • Gatekeeper configurator UI modules are compiled by scripts/build-gatekeeper-configurator.mjs as part of package builds.
  • packages/gatekeeper-*: Gatekeeper workers for external service integrations.
    • Each gatekeeper runs as a separate Cloudflare Worker.
    • Gatekeepers handle OAuth flows and provide sandboxed access to external APIs.
    • A gatekeeper may declare VendorDescription.autoProvisionsAccount: it can mint a connected account with no OAuth flow (via GatekeeperVendor.createAccount(), which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — disabled / optional / enabled (default optional) — resolved in provisioning-policy.ts: enabled auto-provisions the account for every user (forced, and hidden from the Connectors list), optional lets each user opt in from the Connectors page, and disabled offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The account (a GatekeeperUser) declares in its AccountDescription whether it provides an agent singleton (singleton: { tsType }) and/or a management UI (providesUi). The Workshop auto-provides the singleton to the owner's workspaces as an ambient gatekeeper record, folded into each chat's env as a named chat binding (named by the gatekeeper's suggestedBindingName; see prepareChatBindings in overseer.ts) that the agent reads in executeCode (getSession/getAgentCatalog), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with setGadgetBinding when the gadget's persistent code needs it. The UI is hosted at /gatekeepers/$appId (the gatekeeper's vendor id, e.g. /gatekeepers/context) via startAppUi({ isAdmin }). The two are orthogonal — an account can declare either, both, or neither.
  • packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — gatekeeper-mcp (endpoints a user pastes) and gatekeeper-mcp-portal (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See packages/mcp-shared/README.md and each connector's README.
    • The trust boundary is tools.ts, and nothing outside it reads a tool's annotations: a tool the server declares readOnlyHint: true runs as an observation, everything else is queued for approval, and auto-applying a write additionally requires a vetted endpoint — which only the portal can produce, via MCP_PORTAL_TRUST_ANNOTATIONS.
    • OAuth uses the official @modelcontextprotocol/client; always give SDK OAuth operations sdkFetch(...) so every request and redirect retains endpoint and SSRF checks.
  • packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: private (owned by a single account, readable/writable only by that account) and public (created/edited only by deployment admins, readable by everyone and auto-enabled for all users). It owns its state in three Durable Objects (ContextCollectionDurableObject for content, UserLibraryDurableObject for each account's own private collections, LibraryRegistryDurableObject for the domain's public set) plus a KV namespace. All data is namespaced by a sharingDomain (from the binding's props, see domain.ts) so multiple workshops sharing one gatekeeper instance stay isolated.
    • Its GatekeeperVendor entrypoint (bound as GATEKEEPER_CONTEXT) declares autoProvisionsAccount and mints a ContextAccount via createAccount() (no user identity is passed in; the account keys its private data by its own generated accountId). The account exposes the agent read session (getSession()), collection discovery metadata (getAgentCatalog()), and a management UI (startAppUi({ isAdmin })). The UI is a single-file React SPA in app/ (Vite + Tailwind + Kumo) bundled by build-app.mjs into src/generated/app.txt.
  • packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped ScheduleDriver Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI.
  • packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: /api/* and /blueprint-screenshot/* to the workshop backend, /gatekeeper/<name>/* to whichever gatekeepers are bound (discovered by scanning its own GATEKEEPER_* service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (pnpm dev-server): with no ASSETS binding it proxies frontend requests to the Vite dev server instead.

Deployment admin settings (the /admin panel) follow a few conventions worth knowing when extending them:

  • packages/workshop-backend/src/admin-config.ts defines AdminConfig — the deployment's "soft" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see provisioning-policy.ts). Connectors/resources default to enabled and the admin UI opts them out; auto-provisioning gatekeepers default to optional. Authentication/authorization config (sign-in providers via AUTH_GATEKEEPERS, password login via DISABLE_PASSWORD_AUTH) is deliberately NOT here — it stays env-var driven (auth/config.ts) so it can't be changed by a compromised admin session.
  • The AdminSettings durable object owns the authoritative AdminConfig and mirrors it to a single reserved KV key (.adminConfig, see isReservedBlueprintKey()), so hot-path code (connect/agent) reads it with one cheap KV get via readAdminConfig(env). The DO is the only writer (updateAdminConfig(patch)).
  • Admin operations are exposed as an AdminApi capability obtained via AuthenticatedApi.getAdminApi() (returns null for non-admins). The #isAdmin() check happens once when the capability is minted, so the individual methods don't re-check.
  • user.ts:getGatekeeperClassFor() is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted (gadget/agent code can't reach it directly).

Release pipeline (scripts/release/) — how customer instances get deployed:

  • build-release.mjs bundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced by manifest-lib.mjs from each package's wrangler.jsonc with account-specific values replaced by placeholders ($ACCOUNT_ID, $WORKER_NAME(...), $SECRET(...), $PUBLIC_BASE_URL, ...).
  • upload-release.mjs mirrors the release to R2 content-addressed, manifest last; with --candidate the manifest lands under candidates/<id>/ (invisible to the deploy service) so e2e can verify it, and promote-release.mjs then copies it to releases/<id>/ — publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.
  • The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with UPDATE_GOLDEN=1 node --test scripts/release-manifest.test.js and review the golden diff.
  • Running the flow by hand (upload and promote need R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY):
    • node scripts/release/build-release.mjs --out release-out — build everything into release-out/ (id defaults to r<CI_PIPELINE_IID>-<sha7> in CI, dev-<timestamp> locally; override with --release-id <id>).
    • node scripts/release/upload-release.mjs --release release-out --candidate — mirror to R2; omit --candidate to publish directly (bypasses the gate — CI never does this).
    • node scripts/release/promote-release.mjs --release-id <id> — copy the verified candidate's manifest into releases/<id>/.
  • Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth CLIENT_ID/CLIENT_SECRET secrets; a per-package deploy-inputs.json overrides them, and NO_DEFAULT_CRED_INPUTS in manifest-lib.mjs opts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (ADMINS, DEPLOY_URL, ...) are injected by the deploy service at PUT time, never manifest-templated.

To test changes:

  • Run pnpm build (optionally narrowed to a particular package) to run TypeScript type checks. It is a type check and codegen pass, not a compile: every package but typed-storage is noEmit, because nothing imports the others' dist — wrangler and vite bundle from source. typed-storage still emits because its exports resolves to dist/index.js. A re-run with nothing changed is much cheaper than the first, because Vite+ replays it from the task cache — see below.

  • Run pnpm test to run unit tests, though as of this writing most packages don't have tests yet. It runs the root's own node --test scripts/*.test.js suite and then the per-package tests.

  • The cached per-package test run is a Vite+ test task in each package's vite.config.ts rather than a test script, so its input can exclude the scratch paths vitest writes and reads back (scripts/vitest-task-vite-config.ts, shared by all of them). Gatekeepers with a configurator UI re-export withTests from gatekeeper-configurator-vite-config.ts to get both tasks at once; the ones with no test files re-export its default instead, because vitest run exits 1 when it finds none.

  • Two ways to run one package's tests: pnpm --filter <package> test:run goes straight to vitest, vp run -F <package> test goes through the cache. The cached path replays instantly when the package is untouched, but its fingerprinting and archiving lose to plain vitest on a package you just edited — by more than the whole suite costs on a small one. Use test:run while iterating and pnpm test to verify. The direct script is test:run rather than test because a task may not share a name with a script.

  • pnpm build and pnpm clean are vp run -r <task>, not pnpm run --recursive <task>. Vite+ runs the same per-package scripts and tasks, in dependency order, but caches each one against its inputs, so an unchanged package replays its previous output instead of re-running. Commands joined with && — or given as an array in a task — are cached as separate entries, so a package whose codegen is fresh can still re-run its tsc. vp run --last-details explains every hit and miss, which is the thing to read when a build is slower than expected. Don't reintroduce a root script that calls pnpm run --recursive: vp run -r selects the root package too, and would run it as a task and rebuild the whole workspace a second time.

  • pnpm test uses --filter '!cloudflare-os' rather than -r for the mirror-image reason: the root has a test script of its own, and -r selects the root, so the scripts suite ran twice per pnpm test. The filter hardcodes the root package's name — rename the root and the duplicate run comes back silently.

  • "singleThreaded": true is set in the root tsconfig.json (and mirrored in the two standalone tsconfig.app.jsons that don't extend it), so every tsc run is single-threaded without per-script flags. tsgo's default mode splits the program across parallel checker instances with separate type caches, and since every file here touches capnweb's instantiation-heavy recursive generics, each checker re-derives the same huge type graphs: measured on workshop-backend, 6.9x the types and 4.2x the instantiations of a single checker, 7.3s/1.9GB vs 2.0s/0.7GB single-threaded (tsc 5.9 was 4.1s/0.9GB). Single-threaded is both the fastest and the smallest configuration, and it makes build tasks cheap enough for vp's default concurrency. Tests are the separate risk: the workerd fleets are the memory hogs there, and an OOM-killed (exit 137) workerd child wedges its vitest parent forever instead of failing. If pnpm test ever hangs rather than fails, suspect that first and drop vp's concurrency.

  • Caching is off for tasks that read a path they also write, which is why workshop-frontend's vite build still doesn't cache (the gatekeeper SPA bundles used to be in the same position — see the build:app bullet below for the fix, which would transfer). The test tasks were in the same position until they excluded the scratch paths vitest and wrangler write under node_modules/.vite, node_modules/.vite-temp and .wrangler from both input and output; scripts/vitest-task-vite-config.ts covers which and why. That list is unlikely to be closed — when a test task stops caching, --last-details names the path it read and wrote. This is also why no tsconfig sets incremental: tsc reads its own .tsbuildinfo and writes it back, which takes the whole type check out of the task cache to save less than the cache does (measured: 2% hits and 21.1s on a clean tree, against 65% and 13.4s without it). Don't add it back without re-measuring vp run --last-details.

  • pnpm dev-server builds the gatekeeper UIs before starting wrangler dev, through two concurrent vp run -r --cache calls — build:configurator and build:app:dev (vp run takes one task each). vp selects packages by which ones declare the task or script, so a new gatekeeper needs one of those to be built here at all, the same requirement pnpm build has. Watchers spawn only after the pre-flight finishes: every watch mode builds before it watches, so an earlier spawn would put two processes on the same src/generated files. Two dev-only details keep startup fast:

    • The generated dev configs spawn each worker's build.command binary directly (node <resolved entry>, resolved through the package's own node_modules) instead of through pnpm exec, which costs ~0.33s of process startup per call — paid for every worker, and again on each rebuild, all of it on the startup critical path. Commands that don't resolve are left as written. wrangler dev is reached the same way.
    • The app watchers are deferred until Wrangler is listening (TCP poll, 60s backstop): vite build --watch can't skip its initial build and these are the largest builds in the repo. Hence spawn rather than execFileSync at the tail of run-dev-server.js — but shutdown is still driven by Wrangler's exit, because Ctrl-C reaches the whole process group and exiting out from under Wrangler would orphan its workerd children.
  • build:app is a Vite+ task in each gatekeeper's vite.config.ts rather than a package.json script so its input can be stated explicitly: {auto: true} minus **/dist-app/**, **/src/generated/** and **/.wrangler/** at base: "workspace", plus an explicit output. Automatic tracking alone never cached it, because the build writes into the same package tracking hashes as its input. Two traps: the exclusions have to be workspace-wide or the gatekeepers invalidate each other, and only directory contents can be excluded, not the directories themselves, so pnpm clean still costs one cold build.

  • build:app:dev is the same build with minify: false, run by the pnpm dev-server pre-flight so its app.txt matches what the watcher's un-skippable initial build will write — otherwise emitAppText rewrites the file and Wrangler restarts the worker mid-startup. It captures only app.txt, since dist-app/ has no reader outside vite.app.config.ts. build and deploy still use build:app, so nothing unminified ships, and build-app.mjs always sets GATEKEEPER_APP_UNMINIFIED explicitly — an inherited value would otherwise make a production build unminified and get it cached that way.

    Two structural constraints explain the file layout. Vite+ reads per-package settings only from vite.config.*, which the SPA's own build config occupied, so that moved to vite.app.config.ts (referenced by build-app.mjs -c, tsconfig.vite.json and gatekeeper-context's __tests__/vite-config.test.ts). And a task may not share a name with a package.json script, so the build:app script is gone and build/deploy call vp run --cache build:app instead. Don't define the task in the workspace-root config: it gets created for every package, including the root, which then fails.

  • The five packages whose tests run in workerd (router, typed-storage, backend-utils, workshop-backend, gatekeeper-scheduler) load test-setup/assert-workerd.ts as a setupFiles entry. It throws unless navigator.userAgent is Cloudflare-Workers, so a @cloudflare/vitest-pool-workers pool that fails to start fails the suite instead of silently falling back to Node — which otherwise looks like a pass in the packages that import no cloudflare:* module. Don't remove it to make a suite green.

Linting (oxlint, via Vite+):

  • pnpm lint runs what CI enforces: lint:check (oxlint) and types:check. Run this before pushing.
  • Individual scripts:
    • pnpm lint:check / pnpm lint:fixvp lint, i.e. oxlint driven by Vite+ (rules in the lint block of the root vite.config.ts; correctness + suspicious as errors). Vite+ pins the oxlint it runs (1.76.0), so there is no separate oxlint dependency to drift from it and no .oxlintrc.json beside the config — one toolchain config, one version. Diagnostics are identical to what running that oxlint directly would emit.
    • pnpm types:check — an alias for pnpm build. They were separate scripts running the same recursive tsc twice; one name is kept for habit and the other because the codegen prerequisites hang off it. vp lint is not part of vp run, so it has no task cache; it takes about a second regardless.
  • Unused function parameters and caught errors are not lint-enforced; unused imports and local variables are still errors.
  • Some rules are kept as warnings (e.g. no-shadow) for incremental cleanup; warnings don't block CI.
  • Type-aware oxlint rules are intentionally not enabled yet. The type-aware engine is tsgo (TypeScript 7), which is now also the workspace tsc, and every package type-checks under it (capnweb 0.11's shared RpcPromise alias fixed the TS2321/TS2589 instantiation-depth errors that used to block three packages). But nobody has run the type-aware rules themselves: expect a triage pass, and note no-floating-promises conflicts with RPC promise pipelining (below), which intentionally leaves promises unawaited. Type safety is still enforced by tsc through pnpm types:check and pnpm build.
  • The typescript catalog entry is 7.0.2 (tsgo), but TS 7's main export is ./lib/version.cjs — the compiler API is gone from it — so everything that still needs that API gets its own JS-based compiler. scripts/build-gatekeeper-configurator.mjs (and the mcp-shared schema test) import the root typescript6 alias (npm:typescript@6.0.3); capnweb-validate (0.2.4+) ships its own capped typescript dependency for the @validateRpc transform. typed-storage, the only package emitting declarations, sets the "rootDir": "./src" TS 7 requires (TS5011).
  • No tsconfig sets baseUrl, and none should. Every paths entry here is an explicit relative path, which tsc resolves against the tsconfig's own directory, so baseUrl bought nothing — and TypeScript 7 removed the option outright (TS5102).

IMPORTANT: This repository uses pnpm, not npm. Always use pnpm.

IMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.

IMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.

IMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call stub[Symbol.dispose]() when the stub is no longer needed (or use a using declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.

IMPORTANT: Server-side logging uses @gadgets/backend-utils/logger (frontend browser console.* is out of scope):

  • Define a package-owned field type and module-scoped logger with a stable dot-separated component and, for gatekeepers, vendorId: const logger = createLogger<GitHubLogFields>({ component: "gatekeeper.github", vendorId: VENDOR_ID });.
  • Emit concrete event names and relevant typed fields, for example: logger.warn("failed to notify credential expiry", { event: "credentials.expiry.notify.failed", error: err });. Each call emits one indexed object; module/child fields such as vendorId are inherited.
  • Use immutable logger.with(fields) for object-owned or nearby context. Prefer module/object loggers over logger parameters, and do not replace a shallow child logger with ambient context just to remove a local variable.
  • For bounded operation context needed by deep helpers, independent loggers, or other observability consumers, use createObservabilityContext from @gadgets/backend-utils/observability-context. Re-establish it per operation; it does not cross RPC, hibernation, or restart, and requires nodejs_als or nodejs_compat.
  • Pass caught values as error. The helper stringifies Error instances and primitives, uses an own string message for plain objects, omits undefined, and adds stacks to all Error logs. Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.
  • Extend field vocabularies locally. Levels: error needs attention, warn continues best-effort, info is notable lifecycle, and debug is noisy breadcrumbs. Never log secrets, prompts, headers, tokens, or request/response bodies.
  • To also dispatch a failure to the optional external issue Reporter (in addition to logging it), call reportIssue(failureSite, caught, options?) from @gadgets/backend-utils/error-reporting. Attach ambient fields from the package's observability context and augment them with capture-site fields: reportIssue("overseer.catalog-fallback", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });. It is a no-op when the ERROR_REPORTER binding is absent (local dev / deployments without an issue destination). Only bounded scalars are retained as attributes; reported context obeys the same no-secrets rules as log fields.

IMPORTANT: Frontend error reporting is a separate, opt-in path:

  • @gadgets/error-reporting owns the vendor-neutral browser/Worker event contract and tolerant, bounded normalization. VITE_FRONTEND_ERROR_REPORTING=true enables trusted frontend producers and their hidden source maps at build time; deployments without reporting should leave it unset.
  • The Workshop browser sends best-effort reports to the same-origin POST /api/client-errors endpoint. The backend dispatches only when both FRONTEND_ERROR_REPORTER and FRONTEND_ERROR_RATE_LIMITER are bound; otherwise the endpoint is an intentional no-op.
  • Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin srcDoc frames. They send bounded reports with postMessage; the host accepts them only from the known frame window with origin null, adds host-owned surface/vendor context, and performs the same-origin POST. Do not add direct cross-origin reporting from a gatekeeper Worker domain.
  • Frontend reports and frame metadata are diagnostic only and never convey identity or authority. Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code. Exception messages and stacks reach the external Reporter, so never intentionally put secrets, prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.