From 67c3b478ea8e189ade5b8fe5b16ba4e68628b2e7 Mon Sep 17 00:00:00 2001 From: gone_snow Date: Sat, 17 Oct 2026 10:45:02 +0200 Subject: [PATCH 1/3] Initial commit with codebound changes to add, edit, delete scripts --- README.md | 3 + docs/codebound.md | 192 +++++ package-lock.json | 68 ++ packages/backend/package.json | 1 + .../migration.sql | 31 + packages/backend/prisma/schema.prisma | 27 + packages/backend/src/app.module.ts | 2 + .../src/codebound/codebound.controller.ts | 42 ++ .../backend/src/codebound/codebound.dto.ts | 65 ++ .../codebound/codebound.integration.spec.ts | 119 ++++ .../backend/src/codebound/codebound.module.ts | 14 + .../src/codebound/codebound.service.spec.ts | 241 +++++++ .../src/codebound/codebound.service.ts | 222 ++++++ .../backend/src/codebound/sandbox-worker.ts | 95 +++ .../backend/src/codebound/sandbox.spec.ts | 175 +++++ packages/backend/src/codebound/sandbox.ts | 126 ++++ .../src/codebound/saved-scripts.controller.ts | 79 +++ .../src/codebound/saved-scripts.db.spec.ts | 213 ++++++ .../codebound/saved-scripts.service.spec.ts | 278 ++++++++ .../src/codebound/saved-scripts.service.ts | 217 ++++++ .../src/mcp-server/dynamic-mcp-tools.spec.ts | 14 + .../src/mcp-server/dynamic-mcp-tools.ts | 6 +- .../src/mcp-server/mcp-server.module.ts | 2 +- packages/frontend/src/app/codebound/page.tsx | 656 ++++++++++++++++++ .../frontend/src/components/app-sidebar.tsx | 1 + packages/frontend/src/lib/api.ts | 55 ++ packages/frontend/tests/e2e/codebound.spec.ts | 297 ++++++++ 27 files changed, 3238 insertions(+), 3 deletions(-) create mode 100644 docs/codebound.md create mode 100644 packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql create mode 100644 packages/backend/src/codebound/codebound.controller.ts create mode 100644 packages/backend/src/codebound/codebound.dto.ts create mode 100644 packages/backend/src/codebound/codebound.integration.spec.ts create mode 100644 packages/backend/src/codebound/codebound.module.ts create mode 100644 packages/backend/src/codebound/codebound.service.spec.ts create mode 100644 packages/backend/src/codebound/codebound.service.ts create mode 100644 packages/backend/src/codebound/sandbox-worker.ts create mode 100644 packages/backend/src/codebound/sandbox.spec.ts create mode 100644 packages/backend/src/codebound/sandbox.ts create mode 100644 packages/backend/src/codebound/saved-scripts.controller.ts create mode 100644 packages/backend/src/codebound/saved-scripts.db.spec.ts create mode 100644 packages/backend/src/codebound/saved-scripts.service.spec.ts create mode 100644 packages/backend/src/codebound/saved-scripts.service.ts create mode 100644 packages/frontend/src/app/codebound/page.tsx create mode 100644 packages/frontend/tests/e2e/codebound.spec.ts diff --git a/README.md b/README.md index a6f917bec..1dbc01606 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,9 @@ Everything runs on your infrastructure, so you decide what leaves it. OAuth2, RB [Response mapping reference](docs/tool-definition.md#3-response-mapping-optional) +**Codebound prototype:** save and run JavaScript against authorized REST read tools and +return a compact JSON result from the app. [Script runner guide](docs/codebound.md) + --- ## Use it from Claude, ChatGPT, Copilot and Gemini diff --git a/docs/codebound.md b/docs/codebound.md new file mode 100644 index 000000000..ea616580b --- /dev/null +++ b/docs/codebound.md @@ -0,0 +1,192 @@ +# Codebound script runner + +Codebound saves JavaScript workflows and runs them against authorized connector +tools, returning a compact JSON result. Open **Build → Codebound**, choose an +active MCP server, select the tools a run may call, then click **Run script**. +Give the script a name and description and click **Save script** to reuse it. +The connector must already be assigned to the server and ready to use. + +For the setup demo connector, the starter script is: + +```javascript +const post = await tools.call('setup_demo_get_post', {}); +if (!post || typeof post.id !== 'number' || typeof post.title !== 'string') { + throw new Error('Expected a post with a numeric id and a title.'); +} +return { id: post.id, title: post.title }; +``` + +The editor shows the available tools' descriptions and input/output schemas. +An absent output schema is shown as `null`; check a representative response +and validate the fields your script needs. `tools.call` returns the executor's +filtered, structured JSON response, without MCP text footers. Scripts can use +`input`, the JSON parameters entered beside the code. Await each call before +starting another. Only the final return value is displayed. + +## Supported scope + +The saved-script list supports **Open**, **New script**, and **Delete** with a +confirmation. Open a script, edit it, and click **Save changes**. Saved records +include the name, description, MCP server, JavaScript, default parameters, and +tool allowlist. They persist in PostgreSQL across page reloads and backend +restarts. The list is paginated and excludes source and parameters until opened. + +Running an opened script uses its stored source, server, and allowlist. The +parameters box can override its defaults for one run. If source, server, or +allowlist have unsaved changes, **Run script** tests that draft; **Save changes** +updates the reusable script. Save also persists the parameters as new defaults. +Opening another script or starting a new one prompts before discarding edits. + +Edits use a revision counter: an outdated editor receives HTTP 409 instead of +overwriting another user's changes. Running an opened script also checks its +reviewed revision. Reopen it after a conflict to review the current source. +Deleting a script removes access to it; deleting its server also deletes linked +scripts. Runs already in progress use the snapshot loaded when the run began. + +MCP discovery/invocation of saved scripts, marketplace bundles, AI authoring, +and the quotation workflow remain later milestones. + +Both Cloud and self-hosted deployments use the same page and authenticated API. +The existing workspace license check still applies. Server assignment, active +connectors, organization membership, and role-based tool access restrict calls +in both modes. Editors and administrators can create, edit, and delete scripts. +Viewers can read and run them within their current tool permissions. A saved +allowlist is a restriction, not a grant: another author's script never expands +the caller's connector access. Both deployments use the same storage and API; +there is no edition-specific behavior. + +### Local development database + +The `20261017000000_add_codebound_scripts` migration adds `codebound_scripts` +and a composite foreign key that enforces the server's organization. Apply it +to the development database on localhost:5433, from `packages/backend`: + +```bash +set -a +. ../../.env +set +a +npx prisma migrate deploy +npx prisma generate +``` + +For this prototype, eligible tools are REST `GET`, `HEAD`, and `static` tools +whose read-only annotation is true. Other transports and writes are excluded. +The read-only classification depends on the connector's configured HTTP method; +it cannot establish whether an upstream API abuses GET for mutations. + +## Execution boundary and limits + +Each run creates a fresh QuickJS WebAssembly runtime in a worker. User code runs +only in QuickJS. Node, environment variables, files, direct networking, timers, +and package imports are unavailable. Only serialized JSON crosses the broker. +Credentials remain in the existing connector executor, which retains network +rules, response transforms, secret redaction, and tool invocation auditing. +Script reads bypass the executor's response cache so each read is audited and +cannot consume a shared cache entry from a different caller. + +| Budget | Limit | +| --- | --- | +| Worker wall time, including connector waits | 5 seconds | +| QuickJS heap / stack | 64 MiB / 512 KiB | +| Connector calls | 20, sequential | +| Parameters / arguments per call | 64 KiB each | +| Cumulative serialized tool replies | 1 MiB | +| Returned JSON | 32 KiB | +| Script source | 32,768 characters | +| Active runs per backend process | 4 total, 1 per organization | + +The host terminates the worker at the deadline and rejects late results. +Existing connector engines do not accept an abort signal: a read already sent +upstream may finish after the script stops. No further reads are dispatched. +The organization's run slot stays occupied until that read finishes. Limits +apply per backend process; this is not a distributed resource quota. + +Every started parent run produces a `CODEBOUND_RUN` security event containing +the run ID, caller, server, status, duration, and dispatched call count. Nested +invocations carry `Codebound run ` in their intent field. Parent events +omit source, parameters, results, and raw exceptions. Connector errors shown to +scripts are generic; inspect the normal tool invocation log for details. +Runs of saved scripts also include the script ID and revision in the parent +event. Create, update, and delete produce `CODEBOUND_SCRIPT_CREATED`, +`CODEBOUND_SCRIPT_UPDATED`, and `CODEBOUND_SCRIPT_DELETED` security events, +containing metadata without source or parameters. + +## API and verification + +Dashboard JWT authentication is required: + +- `GET /api/codebound/servers/:serverId/tools`: permitted tool descriptions and schemas. +- `POST /api/codebound/servers/:serverId/run`: `{ code, input, allowedTools }`. + Returns `{ runId, result, calls, durationMs }` or an HTTP error. +- `GET /api/codebound/scripts?limit=20&offset=0`: `{ items, total }` for this + workspace; `limit` is capped at 200. +- `POST /api/codebound/scripts`: `{ name, description, mcpServerId, code, input, + allowedTools }` creates a saved script. +- `GET /api/codebound/scripts/:id`: opens its source and defaults. +- `PUT /api/codebound/scripts/:id`: replaces editable fields using the create + body plus the reviewed `revision`; returns the incremented revision. +- `DELETE /api/codebound/scripts/:id`: removes it. +- `POST /api/codebound/scripts/:id/run`: `{ input, revision }`; source, server, + and allowlist overrides are rejected. Returns the usual run result. + +The caller's workspace and user identity come from authentication. Script +parameters cannot override them. Every nested call rechecks current permissions +and server assignments, validates arguments with the same schema conversion +used by MCP, then invokes the existing executor pinned to the resolved connector. + +Run backend boundary/permission tests from `packages/backend`: + +```bash +npm test -- --runInBand codebound +``` + +After applying the migration, also exercise real persistence, concurrent writes, +tenant denials, the database foreign key, and deletion against local PostgreSQL. +With the local `.env` exported as above, run: + +```bash +RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound +``` + +The database tests create two temporary tenant fixtures and remove them after +the run. They require localhost:5433 and are skipped without the explicit flag. + +Run UI tests from `packages/frontend`, with the regular development server +stopped so Playwright can start Next on port 3100: + +```bash +npm run test:e2e -- codebound.spec.ts +``` + +The UI tests mock API responses. Backend tests exercise real QuickJS execution, +including isolation, forbidden host APIs, timeouts, quotas, role/tenant denials, +and recovery. An integration test runs the broker, executor, and REST engine +against a local HTTP fixture, checking response filtering and both audit levels. +They are prototype evidence; they do not certify production isolation or a live +third-party integration. + +### Verification on 17 October 2026 + +| Working directory | Command | Result | +| --- | --- | --- | +| `packages/backend` | `npm test -- --runInBand` | 662 suites passed; 19,799 tests passed; 5 suites / 1,454 tests skipped (including 5 opt-in PostgreSQL tests, exercised separately below) | +| `packages/backend` | `npm test -- --runInBand codebound` | 48 tests passed; PostgreSQL tests are opt-in | +| `packages/backend` | `RUN_CODEBOUND_DB=1 npm test -- --runInBand codebound` | 53 tests passed, including PostgreSQL persistence and isolation | +| `packages/backend` | `npx prisma validate` / `npx prisma migrate deploy` | Schema valid; new migration applied to local development PostgreSQL | +| `packages/backend` | `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` | No difference detected | +| `packages/backend` | `npm run lint:check` | Passed; 14 existing warnings | +| `packages/backend` | `npx tsc --noEmit -p tsconfig.json` | Passed | +| `packages/backend` | `npm run build` | Passed, including Prisma generation | +| `packages/frontend` | `npm run lint` | Passed; 26 existing warnings | +| `packages/frontend` | `npx tsc --noEmit -p tsconfig.json` | Passed | +| `packages/frontend` | `npx playwright install --with-deps chromium` | Passed | +| `packages/frontend` | `npm run test:e2e -- codebound.spec.ts` | 7 Chromium tests passed, including saved-script CRUD, viewer controls, and pagination recovery | +| Repository root | `node scripts/validate-adapters.mjs` | 490 adapters passed; 1,458 existing non-blocking warnings | +| Repository root | `node scripts/check-runtime-deps.mjs` | Passed | + +The first backend baseline under the restricted execution sandbox had 23 failed +suites because local HTTP listeners were blocked. The full rerun with localhost +access passed as recorded above. Browser installation and Playwright also needed +access outside that sandbox. The initial UI run caught an ambiguous alert test +locator; the corrected tests pass. Regular development servers and Playwright's +server were stopped after verification. diff --git a/package-lock.json b/package-lock.json index afd05558d..284ca44c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2760,6 +2760,48 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.31.0.tgz", + "integrity": "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.31.0.tgz", + "integrity": "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.31.0.tgz", + "integrity": "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.31.0.tgz", + "integrity": "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.31.0.tgz", + "integrity": "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, "node_modules/@jmespath-community/jmespath": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jmespath-community/jmespath/-/jmespath-1.3.0.tgz", @@ -21121,6 +21163,31 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quickjs-emscripten": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.31.0.tgz", + "integrity": "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", + "@jitl/quickjs-wasmfile-release-sync": "0.31.0", + "quickjs-emscripten-core": "0.31.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.31.0.tgz", + "integrity": "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.31.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -25074,6 +25141,7 @@ "pg": "^8.23.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "quickjs-emscripten": "0.31.0", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "soap": "^1.13.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 6c40a8847..f3b321097 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -75,6 +75,7 @@ "pg": "^8.23.0", "pino": "^10.3.1", "pino-http": "^11.0.0", + "quickjs-emscripten": "0.31.0", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "soap": "^1.13.0", diff --git a/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql b/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql new file mode 100644 index 000000000..71dd784f6 --- /dev/null +++ b/packages/backend/prisma/migrations/20261017000000_add_codebound_scripts/migration.sql @@ -0,0 +1,31 @@ +-- A script's server must belong to its organization, including direct DB writes. +CREATE UNIQUE INDEX "mcp_server_configs_id_organization_id_key" + ON "mcp_server_configs"("id", "organization_id"); + +CREATE TABLE "codebound_scripts" ( + "id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "mcp_server_id" TEXT NOT NULL, + "name" VARCHAR(120) NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "code" TEXT NOT NULL, + "input" JSONB NOT NULL DEFAULT '{}', + "allowed_tools" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "codebound_scripts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "codebound_scripts_organization_id_updated_at_id_idx" + ON "codebound_scripts"("organization_id", "updated_at", "id"); +CREATE INDEX "codebound_scripts_mcp_server_id_organization_id_idx" + ON "codebound_scripts"("mcp_server_id", "organization_id"); + +ALTER TABLE "codebound_scripts" ADD CONSTRAINT "codebound_scripts_organization_id_fkey" + FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "codebound_scripts" ADD CONSTRAINT "codebound_scripts_mcp_server_id_organization_id_fkey" + FOREIGN KEY ("mcp_server_id", "organization_id") + REFERENCES "mcp_server_configs"("id", "organization_id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 5290f6be3..64d71ac1c 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -30,6 +30,7 @@ model Organization { members OrganizationMember[] connectors Connector[] mcpServers McpServerConfig[] + codeboundScripts CodeboundScript[] roles Role[] mcpApiKeys McpApiKey[] invitationTokens InvitationToken[] @@ -600,12 +601,38 @@ model McpServerConfig { apiKeys McpApiKey[] toolInvocations ToolInvocation[] kgSkills KgSkillSuggestion[] + codeboundScripts CodeboundScript[] + @@unique([id, organizationId]) @@unique([organizationId, slug]) @@index([organizationId]) @@map("mcp_server_configs") } +// ── Saved Codebound Scripts ──────────────────────────────────────────────── + +model CodeboundScript { + id String @id @default(cuid()) + organizationId String @map("organization_id") + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + mcpServerId String @map("mcp_server_id") + // The composite FK prevents binding a script to another tenant's server. + mcpServer McpServerConfig @relation(fields: [mcpServerId, organizationId], references: [id, organizationId], onDelete: Cascade) + + name String @db.VarChar(120) + description String @default("") @db.Text + code String @db.Text + input Json @default("{}") + allowedTools String[] @default([]) @map("allowed_tools") + revision Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([organizationId, updatedAt, id]) + @@index([mcpServerId, organizationId]) + @@map("codebound_scripts") +} + // ── MCP Server ↔ Connector (many-to-many) ─────────────────────────────────── model McpServerConnector { diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 17f8a2112..d7346fba9 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { mcpStrategy } from './mcp-server/mcp-strategy'; import { AuthModule } from './auth/auth.module'; import { ConnectorsModule } from './connectors/connectors.module'; import { McpServerModule } from './mcp-server/mcp-server.module'; +import { CodeboundModule } from './codebound/codebound.module'; import { UsersModule } from './users/users.module'; import { AuditModule } from './audit/audit.module'; @@ -161,6 +162,7 @@ if (useOAuth) { ConnectorsModule, AdaptersModule, McpServerModule, + CodeboundModule, OrganizationsModule, AuditModule, diff --git a/packages/backend/src/codebound/codebound.controller.ts b/packages/backend/src/codebound/codebound.controller.ts new file mode 100644 index 000000000..85c237308 --- /dev/null +++ b/packages/backend/src/codebound/codebound.controller.ts @@ -0,0 +1,42 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RunScriptDto } from './codebound.dto'; +import { CodeboundService, ScriptCaller } from './codebound.service'; + +@ApiTags('Codebound') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/codebound/servers/:serverId') +export class CodeboundController { + constructor(private readonly codebound: CodeboundService) {} + + @Get('tools') + @ApiOperation({ summary: 'List REST read tools available to a script run' }) + tools( + @Req() req: { user: ScriptCaller }, + @Param('serverId') serverId: string, + ) { + return this.codebound.listTools(req.user, serverId); + } + + @Post('run') + @ApiOperation({ + summary: 'Run an isolated JavaScript body with authorized connector reads', + }) + run( + @Req() req: { user: ScriptCaller }, + @Param('serverId') serverId: string, + @Body() dto: RunScriptDto, + ) { + return this.codebound.run(req.user, serverId, dto); + } +} diff --git a/packages/backend/src/codebound/codebound.dto.ts b/packages/backend/src/codebound/codebound.dto.ts new file mode 100644 index 000000000..71d42e4de --- /dev/null +++ b/packages/backend/src/codebound/codebound.dto.ts @@ -0,0 +1,65 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsObject, + IsInt, + IsString, + Matches, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class RunScriptDto { + @IsString() + @MinLength(1) + @MaxLength(32768) + code: string; + + @IsObject() + input: Record; + + @IsArray() + @ArrayMaxSize(20) + @ArrayUnique() + @IsString({ each: true }) + @MaxLength(200, { each: true }) + allowedTools: string[]; +} + +export class SaveScriptDto extends RunScriptDto { + @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @MinLength(1) + @MaxLength(120) + @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + name: string; + + @IsString() + @MaxLength(2000) + description: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + mcpServerId: string; +} + +export class UpdateScriptDto extends SaveScriptDto { + @IsInt() + @Min(1) + revision: number; +} + +export class RunSavedScriptDto { + @IsObject() + input: Record; + + // The version the user reviewed. A changed script must be re-opened before + // running it rather than silently executing a collaborator's new source. + @IsInt() + @Min(1) + revision: number; +} diff --git a/packages/backend/src/codebound/codebound.integration.spec.ts b/packages/backend/src/codebound/codebound.integration.spec.ts new file mode 100644 index 000000000..17e615ef2 --- /dev/null +++ b/packages/backend/src/codebound/codebound.integration.spec.ts @@ -0,0 +1,119 @@ +import { createServer, Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { RestEngine } from '../connectors/engines/rest.engine'; +import { DynamicMcpTools } from '../mcp-server/dynamic-mcp-tools'; +import { RegisteredTool } from '../mcp-server/tool-registry'; +import { CodeboundService } from './codebound.service'; + +describe('Codebound through the REST executor', () => { + let server: Server; + let origin: string; + const requests: string[] = []; + + beforeAll(async () => { + server = createServer((request, response) => { + requests.push(request.url ?? ''); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + id: 1, + title: 'Demo post', + body: 'Intermediate data', + private: 'Hidden by the response mapping', + }), + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll( + () => new Promise((resolve) => server?.close(() => resolve())), + ); + + it('makes a real connector read, filters its response, and audits both levels', async () => { + const tool: RegisteredTool = { + id: 'tool-a', + connectorId: 'connector-a', + organizationId: 'org-a', + name: 'setup_demo_get_post', + description: 'Read a demo post', + parameters: { type: 'object', properties: {} }, + connectorType: 'REST', + connectorConfig: { baseUrl: origin, authType: 'NONE' }, + endpointMapping: { method: 'GET', path: '/posts/1' }, + responseMapping: { transform: { exclude: ['private'] } }, + }; + const registry = { + getAllTools: () => [tool], + getTool: () => tool, + getToolForOrg: () => tool, + }; + const audit = { logInvocation: jest.fn().mockResolvedValue(undefined) }; + const security = { log: jest.fn().mockResolvedValue(undefined) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const prisma = { + mcpServerConfig: { + findFirst: jest.fn().mockResolvedValue({ + connectors: [{ connectorId: tool.connectorId }], + }), + }, + }; + const executor = new DynamicMcpTools( + registry as any, + audit as any, + {} as any, + license as any, + { isCloud: () => false } as any, + prisma as any, + new RestEngine({} as any, {} as any), + {} as any, + {} as any, + {} as any, + {} as any, + { scheduleObservationalIngest: jest.fn() } as any, + ); + const service = new CodeboundService( + prisma as any, + { getAllowedToolIds: async () => null } as any, + registry as any, + executor, + license as any, + security as any, + ); + const output = await service.run( + { sub: 'user-a', email: 'a@example.com', organizationId: 'org-a' }, + 'server-a', + { + allowedTools: [tool.name], + input: {}, + code: ` + const post = await tools.call('setup_demo_get_post', {}); + if ('private' in post) throw new Error('Response mapping was bypassed'); + return { id: post.id, title: post.title }; + `, + }, + ); + expect(output.result).toEqual({ id: 1, title: 'Demo post' }); + expect(requests).toEqual(['/posts/1']); + expect(audit.logInvocation).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org-a', + toolId: tool.id, + intent: `Codebound run ${output.runId}`, + }), + ); + expect(security.log).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + runId: output.runId, + status: 'SUCCESS', + }), + }), + ); + }); +}); diff --git a/packages/backend/src/codebound/codebound.module.ts b/packages/backend/src/codebound/codebound.module.ts new file mode 100644 index 000000000..0cf6bf5be --- /dev/null +++ b/packages/backend/src/codebound/codebound.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { McpServerModule } from '../mcp-server/mcp-server.module'; +import { LicenseModule } from '../license/license.module'; +import { CodeboundController } from './codebound.controller'; +import { CodeboundService } from './codebound.service'; +import { SavedScriptsController } from './saved-scripts.controller'; +import { SavedScriptsService } from './saved-scripts.service'; + +@Module({ + imports: [McpServerModule, LicenseModule], + controllers: [CodeboundController, SavedScriptsController], + providers: [CodeboundService, SavedScriptsService], +}) +export class CodeboundModule {} diff --git a/packages/backend/src/codebound/codebound.service.spec.ts b/packages/backend/src/codebound/codebound.service.spec.ts new file mode 100644 index 000000000..b2994852f --- /dev/null +++ b/packages/backend/src/codebound/codebound.service.spec.ts @@ -0,0 +1,241 @@ +import { CodeboundService } from './codebound.service'; +import { RegisteredTool } from '../mcp-server/tool-registry'; +import { validate } from 'class-validator'; +import { RunScriptDto } from './codebound.dto'; + +describe('Codebound authorization and execution', () => { + const caller = { + sub: 'user-a', + email: 'a@example.com', + organizationId: 'org-a', + }; + const tool = { + id: 'tool-a', + name: 'setup_demo_get_post', + connectorId: 'connector-a', + organizationId: 'org-a', + connectorType: 'REST', + description: 'Demo post', + parameters: { type: 'object', properties: {} }, + endpointMapping: { method: 'GET', path: '/posts/1' }, + connectorConfig: { baseUrl: 'https://example.com', authType: 'NONE' }, + } as RegisteredTool; + + function setup() { + const prisma = { + mcpServerConfig: { + findFirst: jest + .fn() + .mockResolvedValue({ connectors: [{ connectorId: 'connector-a' }] }), + }, + }; + const roles = { getAllowedToolIds: jest.fn().mockResolvedValue(null) }; + const registry = { getAllTools: jest.fn().mockReturnValue([tool]) }; + const executor = { + executeTool: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: '{"raw":"should not be used"}' }], + structured: { id: 1, title: 'Filtered', body: 'Data stays inside run' }, + }), + }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const security = { log: jest.fn().mockResolvedValue(undefined) }; + const service = new CodeboundService( + prisma as any, + roles as any, + registry as any, + executor as any, + license as any, + security as any, + ); + const dto = { + code: 'const p = await tools.call("setup_demo_get_post", {}); return {id: p.id, title: p.title};', + input: {}, + allowedTools: [tool.name], + }; + return { + service, + prisma, + roles, + registry, + executor, + license, + security, + dto, + }; + } + + it('passes trusted identity to the executor and uses its filtered structured result', async () => { + const { service, executor, security, prisma, dto } = setup(); + const result = await service.run(caller, 'server-a', dto); + expect(result.result).toEqual({ id: 1, title: 'Filtered' }); + expect(prisma.mcpServerConfig.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'server-a', organizationId: 'org-a', isActive: true }, + }), + ); + expect(executor.executeTool).toHaveBeenCalledWith( + tool.name, + {}, + expect.objectContaining({ + userId: 'user-a', + organizationId: 'org-a', + connectorIds: ['connector-a'], + mcpServerId: 'server-a', + skipCache: true, + intent: `Codebound run ${result.runId}`, + }), + ); + expect(security.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'CODEBOUND_RUN', + metadata: expect.objectContaining({ + runId: result.runId, + status: 'SUCCESS', + calls: 1, + }), + }), + ); + expect(JSON.stringify(security.log.mock.calls)).not.toContain('Filtered'); + }); + + it('does not return connector credentials in tool discovery', async () => { + const { service, registry } = setup(); + registry.getAllTools.mockReturnValue([ + { + ...tool, + connectorConfig: { + ...tool.connectorConfig, + envVars: { API_KEY: 'sensitive' }, + }, + }, + ]); + expect( + JSON.stringify(await service.listTools(caller, 'server-a')), + ).not.toContain('sensitive'); + }); + + it('refuses a guessed server in another workspace', async () => { + const { service, prisma, executor, dto } = setup(); + prisma.mcpServerConfig.findFirst.mockResolvedValue(null); + await expect(service.run(caller, 'server-b', dto)).rejects.toThrow( + 'not found', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it.each(['tenant', 'role', 'assignment', 'write', 'not-ready', 'allowlist'])( + 'blocks %s violations before dispatch', + async (reason) => { + const { service, roles, registry, executor, dto } = setup(); + if (reason === 'tenant') + registry.getAllTools.mockReturnValue([ + { ...tool, organizationId: 'org-b' }, + ]); + if (reason === 'role') roles.getAllowedToolIds.mockResolvedValue([]); + if (reason === 'assignment') + registry.getAllTools.mockReturnValue([ + { ...tool, connectorId: 'connector-b' }, + ]); + if (reason === 'write') + registry.getAllTools.mockReturnValue([ + { + ...tool, + endpointMapping: { method: 'POST' }, + annotations: { readOnlyHint: true }, + }, + ]); + if (reason === 'not-ready') + registry.getAllTools.mockReturnValue([ + { ...tool, setupStatus: 'needs_authorization' }, + ]); + if (reason === 'allowlist') dto.allowedTools = []; + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow(); + expect(executor.executeTool).not.toHaveBeenCalled(); + }, + ); + + it('rechecks roles after each nested call', async () => { + const { service, roles, executor, dto } = setup(); + roles.getAllowedToolIds + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue([]); + dto.code = + 'await tools.call("setup_demo_get_post", {}); return await tools.call("setup_demo_get_post", {});'; + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'access is denied', + ); + expect(executor.executeTool).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid tool parameters before dispatch', async () => { + const { service, registry, executor, dto } = setup(); + registry.getAllTools.mockReturnValue([ + { + ...tool, + parameters: { + type: 'object', + properties: { id: { type: 'number' } }, + required: ['id'], + }, + }, + ]); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'input schema', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it('keeps the existing license gate', async () => { + const { service, license, executor, dto } = setup(); + license.checkLicenseActive.mockRejectedValue(new Error('License inactive')); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'License inactive', + ); + expect(executor.executeTool).not.toHaveBeenCalled(); + }); + + it('does not disclose connector error payloads', async () => { + const { service, executor, dto } = setup(); + executor.executeTool.mockResolvedValue({ + isError: true, + content: [{ type: 'text', text: 'secret' }], + } as any); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow( + 'Connector call failed.', + ); + }); + + it('limits simultaneous runs per workspace and recovers after errors', async () => { + const { service, dto } = setup(); + const first = service.run(caller, 'server-a', { + ...dto, + code: 'throw new Error("test");', + }); + await expect(service.run(caller, 'server-a', dto)).rejects.toThrow('busy'); + await expect(first).rejects.toThrow('test'); + expect((await service.run(caller, 'server-a', dto)).result).toEqual({ + id: 1, + title: 'Filtered', + }); + }); + + it('validates the HTTP request shape with DTO decorators', async () => { + const valid = Object.assign(new RunScriptDto(), { + code: 'return input;', + input: {}, + allowedTools: [], + }); + expect(await validate(valid)).toEqual([]); + const invalid = Object.assign(new RunScriptDto(), { + code: '', + input: [], + allowedTools: ['a', 'a'], + }); + expect( + (await validate(invalid)).map((error) => error.property).sort(), + ).toEqual(['allowedTools', 'code', 'input']); + }); +}); diff --git a/packages/backend/src/codebound/codebound.service.ts b/packages/backend/src/codebound/codebound.service.ts new file mode 100644 index 000000000..892a348b6 --- /dev/null +++ b/packages/backend/src/codebound/codebound.service.ts @@ -0,0 +1,222 @@ +import { + BadRequestException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import { PrismaService } from '../common/prisma.service'; +import { RolesService } from '../roles/roles.service'; +import { DynamicMcpTools } from '../mcp-server/dynamic-mcp-tools'; +import { + isListable, + RegisteredTool, + ToolRegistry, +} from '../mcp-server/tool-registry'; +import { deriveToolAnnotations } from '../mcp-server/tool-annotations'; +import { + jsonSchemaToZodShape, + stripEnvVarParams, +} from '../mcp-server/tool-schema.util'; +import { LicenseGuardService } from '../license/license-guard.service'; +import { SecurityEventService } from '../audit/security-event.service'; +import { RunScriptDto } from './codebound.dto'; +import { RUN_TIMEOUT_MS, runSandbox, ScriptError } from './sandbox'; + +export interface ScriptCaller { + sub: string; + email: string; + organizationId: string; + role?: string; +} + +@Injectable() +export class CodeboundService { + private readonly activeOrganizations = new Set(); + + constructor( + private readonly prisma: PrismaService, + private readonly roles: RolesService, + private readonly registry: ToolRegistry, + private readonly executor: DynamicMcpTools, + private readonly license: LicenseGuardService, + private readonly securityEvents: SecurityEventService, + ) {} + + private async toolsFor( + caller: ScriptCaller, + serverId: string, + ): Promise { + // Tenant filtering happens in the query, before resolving any connectors. + const server = await this.prisma.mcpServerConfig.findFirst({ + where: { + id: serverId, + organizationId: caller.organizationId, + isActive: true, + }, + select: { + connectors: { + where: { + connector: { + organizationId: caller.organizationId, + isActive: true, + }, + }, + select: { connectorId: true }, + }, + }, + }); + if (!server) throw new NotFoundException('Active MCP server not found.'); + const allowedIds = await this.roles.getAllowedToolIds( + caller.sub, + caller.organizationId, + ); + const connectorIds = new Set( + server.connectors.map((entry) => entry.connectorId), + ); + return this.registry.getAllTools().filter( + (tool) => + tool.organizationId === caller.organizationId && + connectorIds.has(tool.connectorId) && + (allowedIds === null || allowedIds.includes(tool.id)) && + isListable(tool) && + // First milestone: only REST reads/static responses. Hints alone are + // advisory, so also check the actual configured transport and method. + tool.connectorType === 'REST' && + ['GET', 'HEAD', 'static'].includes(tool.endpointMapping.method) && + deriveToolAnnotations(tool).readOnlyHint === true, + ); + } + + async listTools(caller: ScriptCaller, serverId: string) { + const tools = await this.toolsFor(caller, serverId); + return tools + .filter( + (tool, index) => + tools.findIndex((entry) => entry.name === tool.name) === index, + ) + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: stripEnvVarParams( + tool.parameters, + tool.connectorConfig.envVars, + ), + outputSchema: tool.outputSchema ?? null, + })); + } + + async run( + caller: ScriptCaller, + serverId: string, + dto: RunScriptDto, + savedScript?: { id: string; revision: number }, + ) { + // Per-process backpressure, also bounding uninterruptible upstream reads. + if ( + this.activeOrganizations.has(caller.organizationId) || + this.activeOrganizations.size >= 4 + ) { + throw new HttpException('Script runner is busy. Try again shortly.', 429); + } + this.activeOrganizations.add(caller.organizationId); + let runningCalls = 0; + let scriptFinished = false; + const release = () => { + if (scriptFinished && runningCalls === 0) + this.activeOrganizations.delete(caller.organizationId); + }; + const runId = randomUUID(); + const started = Date.now(); + let status = 'ERROR'; + let calls = 0; + try { + await this.license.checkLicenseActive(caller.organizationId); + await this.toolsFor(caller, serverId); + const allowlist = new Set(dto.allowedTools); + const output = await runSandbox( + dto.code, + dto.input, + async (name, args) => { + runningCalls++; + try { + if (!allowlist.has(name)) + throw new ScriptError("Tool is not in this run's allowlist."); + // Recheck current assignments and permissions for EVERY nested call. + const tool = (await this.toolsFor(caller, serverId)).find( + (entry) => entry.name === name, + ); + if (!tool) + throw new ScriptError('Tool is unavailable or access is denied.'); + if (scriptFinished || Date.now() >= started + RUN_TIMEOUT_MS) + throw new ScriptError('Script time limit exceeded.'); + const schema = stripEnvVarParams( + tool.parameters, + tool.connectorConfig.envVars, + ); + const parsed = z + .object(jsonSchemaToZodShape(schema)) + .safeParse(args); + if (!parsed.success) + throw new ScriptError( + 'Tool arguments do not match its input schema.', + ); + calls++; + const result = await this.executor.executeTool(name, parsed.data, { + userId: caller.sub, + userEmail: caller.email, + organizationId: caller.organizationId, + authMethod: 'jwt', + mcpServerId: serverId, + connectorIds: [tool.connectorId], + // Always audit script reads and avoid shared response cache entries. + skipCache: true, + intent: `Codebound run ${runId}`, + }); + if (result.isError) + throw new ScriptError( + 'Connector call failed. Check the tool invocation log.', + ); + if (result.structured !== undefined) return result.structured; + try { + return JSON.parse(result.content[0].text); + } catch { + throw new ScriptError('Connector did not return JSON data.'); + } + } finally { + runningCalls--; + release(); + } + }, + ); + status = 'SUCCESS'; + return { runId, ...output, durationMs: Date.now() - started }; + } catch (error) { + if (error instanceof ScriptError) + throw new BadRequestException(error.message); + throw error; + } finally { + scriptFinished = true; + release(); + // Record metadata only: neither source, inputs, intermediate data, nor + // results belong in the parent security event. + await this.securityEvents.log({ + event: 'CODEBOUND_RUN', + actorType: 'USER', + actorUserId: caller.sub, + organizationId: caller.organizationId, + metadata: { + runId, + serverId, + status, + calls, + durationMs: Date.now() - started, + ...(savedScript + ? { savedScriptId: savedScript.id, revision: savedScript.revision } + : {}), + }, + }); + } + } +} diff --git a/packages/backend/src/codebound/sandbox-worker.ts b/packages/backend/src/codebound/sandbox-worker.ts new file mode 100644 index 000000000..7c0ced908 --- /dev/null +++ b/packages/backend/src/codebound/sandbox-worker.ts @@ -0,0 +1,95 @@ +// The worker runs only this trusted harness. User source is evaluated solely +// inside QuickJS/WASM; no Node object or function crosses that boundary. +export const SANDBOX_WORKER = String.raw` +const { parentPort, workerData } = require('node:worker_threads'); +const { getQuickJS } = require(workerData.quickjsPath); + +(async () => { + const QuickJS = await getQuickJS(); + const runtime = QuickJS.newRuntime(); + runtime.setMemoryLimit(64 * 1024 * 1024); + runtime.setMaxStackSize(512 * 1024); + runtime.setInterruptHandler(() => Date.now() >= workerData.deadline); + // No module loader, network, process, filesystem, timers, or Node globals. + const vm = runtime.newContext(); + const pending = new Map(); + let sequence = 0; + let finished = false; + const finish = (message) => { + if (finished) return; + finished = true; + parentPort.postMessage(message); + }; + const pump = () => { + if (finished) return; + const jobs = runtime.executePendingJobs(); + if (jobs.error) { + jobs.error.dispose(); + finish({ type: 'error', error: 'Script execution failed or exceeded its resource limit.' }); + } + }; + const call = vm.newFunction('call', (payload) => { + if (vm.typeof(payload) !== 'string') throw new Error('Invalid tool call.'); + const text = vm.getString(payload); + if (Buffer.byteLength(text) > 65536) throw new Error('Tool arguments exceed 64 KiB.'); + const deferred = vm.newPromise(); + const id = ++sequence; + pending.set(id, deferred); + parentPort.postMessage({ type: 'call', id, payload: text }); + return deferred.handle; + }); + const done = vm.newFunction('done', (result) => { + if (vm.typeof(result) !== 'string') { + finish({ type: 'error', error: 'Return a JSON value.' }); + } else { + const json = vm.getString(result); + if (Buffer.byteLength(json) > 32768) finish({ type: 'error', error: 'Script output exceeds 32 KiB.' }); + else finish({ type: 'result', json }); + } + }); + const fail = vm.newFunction('fail', (message) => { + const text = vm.typeof(message) === 'string' ? vm.getString(message) : 'Script failed.'; + finish({ type: 'error', error: text.slice(0, 500) }); + }); + vm.setProp(vm.global, '__call', call); + vm.setProp(vm.global, '__done', done); + vm.setProp(vm.global, '__fail', fail); + call.dispose(); done.dispose(); fail.dispose(); + parentPort.on('message', (message) => { + if (finished) return; + const deferred = pending.get(message.id); + if (!deferred) return; + pending.delete(message.id); + // Resolve with serialized data; guest JSON.parse owns the resulting object. + const value = vm.newString(message.json); + deferred.resolve(value); + value.dispose(); + deferred.dispose(); + pump(); + }); + // Capture the bridge and JSON intrinsics before running user code, then hide + // bridge globals. User code receives only input and tools.call(name, args). + const bootstrap = vm.evalCode( + '(function(call, done, fail, parse, stringify) {' + + 'delete globalThis.__call; delete globalThis.__done; delete globalThis.__fail;' + + 'const tools = Object.freeze({call: async (name, args) => {' + + 'const reply = parse(await call(stringify({name, args})));' + + 'if (!reply.ok) throw new Error(reply.error); return reply.value;' + + '}});' + + 'const input = parse(' + JSON.stringify(workerData.inputJson) + ');' + + '(async function(input, tools) { "use strict";\n' + workerData.code + + '\n})(input, tools).then(value => { try { done(stringify(value)); }' + + 'catch (_) { fail("Return a JSON-serializable value."); } },' + + 'error => { try { fail(String(error)); } catch (_) { fail("Script failed."); } });' + + '})(__call, __done, __fail, JSON.parse, JSON.stringify);', + 'codebound.js' + ); + if (bootstrap.error) { + bootstrap.error.dispose(); + finish({ type: 'error', error: 'Script syntax error or resource limit exceeded.' }); + } else { + bootstrap.value.dispose(); + pump(); + } +})().catch(() => parentPort.postMessage({ type: 'error', error: 'Sandbox could not start.' })); +`; diff --git a/packages/backend/src/codebound/sandbox.spec.ts b/packages/backend/src/codebound/sandbox.spec.ts new file mode 100644 index 000000000..6d478ea25 --- /dev/null +++ b/packages/backend/src/codebound/sandbox.spec.ts @@ -0,0 +1,175 @@ +import { runSandbox } from './sandbox'; + +describe('Codebound QuickJS sandbox', () => { + const call = jest.fn(async () => ({ + id: 1, + title: 'Demo', + body: 'Large intermediate response', + })); + beforeEach(() => call.mockClear()); + + it('awaits a connector and returns only the selected fields', async () => { + const result = await runSandbox( + ` + const post = await tools.call('setup_demo_get_post', input); + return { id: post.id, title: post.title }; + `, + { postId: 1 }, + call, + ); + expect(result).toEqual({ result: { id: 1, title: 'Demo' }, calls: 1 }); + expect(call).toHaveBeenCalledWith('setup_demo_get_post', { postId: 1 }); + }); + + it('supports sequential calls and JSON parameters', async () => { + expect( + await runSandbox( + ` + const first = await tools.call('a', {}); + const second = await tools.call('b', {}); + return first.id + second.id + input.extra; + `, + { extra: 3 }, + call, + ), + ).toEqual({ result: 5, calls: 2 }); + }); + + it('provides no host capabilities, including through a Function constructor', async () => { + const { result } = await runSandbox( + `return { + process: typeof process, require: typeof require, fetch: typeof fetch, + Buffer: typeof Buffer, timers: typeof setTimeout, + bridge: typeof __call, + escape: Function('return typeof process')() + };`, + {}, + call, + ); + expect(Object.values(result as object)).toEqual(Array(7).fill('undefined')); + await expect( + runSandbox(`return import('node:fs');`, {}, call), + ).rejects.toThrow(); + }); + + it('does not share globals between concurrent or sequential runs', async () => { + const results = await Promise.all([ + runSandbox('globalThis.secret = "tenant-a"; return secret;', {}, call), + runSandbox('return typeof secret;', {}, call), + ]); + expect(results.map((item) => item.result)).toEqual([ + 'tenant-a', + 'undefined', + ]); + expect((await runSandbox('return typeof secret;', {}, call)).result).toBe( + 'undefined', + ); + }); + + it.each([ + ['synchronous loop', 'while (true) {}'], + ['loop after await', 'await tools.call("a", {}); while (true) {}'], + ['microtask loop', 'while (true) { await Promise.resolve(); }'], + ['unresolved promise', 'await new Promise(() => {});'], + ])('stops a %s and permits the next run', async (_name, code) => { + await expect(runSandbox(code, {}, call, 300)).rejects.toThrow(); + expect((await runSandbox('return 42;', {}, call)).result).toBe(42); + }); + + it('stops excessive allocation', async () => { + await expect( + runSandbox( + 'const a = []; while (true) a.push(new Array(100000).fill(1));', + {}, + call, + ), + ).rejects.toThrow(); + }); + + it('limits tool calls even if the guest catches errors', async () => { + await expect( + runSandbox( + `for (let i = 0; i < 30; i++) { + try { await tools.call('a', {}); } catch (_) {} + } return true;`, + {}, + call, + ), + ).rejects.toThrow('20 tool call limit'); + expect(call).toHaveBeenCalledTimes(20); + }); + + it('requires sequential calls', async () => { + await expect( + runSandbox( + `return Promise.all([tools.call('a', {}), tools.call('b', {})]);`, + {}, + async () => new Promise(() => {}), + ), + ).rejects.toThrow('Await each tool call'); + }); + + it('bounds output and cumulative intermediate data', async () => { + await expect( + runSandbox('return "x".repeat(32769);', {}, call), + ).rejects.toThrow('32 KiB'); + await expect( + runSandbox('return await tools.call("a", {});', {}, async () => + 'x'.repeat(1024 * 1024), + ), + ).rejects.toThrow('1 MiB'); + }); + + it('rejects oversized arguments and input', async () => { + await expect( + runSandbox( + 'return await tools.call("a", {x: "é".repeat(40000)});', + {}, + call, + ), + ).rejects.toThrow('64 KiB'); + expect(() => + runSandbox('return input;', { data: 'x'.repeat(65536) }, call), + ).toThrow('64 KiB'); + }); + + it('rejects invalid JSON outputs and syntax', async () => { + for (const code of [ + 'return undefined;', + 'return 1n;', + 'const a = {}; a.a = a; return a;', + 'const = nope;', + ]) { + await expect(runSandbox(code, {}, call)).rejects.toThrow(); + } + }); + + it('does not expose raw host errors', async () => { + await expect( + runSandbox('return await tools.call("a", {});', {}, async () => { + throw new Error('Credential secret-value'); + }), + ).rejects.toThrow('Connector call failed.'); + }); + + it('discards late results after timeout and makes no subsequent calls', async () => { + let complete!: (value: unknown) => void; + const slow = jest.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + await expect( + runSandbox( + 'await tools.call("a", {}); return tools.call("b", {});', + {}, + slow, + 300, + ), + ).rejects.toThrow(); + complete({ id: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(slow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/src/codebound/sandbox.ts b/packages/backend/src/codebound/sandbox.ts new file mode 100644 index 000000000..2b64e0180 --- /dev/null +++ b/packages/backend/src/codebound/sandbox.ts @@ -0,0 +1,126 @@ +import { Worker } from 'node:worker_threads'; +import { SANDBOX_WORKER } from './sandbox-worker'; + +export const RUN_TIMEOUT_MS = 5000; +export const MAX_CALLS = 20; +export const MAX_RESPONSE_BYTES = 1024 * 1024; + +export class ScriptError extends Error {} + +/** Fresh worker and fresh QuickJS heap per run, including after failures. */ +export function runSandbox( + code: string, + input: Record, + callTool: (name: string, args: Record) => Promise, + timeoutMs = RUN_TIMEOUT_MS, +): Promise<{ result: unknown; calls: number }> { + const inputJson = JSON.stringify(input); + if (Buffer.byteLength(inputJson) > 65536) + throw new ScriptError('Input exceeds 64 KiB.'); + return new Promise((resolve, reject) => { + const worker = new Worker(SANDBOX_WORKER, { + eval: true, + workerData: { + code, + inputJson, + deadline: Date.now() + timeoutMs, + quickjsPath: require.resolve('quickjs-emscripten'), + }, + resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 }, + // Do not inherit ts-node loaders or other application startup flags. + execArgv: [], + }); + let settled = false; + let calls = 0; + let responseBytes = 0; + let inFlight = false; + const finish = (error?: ScriptError, result?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + void worker.terminate().then(() => { + if (error) reject(error); + else resolve({ result, calls }); + }); + }; + const timer = setTimeout( + () => finish(new ScriptError('Script exceeded the 5 second time limit.')), + timeoutMs, + ); + worker.on('error', () => + finish( + new ScriptError('Sandbox resource limit exceeded or worker failed.'), + ), + ); + worker.on('exit', () => { + if (!settled) finish(new ScriptError('Sandbox exited without a result.')); + }); + worker.on('message', async (message) => { + if (settled) return; + if (message.type === 'result') { + try { + finish(undefined, JSON.parse(message.json)); + } catch { + finish(new ScriptError('Return a JSON value.')); + } + return; + } + if (message.type === 'error') { + finish(new ScriptError(message.error)); + return; + } + if (message.type !== 'call') return; + if (++calls > MAX_CALLS) + return finish( + new ScriptError('Script exceeded the 20 tool call limit.'), + ); + // Existing connector engines cannot cancel a request. Limit the prototype + // to sequential reads; after termination, never dispatch or deliver more. + if (inFlight) + return finish( + new ScriptError('Await each tool call before starting the next.'), + ); + inFlight = true; + let reply: { ok: boolean; value?: unknown; error?: string }; + try { + const { name, args } = JSON.parse(message.payload); + if ( + typeof name !== 'string' || + !args || + typeof args !== 'object' || + Array.isArray(args) + ) { + throw new ScriptError( + 'tools.call requires a tool name and an arguments object.', + ); + } + reply = { ok: true, value: await callTool(name, args) }; + } catch (error) { + // Connector failures must never disclose credentials or raw exceptions. + reply = { + ok: false, + error: + error instanceof ScriptError + ? error.message + : 'Connector call failed.', + }; + } finally { + inFlight = false; + } + if (settled) return; + try { + const json = JSON.stringify(reply); + responseBytes += Buffer.byteLength(json); + if (responseBytes > MAX_RESPONSE_BYTES) + return finish( + new ScriptError('Tool responses exceed the 1 MiB run limit.'), + ); + worker.postMessage({ id: message.id, json }); + } catch { + finish( + new ScriptError('Connector returned data that cannot be serialized.'), + ); + } + }); + }); +} diff --git a/packages/backend/src/codebound/saved-scripts.controller.ts b/packages/backend/src/codebound/saved-scripts.controller.ts new file mode 100644 index 000000000..a2e15e1ed --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginationQueryDto } from '../common/pagination.dto'; +import { ScriptCaller } from './codebound.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; +import { SavedScriptsService } from './saved-scripts.service'; + +@ApiTags('Codebound') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/codebound/scripts') +export class SavedScriptsController { + constructor(private readonly scripts: SavedScriptsService) {} + + @Get() + @ApiOperation({ summary: 'List saved scripts in the current workspace' }) + list( + @Req() req: { user: ScriptCaller }, + @Query() pagination: PaginationQueryDto, + ) { + return this.scripts.list(req.user, pagination); + } + + @Post() + @ApiOperation({ summary: 'Save a reusable script in the current workspace' }) + create(@Req() req: { user: ScriptCaller }, @Body() dto: SaveScriptDto) { + return this.scripts.create(req.user, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Open a saved script' }) + get(@Req() req: { user: ScriptCaller }, @Param('id') id: string) { + return this.scripts.get(req.user, id); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a saved script at its reviewed revision' }) + update( + @Req() req: { user: ScriptCaller }, + @Param('id') id: string, + @Body() dto: UpdateScriptDto, + ) { + return this.scripts.update(req.user, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a saved script' }) + delete(@Req() req: { user: ScriptCaller }, @Param('id') id: string) { + return this.scripts.delete(req.user, id); + } + + @Post(':id/run') + @ApiOperation({ + summary: 'Execute a saved script using current caller permissions', + }) + run( + @Req() req: { user: ScriptCaller }, + @Param('id') id: string, + @Body() dto: RunSavedScriptDto, + ) { + return this.scripts.run(req.user, id, dto); + } +} diff --git a/packages/backend/src/codebound/saved-scripts.db.spec.ts b/packages/backend/src/codebound/saved-scripts.db.spec.ts new file mode 100644 index 000000000..6f29d280f --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.db.spec.ts @@ -0,0 +1,213 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma/client'; +import { SavedScriptsService } from './saved-scripts.service'; +import { ScriptCaller } from './codebound.service'; + +// Opt in against the local development DB after applying migrations. These +// tests create their own tenant fixtures and remove only those fixtures. +const databaseTests = + process.env.RUN_CODEBOUND_DB === '1' ? describe : describe.skip; +databaseTests('Saved scripts in PostgreSQL', () => { + let db: PrismaClient; + let service: SavedScriptsService; + let a: ScriptCaller; + let b: ScriptCaller; + let serverA: string; + let serverB: string; + const organizations: string[] = []; + const users: string[] = []; + const runner = { run: jest.fn().mockResolvedValue({ result: { ok: true } }) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const audit = { log: jest.fn().mockResolvedValue(undefined) }; + const client = () => + new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); + const createService = (prisma: PrismaClient) => + new SavedScriptsService( + prisma as any, + runner as any, + license as any, + audit as any, + ); + const body = () => ({ + name: 'Persistent summary', + description: 'Database test fixture', + mcpServerId: serverA, + code: 'return { answer: input.value };', + input: { value: 42 }, + allowedTools: [], + }); + + beforeAll(async () => { + const url = new URL(process.env.DATABASE_URL ?? ''); + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + url.port !== '5433' + ) { + throw new Error( + 'RUN_CODEBOUND_DB requires local development PostgreSQL on port 5433.', + ); + } + db = client(); + await db.$connect(); + const seed = async () => { + const org = await db.organization.create({ + data: { name: `Codebound test ${randomUUID()}` }, + }); + organizations.push(org.id); + const user = await db.user.create({ + data: { + email: `codebound-${randomUUID()}@example.invalid`, + role: 'ADMIN', + organizationId: org.id, + }, + }); + users.push(user.id); + const server = await db.mcpServerConfig.create({ + data: { userId: user.id, organizationId: org.id, name: 'Test server' }, + }); + return { + caller: { + sub: user.id, + email: user.email, + organizationId: org.id, + role: 'ADMIN', + }, + server: server.id, + }; + }; + const first = await seed(); + const second = await seed(); + a = first.caller; + serverA = first.server; + b = second.caller; + serverB = second.server; + service = createService(db); + }); + + afterEach(async () => { + if (db && organizations.length) + await db.codeboundScript.deleteMany({ + where: { organizationId: { in: organizations } }, + }); + }); + afterAll(async () => { + if (!db) return; + try { + await db.user.deleteMany({ + where: { id: { in: users }, organizationId: { in: organizations } }, + }); + await db.organization.deleteMany({ + where: { id: { in: organizations } }, + }); + } finally { + await db.$disconnect(); + } + }); + + it('persists code, parameters and capabilities across database connections', async () => { + const saved = await service.create(a, body()); + const secondConnection = client(); + try { + const reopened = await createService(secondConnection).get(a, saved.id); + expect(reopened).toEqual( + expect.objectContaining({ ...body(), revision: 1 }), + ); + expect((await service.list(a, {})).total).toBe(1); + } finally { + await secondConnection.$disconnect(); + } + }); + + it('denies cross-tenant list/open/edit/delete/run even with a known ID', async () => { + const saved = await service.create(a, body()); + expect(await service.list(b, {})).toEqual({ items: [], total: 0 }); + await expect(service.get(b, saved.id)).rejects.toThrow('Script not found'); + await expect( + service.update(b, saved.id, { + ...body(), + mcpServerId: serverB, + revision: 1, + }), + ).rejects.toThrow('Script not found'); + await expect(service.delete(b, saved.id)).rejects.toThrow( + 'Script not found', + ); + await expect( + service.run(b, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Script not found'); + expect((await service.get(a, saved.id)).revision).toBe(1); + }); + + it('accepts one of two concurrent writes and rejects the stale writer', async () => { + const saved = await service.create(a, body()); + const results = await Promise.allSettled([ + service.update(a, saved.id, { + ...body(), + code: 'return 1;', + revision: 1, + }), + service.update(a, saved.id, { + ...body(), + code: 'return 2;', + revision: 1, + }), + ]); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const failure = results.find( + (result) => result.status === 'rejected', + ) as PromiseRejectedResult; + expect(failure.reason.getStatus()).toBe(409); + const current = await service.get(a, saved.id); + expect(current.revision).toBe(2); + await expect( + service.run(a, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Reopen it before running'); + }); + + it('enforces the tenant/server boundary in the database as well as the API', async () => { + await expect( + service.create(a, { ...body(), mcpServerId: serverB }), + ).rejects.toThrow('Active MCP server not found'); + await expect( + db.codeboundScript.create({ + data: { + ...body(), + organizationId: a.organizationId, + mcpServerId: serverB, + }, + }), + ).rejects.toMatchObject({ code: 'P2003' }); + }); + + it('deletes scripts permanently and cascades when their server is removed', async () => { + const saved = await service.create(a, body()); + await service.delete(a, saved.id); + await expect(service.get(a, saved.id)).rejects.toThrow('Script not found'); + await expect( + service.run(a, saved.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Script not found'); + const temporary = await db.mcpServerConfig.create({ + data: { + userId: a.sub, + organizationId: a.organizationId, + name: 'Temporary test server', + slug: randomUUID(), + }, + }); + const linked = await service.create(a, { + ...body(), + mcpServerId: temporary.id, + }); + await db.mcpServerConfig.deleteMany({ + where: { id: temporary.id, organizationId: a.organizationId }, + }); + await expect(service.get(a, linked.id)).rejects.toThrow('Script not found'); + }); +}); diff --git a/packages/backend/src/codebound/saved-scripts.service.spec.ts b/packages/backend/src/codebound/saved-scripts.service.spec.ts new file mode 100644 index 000000000..8d6e025f6 --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.service.spec.ts @@ -0,0 +1,278 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { SavedScriptsService } from './saved-scripts.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; + +describe('Saved Codebound scripts', () => { + const caller = { + sub: 'user-a', + email: 'a@example.com', + organizationId: 'org-a', + role: 'EDITOR', + }; + const dto: SaveScriptDto = { + name: 'Post summary', + description: 'A reusable script', + mcpServerId: 'server-a', + code: 'return input;', + input: { limit: 1 }, + allowedTools: ['get_post'], + }; + const script = { + ...dto, + id: 'script-a', + revision: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + + function setup() { + const prisma = { + mcpServerConfig: { + findFirst: jest.fn().mockResolvedValue({ id: 'server-a' }), + }, + codeboundScript: { + findFirst: jest.fn().mockResolvedValue(script), + findFirstOrThrow: jest + .fn() + .mockResolvedValue({ ...script, revision: 2 }), + findMany: jest.fn().mockResolvedValue([script]), + count: jest.fn().mockResolvedValue(1), + create: jest.fn().mockResolvedValue(script), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $transaction: jest.fn(), + }; + prisma.$transaction.mockImplementation(async (work) => + typeof work === 'function' ? work(prisma) : Promise.all(work), + ); + const runner = { run: jest.fn().mockResolvedValue({ result: 42 }) }; + const license = { + checkLicenseActive: jest.fn().mockResolvedValue(undefined), + }; + const audit = { log: jest.fn().mockResolvedValue(undefined) }; + const service = new SavedScriptsService( + prisma as any, + runner as any, + license as any, + audit as any, + ); + return { prisma, runner, license, audit, service }; + } + + it('saves only explicitly accepted fields in the authenticated workspace', async () => { + const { service, prisma, audit } = setup(); + await service.create(caller, { + ...dto, + name: ' Post summary ', + organizationId: 'org-b', + } as SaveScriptDto); + expect(prisma.codeboundScript.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { ...dto, organizationId: 'org-a' }, + }), + ); + expect(prisma.mcpServerConfig.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'server-a', organizationId: 'org-a', isActive: true }, + }), + ); + expect(audit.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'CODEBOUND_SCRIPT_CREATED', + organizationId: 'org-a', + actorUserId: 'user-a', + }), + ); + expect(JSON.stringify(audit.log.mock.calls)).not.toContain('return input'); + }); + + it('lists summaries with bounded pagination and a tenant-scoped count', async () => { + const { service, prisma } = setup(); + expect(await service.list(caller, {})).toEqual({ + items: [script], + total: 1, + }); + expect(prisma.codeboundScript.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org-a' }, + skip: 0, + take: 20, + }), + ); + expect( + prisma.codeboundScript.findMany.mock.calls[0][0].select.code, + ).toBeUndefined(); + expect(prisma.codeboundScript.count).toHaveBeenCalledWith({ + where: { organizationId: 'org-a' }, + }); + }); + + it('does not open a guessed script from another tenant', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.findFirst.mockResolvedValue(null); + await expect(service.get(caller, 'other-id')).rejects.toThrow( + 'Script not found', + ); + expect(prisma.codeboundScript.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'other-id', organizationId: 'org-a' }, + }), + ); + }); + + it.each(['create', 'update', 'delete'] as const)( + 'denies viewer %s before touching storage', + async (operation) => { + const { service, prisma } = setup(); + const viewer = { ...caller, role: 'VIEWER' }; + const action = + operation === 'create' + ? service.create(viewer, dto) + : operation === 'update' + ? service.update(viewer, 'script-a', { ...dto, revision: 1 }) + : service.delete(viewer, 'script-a'); + await expect(action).rejects.toThrow('editor or administrator'); + expect(prisma.codeboundScript.findFirst).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.updateMany).not.toHaveBeenCalled(); + expect(prisma.codeboundScript.deleteMany).not.toHaveBeenCalled(); + }, + ); + + it('refuses an inactive or foreign server without writing', async () => { + const { service, prisma } = setup(); + prisma.mcpServerConfig.findFirst.mockResolvedValue(null); + await expect(service.create(caller, dto)).rejects.toThrow( + 'Active MCP server not found', + ); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + }); + + it('updates atomically at the reviewed revision', async () => { + const { service, prisma } = setup(); + expect( + (await service.update(caller, 'script-a', { ...dto, revision: 1 })) + .revision, + ).toBe(2); + expect(prisma.codeboundScript.updateMany).toHaveBeenCalledWith({ + where: { id: 'script-a', organizationId: 'org-a', revision: 1 }, + data: { ...dto, revision: { increment: 1 } }, + }); + expect(prisma.codeboundScript.findFirstOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'script-a', organizationId: 'org-a' }, + }), + ); + }); + + it('rejects stale writes without returning another revision as a successful save', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.updateMany.mockResolvedValue({ count: 0 }); + await expect( + service.update(caller, 'script-a', { ...dto, revision: 1 }), + ).rejects.toThrow('Reopen it before saving'); + expect(prisma.codeboundScript.findFirstOrThrow).not.toHaveBeenCalled(); + }); + + it('returns not found when a script disappears during an update', async () => { + const { service, prisma } = setup(); + prisma.codeboundScript.updateMany.mockResolvedValue({ count: 0 }); + prisma.codeboundScript.findFirst + .mockResolvedValueOnce(script) + .mockResolvedValueOnce(null); + await expect( + service.update(caller, 'script-a', { ...dto, revision: 1 }), + ).rejects.toThrow('Script not found'); + }); + + it('scopes deletion by organization and makes a repeated delete fail', async () => { + const { service, prisma, audit } = setup(); + expect(await service.delete(caller, 'script-a')).toEqual({ deleted: true }); + expect(prisma.codeboundScript.deleteMany).toHaveBeenCalledWith({ + where: { id: 'script-a', organizationId: 'org-a' }, + }); + expect(audit.log).toHaveBeenCalledWith( + expect.objectContaining({ event: 'CODEBOUND_SCRIPT_DELETED' }), + ); + prisma.codeboundScript.deleteMany.mockResolvedValue({ count: 0 }); + await expect(service.delete(caller, 'script-a')).rejects.toThrow( + 'Script not found', + ); + }); + + it('runs stored source and capabilities with current caller identity and per-run input', async () => { + const { service, runner } = setup(); + const viewer = { ...caller, role: 'VIEWER' }; + await service.run(viewer, script.id, { input: { limit: 2 }, revision: 1 }); + expect(runner.run).toHaveBeenCalledWith( + viewer, + 'server-a', + { + code: dto.code, + input: { limit: 2 }, + allowedTools: dto.allowedTools, + }, + { id: script.id, revision: 1 }, + ); + }); + + it('rejects changed saved source before execution', async () => { + const { service, runner } = setup(); + await expect( + service.run(caller, script.id, { input: {}, revision: 5 }), + ).rejects.toThrow('Reopen it before running'); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('preserves connector authorization failures from the runner', async () => { + const { service, runner } = setup(); + runner.run.mockRejectedValue(new Error('Tool access denied')); + await expect( + service.run(caller, script.id, { input: {}, revision: 1 }), + ).rejects.toThrow('Tool access denied'); + }); + + it('rejects oversized default parameters and an inactive license', async () => { + const { service, prisma, license } = setup(); + await expect( + service.create(caller, { ...dto, input: { value: 'é'.repeat(40000) } }), + ).rejects.toThrow('64 KiB'); + expect(prisma.codeboundScript.create).not.toHaveBeenCalled(); + license.checkLicenseActive.mockRejectedValue(new Error('License inactive')); + await expect(service.create(caller, dto)).rejects.toThrow( + 'License inactive', + ); + }); + + it('validates save/update/run DTOs and rejects source overrides on saved execution', async () => { + expect(await validate(plainToInstance(SaveScriptDto, dto))).toEqual([]); + expect( + (await validate(plainToInstance(UpdateScriptDto, dto))).map( + (error) => error.property, + ), + ).toContain('revision'); + const whitespace = plainToInstance(SaveScriptDto, { ...dto, name: ' ' }); + expect( + (await validate(whitespace)).map((error) => error.property), + ).toContain('name'); + const override = plainToInstance(RunSavedScriptDto, { + input: {}, + revision: 1, + code: 'return 999;', + }); + expect( + ( + await validate(override, { + whitelist: true, + forbidNonWhitelisted: true, + }) + ).map((error) => error.property), + ).toContain('code'); + }); +}); diff --git a/packages/backend/src/codebound/saved-scripts.service.ts b/packages/backend/src/codebound/saved-scripts.service.ts new file mode 100644 index 000000000..aedf2ece0 --- /dev/null +++ b/packages/backend/src/codebound/saved-scripts.service.ts @@ -0,0 +1,217 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '../generated/prisma/client'; +import { PrismaService } from '../common/prisma.service'; +import { SecurityEventService } from '../audit/security-event.service'; +import { LicenseGuardService } from '../license/license-guard.service'; +import { PaginationQueryDto } from '../common/pagination.dto'; +import { CodeboundService, ScriptCaller } from './codebound.service'; +import { + RunSavedScriptDto, + SaveScriptDto, + UpdateScriptDto, +} from './codebound.dto'; + +const SUMMARY = { + id: true, + name: true, + description: true, + mcpServerId: true, + revision: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.CodeboundScriptSelect; +const FULL = { + ...SUMMARY, + code: true, + input: true, + allowedTools: true, +} satisfies Prisma.CodeboundScriptSelect; + +@Injectable() +export class SavedScriptsService { + constructor( + private readonly prisma: PrismaService, + private readonly runner: CodeboundService, + private readonly license: LicenseGuardService, + private readonly audit: SecurityEventService, + ) {} + + private assertCanWrite(caller: ScriptCaller) { + if (!['ADMIN', 'EDITOR'].includes(caller.role ?? '')) { + throw new ForbiddenException( + 'An editor or administrator can save and delete scripts.', + ); + } + } + + private async assertServer(caller: ScriptCaller, id: string) { + const server = await this.prisma.mcpServerConfig.findFirst({ + where: { id, organizationId: caller.organizationId, isActive: true }, + select: { id: true }, + }); + if (!server) throw new NotFoundException('Active MCP server not found.'); + } + + private scriptData(dto: SaveScriptDto) { + if (!dto.name.trim()) + throw new BadRequestException('Script name is required.'); + if (Buffer.byteLength(JSON.stringify(dto.input)) > 65536) { + throw new BadRequestException('Default parameters exceed 64 KiB.'); + } + return { + name: dto.name.trim(), + description: dto.description, + mcpServerId: dto.mcpServerId, + code: dto.code, + input: dto.input as Prisma.InputJsonObject, + allowedTools: dto.allowedTools, + }; + } + + async list(caller: ScriptCaller, pagination: PaginationQueryDto) { + const where = { organizationId: caller.organizationId }; + const [items, total] = await this.prisma.$transaction([ + this.prisma.codeboundScript.findMany({ + where, + select: SUMMARY, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + skip: pagination.offset ?? 0, + take: pagination.limit ?? 20, + }), + this.prisma.codeboundScript.count({ where }), + ]); + return { items, total }; + } + + async get(caller: ScriptCaller, id: string) { + const script = await this.prisma.codeboundScript.findFirst({ + where: { id, organizationId: caller.organizationId }, + select: FULL, + }); + if (!script) throw new NotFoundException('Script not found.'); + return script; + } + + private async record( + event: string, + caller: ScriptCaller, + script: { id: string; mcpServerId: string; revision: number }, + ) { + await this.audit.log({ + event, + actorType: 'USER', + actorUserId: caller.sub, + organizationId: caller.organizationId, + metadata: { + scriptId: script.id, + serverId: script.mcpServerId, + revision: script.revision, + }, + }); + } + + async create(caller: ScriptCaller, dto: SaveScriptDto) { + this.assertCanWrite(caller); + await this.license.checkLicenseActive(caller.organizationId); + const data = this.scriptData(dto); + await this.assertServer(caller, dto.mcpServerId); + try { + const script = await this.prisma.codeboundScript.create({ + data: { ...data, organizationId: caller.organizationId }, + select: FULL, + }); + await this.record('CODEBOUND_SCRIPT_CREATED', caller, script); + return script; + } catch (error) { + // A server may have been removed between validation and the FK write. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new NotFoundException('MCP server is no longer available.'); + } + throw error; + } + } + + async update(caller: ScriptCaller, id: string, dto: UpdateScriptDto) { + this.assertCanWrite(caller); + await this.license.checkLicenseActive(caller.organizationId); + const data = this.scriptData(dto); + // Resolve the script in this workspace before considering the replacement server. + await this.get(caller, id); + await this.assertServer(caller, dto.mcpServerId); + try { + const script = await this.prisma.$transaction(async (tx) => { + const updated = await tx.codeboundScript.updateMany({ + where: { + id, + organizationId: caller.organizationId, + revision: dto.revision, + }, + data: { ...data, revision: { increment: 1 } }, + }); + if (updated.count === 0) { + const current = await tx.codeboundScript.findFirst({ + where: { id, organizationId: caller.organizationId }, + select: { id: true }, + }); + if (!current) throw new NotFoundException('Script not found.'); + throw new ConflictException( + 'Script changed. Reopen it before saving.', + ); + } + // Keep the read in the transaction so the response matches this revision. + return tx.codeboundScript.findFirstOrThrow({ + where: { id, organizationId: caller.organizationId }, + select: FULL, + }); + }); + await this.record('CODEBOUND_SCRIPT_UPDATED', caller, script); + return script; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new NotFoundException('MCP server is no longer available.'); + } + throw error; + } + } + + async delete(caller: ScriptCaller, id: string) { + this.assertCanWrite(caller); + const script = await this.get(caller, id); + const deleted = await this.prisma.codeboundScript.deleteMany({ + where: { id, organizationId: caller.organizationId }, + }); + if (deleted.count === 0) throw new NotFoundException('Script not found.'); + await this.record('CODEBOUND_SCRIPT_DELETED', caller, script); + return { deleted: true }; + } + + async run(caller: ScriptCaller, id: string, dto: RunSavedScriptDto) { + const script = await this.get(caller, id); + if (dto.revision !== script.revision) + throw new ConflictException('Script changed. Reopen it before running.'); + // Source, server and capabilities come exclusively from the stored record. + // The runner rechecks the caller's live connector permissions for each call. + return this.runner.run( + caller, + script.mcpServerId, + { + code: script.code, + input: dto.input, + allowedTools: script.allowedTools, + }, + { id: script.id, revision: script.revision }, + ); + } +} diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts index df3b91280..049740c31 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts @@ -185,6 +185,20 @@ describe('DynamicMcpTools — actionable hint on upstream errors', () => { }); describe('DynamicMcpTools — response cache', () => { + it('bypasses cache reads and writes for an audited script call', async () => { + const { executor, redis, audit, restEngine } = build(makeTool({ ...SELECT_TRANSFORM, cacheTtl: 300 }), { + cached: JSON.stringify({ tenant: 'another caller' }), + }); + const result = await executor.executeTool('list_devices', {}, { + organizationId: 'org-script', connectorIds: ['conn-1'], skipCache: true, + }); + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(restEngine.execute).toHaveBeenCalledTimes(1); + expect(audit.logInvocation).toHaveBeenCalledTimes(1); + expect(result.structured).toEqual(MAPPED); + }); + it('caches the raw response, not the rendered text', async () => { const { executor, redis } = build(makeTool({ ...SELECT_TRANSFORM, cacheTtl: 300 })); await executor.executeTool('list_devices', {}); diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index a9fc2a0c8..5eb38849f 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -221,6 +221,8 @@ export class DynamicMcpTools { mcpServerName?: string; connectorIds?: string[]; intent?: string; + /** Script reads must always execute and audit in their own caller scope. */ + skipCache?: boolean; }, ): Promise<{ content: ToolResultContent; @@ -295,7 +297,7 @@ export class DynamicMcpTools { // after up to cacheTtl seconds. const responseMapping = tool.responseMapping as ResponseMapping | undefined; const cacheTtl = responseMapping?.cacheTtl; - if (cacheTtl && cacheTtl > 0) { + if (!context?.skipCache && cacheTtl && cacheTtl > 0) { const cacheKey = this.buildCacheKey(toolName, params); const cached = await this.redisService.get(cacheKey); if (cached) { @@ -502,7 +504,7 @@ export class DynamicMcpTools { void this.kgService.scheduleObservationalIngest(context?.organizationId); // Cache the raw response if cacheTtl is set (shaping happens on read). - if (cacheTtl && cacheTtl > 0) { + if (!context?.skipCache && cacheTtl && cacheTtl > 0) { const cacheKey = this.buildCacheKey(toolName, params); // The audit log above got the bare body; the cache needs the headers // too, or a cached page would come back without its next cursor. diff --git a/packages/backend/src/mcp-server/mcp-server.module.ts b/packages/backend/src/mcp-server/mcp-server.module.ts index 8b359a660..0e387534f 100644 --- a/packages/backend/src/mcp-server/mcp-server.module.ts +++ b/packages/backend/src/mcp-server/mcp-server.module.ts @@ -33,6 +33,6 @@ const ENGINES = [ imports: [McpServersModule, LicenseModule], controllers: [McpEndpointController, WellKnownOAuthController, RegistryCatchUpController], providers: [SharedSetupRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, GraphqlSchemaService, ...ENGINES], - exports: [McpServerService, ToolRegistry, SharedSetupRegistry, OAuth2TokenService], + exports: [McpServerService, ToolRegistry, SharedSetupRegistry, OAuth2TokenService, DynamicMcpTools], }) export class McpServerModule {} diff --git a/packages/frontend/src/app/codebound/page.tsx b/packages/frontend/src/app/codebound/page.tsx new file mode 100644 index 000000000..eba8a5691 --- /dev/null +++ b/packages/frontend/src/app/codebound/page.tsx @@ -0,0 +1,656 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AppShell } from '@/components/app-shell'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useAuth } from '@/lib/auth-context'; +import { + codebound, + mcpServers, + type SavedScript, + type SavedScriptSummary, + type ScriptTool, +} from '@/lib/api'; + +const EXAMPLE = `const post = await tools.call("setup_demo_get_post", {}); +if (!post || typeof post.id !== "number" || typeof post.title !== "string") { + throw new Error("Expected a post with a numeric id and a title."); +} +return { id: post.id, title: post.title };`; +const fieldClass = + 'w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-sm'; +const PAGE_SIZE = 20; + +export default function CodeboundPage() { + const { token, user } = useAuth(); + const canWrite = user?.role !== 'VIEWER'; + const [servers, setServers] = useState<{ id: string; name: string }[]>([]); + const [serverId, setServerId] = useState(''); + const [tools, setTools] = useState([]); + const [allowedTools, setAllowedTools] = useState([]); + const [code, setCode] = useState(EXAMPLE); + const [input, setInput] = useState('{}'); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selected, setSelected] = useState(null); + const [scripts, setScripts] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [refresh, setRefresh] = useState(0); + const [listLoading, setListLoading] = useState(true); + const [loading, setLoading] = useState(true); + const [loadingTools, setLoadingTools] = useState(false); + const [busy, setBusy] = useState<'run' | 'save' | 'open' | 'delete' | null>( + null, + ); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [dirty, setDirty] = useState(false); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [output, setOutput] = useState + > | null>(null); + const selectedId = selected?.id; + + useEffect(() => { + if (!token) return; + let stale = false; + mcpServers + .list(token) + .then((items) => { + if (stale) return; + const active = items.filter((server) => server.isActive); + setServers(active); + setTools([]); + setAllowedTools([]); + setOutput(null); + setLoadingTools(active.length > 0); + setServerId(active[0]?.id ?? ''); + }) + .catch((error: Error) => { + if (!stale) setError(error.message); + }) + .finally(() => { + if (!stale) setLoading(false); + }); + return () => { + stale = true; + }; + }, [token]); + + useEffect(() => { + if (!token) return; + let stale = false; + codebound + .list(token, offset) + .then((page) => { + if (!stale) { + setScripts(page.items); + setTotal(page.total); + if (offset > 0 && offset >= page.total) + setOffset( + Math.max( + 0, + Math.floor((Math.max(page.total, 1) - 1) / PAGE_SIZE) * + PAGE_SIZE, + ), + ); + } + }) + .catch((error: Error) => { + if (!stale) setError(error.message); + }) + .finally(() => { + if (!stale) setListLoading(false); + }); + return () => { + stale = true; + }; + }, [token, offset, refresh]); + + useEffect(() => { + if (!token || !serverId) return; + let stale = false; + codebound + .tools(serverId, token) + .then((items) => { + if (stale) return; + setTools(items); + if (!selectedId) + setAllowedTools( + items.some((tool) => tool.name === 'setup_demo_get_post') + ? ['setup_demo_get_post'] + : [], + ); + }) + .catch((error: Error) => { + if (!stale) { + setTools([]); + setError(error.message); + } + }) + .finally(() => { + if (!stale) setLoadingTools(false); + }); + return () => { + stale = true; + }; + }, [token, serverId, selectedId]); + + useEffect(() => { + if (!dirty) return; + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault(); + }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [dirty]); + + function parameters(): Record { + const parsed: unknown = JSON.parse(input); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error('Parameters must be a JSON object.'); + return parsed as Record; + } + function reloadList() { + setListLoading(true); + setRefresh((value) => value + 1); + } + function discardChanges() { + return !dirty || window.confirm('Discard unsaved changes?'); + } + function newScript() { + if (!discardChanges()) return; + if (selectedId && serverId) setLoadingTools(true); + setSelected(null); + setName(''); + setDescription(''); + setCode(EXAMPLE); + setInput('{}'); + setOutput(null); + setError(''); + setNotice(''); + setDirty(false); + } + async function openScript(id: string) { + if (!token || !discardChanges()) return; + setBusy('open'); + setError(''); + setNotice(''); + try { + const script = await codebound.get(id, token); + if (script.id !== selectedId || script.mcpServerId !== serverId) + setLoadingTools(true); + setSelected(script); + setName(script.name); + setDescription(script.description); + setServerId(script.mcpServerId); + setCode(script.code); + setInput(JSON.stringify(script.input, null, 2)); + setAllowedTools(script.allowedTools); + setOutput(null); + setDirty(false); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not open script.', + ); + } finally { + setBusy(null); + } + } + async function save() { + if (!token) return; + setBusy('save'); + setError(''); + setNotice(''); + try { + const body = { + name: name.trim(), + description, + mcpServerId: serverId, + code, + input: parameters(), + allowedTools, + }; + const saved = selected + ? await codebound.update( + selected.id, + { ...body, revision: selected.revision }, + token, + ) + : await codebound.create(body, token); + if (!selectedId) setLoadingTools(true); + setSelected(saved); + setName(saved.name); + setDirty(false); + setNotice('Script saved.'); + setOffset(0); + reloadList(); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not save script.', + ); + } finally { + setBusy(null); + } + } + const runsSavedSource = + selected && + selected.code === code && + selected.mcpServerId === serverId && + JSON.stringify(selected.allowedTools) === JSON.stringify(allowedTools); + async function run() { + if (!token) return; + setBusy('run'); + setError(''); + setOutput(null); + setNotice(''); + try { + const params = parameters(); + setOutput( + selected && runsSavedSource + ? await codebound.runSaved( + selected.id, + params, + selected.revision, + token, + ) + : await codebound.run( + serverId, + { code, input: params, allowedTools }, + token, + ), + ); + } catch (error) { + setError(error instanceof Error ? error.message : 'Script failed.'); + } finally { + setBusy(null); + } + } + async function remove() { + if (!token || !deleteTarget) return; + setBusy('delete'); + setError(''); + try { + await codebound.delete(deleteTarget.id, token); + if (selectedId === deleteTarget.id) { + setSelected(null); + setName(''); + setDescription(''); + setCode(EXAMPLE); + setInput('{}'); + setOutput(null); + setDirty(false); + if (serverId) setLoadingTools(true); + } + setDeleteTarget(null); + setNotice('Script deleted.'); + if (scripts.length === 1 && offset > 0) + setOffset(Math.max(0, offset - PAGE_SIZE)); + reloadList(); + } catch (error) { + setError( + error instanceof Error ? error.message : 'Could not delete script.', + ); + } finally { + setBusy(null); + } + } + const unavailable = allowedTools.filter( + (name) => !tools.some((tool) => tool.name === name), + ); + + return ( + +
+ +
+

Saved scripts ({total})

+
+ + +
+
+ {scripts.length === 0 && ( +

+ {listLoading + ? 'Loading saved scripts…' + : 'No saved scripts yet. Name your script and save it below.'} +

+ )} + {scripts.map((script) => ( +
+
+

{script.name}

+

+ {script.description} +

+
+ + {canWrite && ( + + )} +
+ ))} + {total > PAGE_SIZE && ( +
+ + + {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} + + +
+ )} +
+ {deleteTarget && ( + +

Delete “{deleteTarget.name}”? This removes the saved script.

+
+ + +
+
+ )} +

+ Call tools with await tools.call(name, args), read + parameters from input, and return a JSON value. + {selected + ? ` Editing “${selected.name}”.` + : ' Start a new script below.'} + {dirty && ' Unsaved changes.'} +

+ {!canWrite && ( +

+ Viewers can open and run scripts. An editor or administrator can + save and delete them. +

+ )} + + +